La conservación del estado y el almacenamiento persistente son aspectos no triviales de las apps de tinta, en especial en Compose. Los objetos de datos principales, como las propiedades del pincel y los puntos que forman un trazo, son complejos y no se conservan automáticamente. Esto requiere una estrategia deliberada para guardar el estado en situaciones como los cambios de configuración y guardar de forma permanente los dibujos de un usuario en una base de datos.
状态保留
在 Jetpack Compose 中,界面状态通常使用 remember 和 rememberSaveable 进行管理。虽然 rememberSaveable 可在配置更改期间自动保留状态,但其内置功能仅限于实现 Parcelable 或 Serializable 的基本数据类型和对象。
对于包含复杂属性(例如 Brush)的自定义对象,您必须使用自定义状态保存器定义显式序列化和反序列化机制。通过为 Brush 对象定义自定义 Saver,您可以在配置发生更改时保留画笔的基本属性,如以下 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) }
Almacenamiento persistente
Para habilitar funciones como guardar y cargar documentos, y la posible colaboración en tiempo real, almacenamos los trazos y los datos asociados en un formato serializado. En el caso de la API de Ink, es necesaria la serialización y deserialización manuales.
Para restablecer un trazo con precisión, guarda su Brush y StrokeInputBatch.
Brush: Incluye campos numéricos (tamaño, épsilon), color yBrushFamily.StrokeInputBatch: Es una lista de puntos de entrada con campos numéricos.
El módulo Storage simplifica la serialización compacta de la parte más compleja: StrokeInputBatch.
Para guardar un trazo, haz lo siguiente:
- Serializa el
StrokeInputBatchcon la función de codificación del módulo de almacenamiento. Almacena los datos binarios resultantes. - Guarda por separado las propiedades esenciales del pincel del trazo:
- Es la enumeración que representa la familia de pinceles. Si bien la instancia se puede serializar, esto no es eficiente para las apps que usan una selección limitada de familias de pinceles.
colorLongsizeepsilon
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
)
}
Para cargar un objeto de trazo, haz lo siguiente:
- Recupera los datos binarios guardados para
StrokeInputBatchy deserialízalos con la función decode() del módulo de almacenamiento. - Recupera las propiedades de
Brushguardadas y crea el pincel. Crea el trazo final con el pincel recreado y el objeto
StrokeInputBatchdeserializado.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) }
Cómo controlar el zoom, el desplazamiento lateral y la rotación
Si tu app admite el acercamiento, el desplazamiento o la rotación, debes proporcionar la transformación actual a InProgressStrokes. Esto ayuda a que los trazos recién dibujados coincidan con la posición y la escala de los trazos existentes.
Para ello, pasa un Matrix al parámetro pointerEventToWorldTransform. La matriz debe representar la inversa de la transformación que aplicas al lienzo de trazos terminados.
@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
)
}
}
Cómo exportar trazos
You might need to export your stroke scene as a static image file. This is useful for sharing the drawing with other applications, generating thumbnails, or saving a final, uneditable version of the content.
To export a scene, you can render your strokes to an offscreen bitmap instead of
directly to the screen. Use
Android's Picture API, which lets you record drawings on a canvas without
needing a visible UI component.
The process involves creating a Picture instance, calling beginRecording()
to get a Canvas, and then using your existing CanvasStrokeRenderer to draw
each stroke onto that Canvas. After you record all the drawing commands, you
can use the Picture to create a Bitmap,
which you can then compress and save to a file.
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)
}
Objetos de datos y asistentes de conversión
Define una estructura de objeto de serialización que refleje los objetos de la API de Ink necesarios.
Usa el módulo de almacenamiento de la API de Ink para codificar y decodificar StrokeInputBatch.
Objetos de transferencia de datos
@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,
}
Converters
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,
)
}
}