Сохранение состояния и постоянное хранение данных — нетривиальные аспекты приложений для рисования от руки, особенно в Compose. Основные объекты данных, такие как свойства кисти и точки, формирующие обводку, сложны и не сохраняются автоматически. Это требует продуманной стратегии сохранения состояния в таких сценариях, как изменение конфигурации и постоянное сохранение рисунков пользователя в базе данных.
сохранение государства
В Jetpack Compose состояние пользовательского интерфейса обычно управляется с помощью remember и rememberSaveable . Хотя rememberSaveable обеспечивает автоматическое сохранение состояния при изменении конфигурации, его встроенные возможности ограничены примитивными типами данных и объектами, реализующими интерфейсы Parcelable или Serializable .
Для пользовательских объектов, содержащих сложные свойства, таких как Brush , необходимо определить явные механизмы сериализации и десериализации с помощью пользовательского механизма сохранения состояния. Определив пользовательский механизм Saver для объекта Brush , вы можете сохранить основные атрибуты кисти при изменении конфигурации, как показано в следующем примере brushStateSaver .
fun brushStateSaver(converters: Converters): Saver<MutableState<Brush>, SerializedBrush> = Saver(
save = { converters.serializeBrush(it.value) },
restore = { mutableStateOf(converters.deserializeBrush(it)) },
)
Затем вы можете использовать настраиваемую Saver , чтобы сохранить выбранное состояние кисти:
val currentBrush = rememberSaveable(saver = brushStateSaver(Converters())) { mutableStateOf(defaultBrush) }
Постоянное хранение
Для обеспечения таких функций, как сохранение и загрузка документов, а также возможность совместной работы в режиме реального времени, необходимо хранить штрихи и связанные с ними данные в сериализованном формате. Для API Ink требуется ручная сериализация и десериализация.
Для точного восстановления штриха сохраните его Brush и StrokeInputBatch .
-
Brush: включает числовые поля (размер, эпсилон), цвет иBrushFamily. -
StrokeInputBatch: Список входных точек с числовыми полями.
Модуль Storage упрощает компактную сериализацию наиболее сложной части: StrokeInputBatch .
Чтобы предотвратить инсульт:
- Сериализуйте объект
StrokeInputBatchиспользуя функцию кодирования модуля хранения. Сохраните полученные двоичные данные. - Сохраните отдельно основные свойства кисти для мазка:
- Перечисление, представляющее семейство кистей — Хотя экземпляр можно сериализовать, это неэффективно для приложений, использующих ограниченный набор семейств кистей.
-
colorLong -
size -
epsilon
fun serializeStroke(stroke: Stroke): SerializedStroke {
val serializedBrush = serializeBrush(stroke.brush)
val encodedSerializedInputs = ByteArrayOutputStream().use
{
stroke.inputs.encode(it)
it.toByteArray()
}
return SerializedStroke(
inputs = encodedSerializedInputs,
brush = serializedBrush
)
}
Чтобы загрузить объект обводки:
- Получите сохраненные двоичные данные для объекта
StrokeInputBatchи десериализуйте их, используя функцию decode() модуля хранения. - Получите сохраненные свойства
Brushи создайте кисть. Создайте финальный штрих, используя воссозданную кисть и десериализованный объект
StrokeInputBatch.fun deserializeStroke(serializedStroke: SerializedStroke): Stroke { val inputs = ByteArrayInputStream(serializedStroke.inputs).use { StrokeInputBatch.decode(it) } val brush = deserializeBrush(serializedStroke.brush) return Stroke(brush = brush, inputs = inputs) }
Управление масштабированием, панорамированием и вращением.
Если ваше приложение поддерживает масштабирование, панорамирование или вращение, необходимо указать текущее преобразование в InProgressStrokes . Это поможет новым штрихам соответствовать положению и масштабу уже существующих.
Для этого передайте Matrix в параметр pointerEventToWorldTransform . Матрица должна представлять собой обратное преобразование, которое вы применяете к готовому холсту с контурами.
@Composable
fun ZoomableDrawingScreen(...) {
// 1. Manage your zoom/pan state (e.g., using detectTransformGestures).
var zoom by remember { mutableStateOf(1f) }
var pan by remember { mutableStateOf(Offset.Zero) }
// 2. Create the Matrix.
val pointerEventToWorldTransform = remember(zoom, pan) {
android.graphics.Matrix().apply {
// Apply the inverse of your rendering transforms
postTranslate(-pan.x, -pan.y)
postScale(1 / zoom, 1 / zoom)
}
}
Box(modifier = Modifier.fillMaxSize()) {
// ...Your finished strokes Canvas, with regular transform applied
// 3. Pass the matrix to InProgressStrokes.
InProgressStrokes(
modifier = Modifier.fillMaxSize(),
pointerEventToWorldTransform = pointerEventToWorldTransform,
defaultBrush = currentBrush,
nextBrush = onGetNextBrush,
onStrokesFinished = onStrokesFinished
)
}
}
Экспорт ударов
Возможно, вам потребуется экспортировать сцену с контурами в виде статического графического файла. Это полезно для обмена рисунком с другими приложениями, создания эскизов или сохранения окончательной, не подлежащей редактированию версии содержимого.
Для экспорта сцены вы можете отрисовывать свои линии на внеэкранном растровом изображении вместо того, чтобы выводить их непосредственно на экран. Используйте Android's Picture API , который позволяет записывать рисунки на холсте без необходимости использования видимого компонента пользовательского интерфейса.
Процесс включает в себя создание экземпляра Picture , вызов beginRecording() для получения Canvas , а затем использование существующего CanvasStrokeRenderer для отрисовки каждого штриха на этом Canvas . После записи всех команд рисования вы можете использовать Picture для создания Bitmap , который затем можно сжать и сохранить в файл.
fun exportDocumentAsImage() {
val picture = Picture()
val canvas = picture.beginRecording(bitmapWidth, bitmapHeight)
// The following is similar logic that you'd use in your custom View.onDraw or Compose Canvas.
for (item in myDocument) {
when (item) {
is Stroke -> {
canvasStrokeRenderer.draw(canvas, stroke, worldToScreenTransform)
}
// Draw your other types of items to the canvas.
}
}
// Create a Bitmap from the Picture and write it to a file.
val bitmap = Bitmap.createBitmap(picture)
val outstream = FileOutputStream(filename)
bitmap.compress(Bitmap.CompressFormat.PNG, 100, outstream)
}
Вспомогательные средства для работы с объектами данных и преобразователями
Определите структуру объектов сериализации, которая соответствует необходимым объектам Ink API.
Используйте модуль хранения Ink API для кодирования и декодирования StrokeInputBatch .
Объекты передачи данных
@Parcelize
@Serializable
data class SerializedStroke(
val inputs: ByteArray,
val brush: SerializedBrush
) : Parcelable {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is SerializedStroke) return false
if (!inputs.contentEquals(other.inputs)) return false
if (brush != other.brush) return false
return true
}
override fun hashCode(): Int {
var result = inputs.contentHashCode()
result = 31 * result + brush.hashCode()
return result
}
}
@Parcelize
@Serializable
data class SerializedBrush(
val size: Float,
val color: Long,
val epsilon: Float,
val stockBrush: SerializedStockBrush,
val clientBrushFamilyId: String? = null
) : Parcelable
enum class SerializedStockBrush {
Marker,
PressurePen,
Highlighter,
DashedLine,
}
Конвертеры
object Converters {
private val stockBrushToEnumValues = mapOf(
StockBrushes.marker() to SerializedStockBrush.Marker,
StockBrushes.pressurePen() to SerializedStockBrush.PressurePen,
StockBrushes.highlighter() to SerializedStockBrush.Highlighter,
StockBrushes.dashedLine() to SerializedStockBrush.DashedLine,
)
private val enumToStockBrush =
stockBrushToEnumValues.entries.associate { (key, value) -> value to key
}
private fun serializeBrush(brush: Brush): SerializedBrush {
return SerializedBrush(
size = brush.size,
color = brush.colorLong,
epsilon = brush.epsilon,
stockBrush = stockBrushToEnumValues[brush.family] ?: SerializedStockBrush.Marker,
)
}
fun serializeStroke(stroke: Stroke): SerializedStroke {
val serializedBrush = serializeBrush(stroke.brush)
val encodedSerializedInputs = ByteArrayOutputStream().use { outputStream ->
stroke.inputs.encode(outputStream)
outputStream.toByteArray()
}
return SerializedStroke(
inputs = encodedSerializedInputs,
brush = serializedBrush
)
}
private fun deserializeStroke(
serializedStroke: SerializedStroke,
): Stroke? {
val inputs = ByteArrayInputStream(serializedStroke.inputs).use { inputStream ->
StrokeInputBatch.decode(inputStream)
}
val brush = deserializeBrush(serializedStroke.brush, customBrushes)
return Stroke(brush = brush, inputs = inputs)
}
private fun deserializeBrush(
serializedBrush: SerializedBrush,
): Brush {
val stockBrushFamily = enumToStockBrush[serializedBrush.stockBrush]
val brushFamily = customBrush?.brushFamily ?: stockBrushFamily ?: StockBrushes.marker()
return Brush.createWithColorLong(
family = brushFamily,
colorLong = serializedBrush.color,
size = serializedBrush.size,
epsilon = serializedBrush.epsilon,
)
}
}