Bảo tồn trạng thái và lưu trữ lâu dài

Việc lưu giữ trạng thái và bộ nhớ liên tục là những khía cạnh quan trọng của các ứng dụng viết mực, đặc biệt là trong Compose. Các đối tượng dữ liệu cốt lõi (chẳng hạn như thuộc tính cọ và các điểm tạo thành nét vẽ) rất phức tạp và không tự động duy trì. Điều này đòi hỏi một chiến lược có chủ ý để lưu trạng thái trong các trường hợp như thay đổi cấu hình và lưu vĩnh viễn bản vẽ của người dùng vào cơ sở dữ liệu.

Lưu giữ trạng thái

Trong Jetpack Compose, trạng thái giao diện người dùng thường được quản lý bằng rememberrememberSaveable. Mặc dù rememberSaveable cung cấp khả năng tự động lưu giữ trạng thái trong quá trình thay đổi cấu hình, nhưng các chức năng tích hợp của thành phần này chỉ giới hạn ở các kiểu dữ liệu nguyên thuỷ và các đối tượng triển khai Parcelable hoặc Serializable.

Đối với các đối tượng tuỳ chỉnh có chứa các thuộc tính phức tạp, chẳng hạn như Brush, bạn phải xác định cơ chế tuần tự hoá và giải tuần tự hoá rõ ràng bằng cách sử dụng trình lưu trạng thái tuỳ chỉnh. Bằng cách xác định Saver tuỳ chỉnh cho đối tượng Brush, bạn có thể giữ lại các thuộc tính thiết yếu của cọ khi xảy ra thay đổi về cấu hình, như minh hoạ trong ví dụ brushStateSaver sau.

fun brushStateSaver(converters: Converters): Saver<MutableState<Brush>, SerializedBrush> = Saver(
    save = { converters.serializeBrush(it.value) },
    restore = { mutableStateOf(converters.deserializeBrush(it)) },
)

Sau đó, bạn có thể dùng Saver tuỳ chỉnh để duy trì trạng thái bút vẽ đã chọn:

val currentBrush = rememberSaveable(saver = brushStateSaver(Converters())) { mutableStateOf(defaultBrush) }

Bộ nhớ liên tục

To enable features such as document saving, loading, and potential real-time collaboration, store strokes and associated data in a serialized format. For the Ink API, manual serialization and deserialization are necessary.

To accurately restore a stroke, save its Brush and StrokeInputBatch.

The Storage module simplifies compactly serializing the most complex part: the StrokeInputBatch.

To save a stroke:

  • Serialize the StrokeInputBatch using the storage module's encode function. Store the resulting binary data.
  • Separately save the essential properties of the stroke's Brush:
    • The enum that represents the brush family &mdash Although the instance can be serialized, this is not efficient for apps that use a limited selection of brush families
    • 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
  )
}

To load a stroke object:

  • Retrieve the saved binary data for the StrokeInputBatch and deserialize it using the storage module's decode() function.
  • Retrieve the saved Brush properties and create the brush.
  • Create the final stroke using the recreated brush and the deserialized 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)
    }
    

Handle zoom, pan, and rotation

If your app supports zooming, panning, or rotation, you must provide the current transformation to InProgressStrokes. This helps newly drawn strokes match the position and scale of your existing strokes.

You do this by passing a Matrix to the pointerEventToWorldTransform parameter. The matrix should represent the inverse of the transformation you apply to your finished strokes canvas.

@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
        )
    }
}

Export strokes

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)
}

Data object and converter helpers

Define a serialization object structure that mirrors needed Ink API objects.

Use the Ink API's storage module to encode and decode StrokeInputBatch.

Data transfer objects
@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,
    )
  }
}