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

Để bật các tính năng như lưu, tải tài liệu và cộng tác theo thời gian thực (nếu có), hãy lưu trữ nét vẽ và dữ liệu liên quan ở định dạng được chuyển đổi tuần tự. Đối với Ink API, bạn cần phải chuyển đổi tuần tự và huỷ chuyển đổi tuần tự theo cách thủ công.

Để khôi phục chính xác một nét vẽ, hãy lưu BrushStrokeInputBatch của nét vẽ đó.

  • Brush: Bao gồm các trường số (kích thước, epsilon), màu sắc và BrushFamily.
  • StrokeInputBatch: Một danh sách các điểm đầu vào có trường số.

Mô-đun Storage (Bộ nhớ) giúp đơn giản hoá việc tuần tự hoá một cách gọn gàng phần phức tạp nhất: StrokeInputBatch.

Cách lưu nét vẽ:

  • Nối tiếp StrokeInputBatch bằng hàm mã hoá của mô-đun lưu trữ. Lưu trữ dữ liệu nhị phân thu được.
  • Lưu riêng các thuộc tính thiết yếu của Brush (Cọ) trong nét vẽ:
    • Enum đại diện cho họ cọ &mdash Mặc dù có thể chuyển đổi thực thể này thành chuỗi, nhưng việc này không hiệu quả đối với những ứng dụng sử dụng một số ít họ cọ
    • 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
  )
}

Cách tải một đối tượng nét vẽ:

  • Truy xuất dữ liệu nhị phân đã lưu cho StrokeInputBatch và giải tuần tự hoá dữ liệu đó bằng hàm decode() của mô-đun lưu trữ.
  • Truy xuất các thuộc tính Brush đã lưu và tạo cọ vẽ.
  • Tạo nét vẽ cuối cùng bằng cọ vẽ được tạo lại và StrokeInputBatch đã chuyển đổi tuần tự.

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

Xử lý thao tác thu phóng, dịch chuyển và xoay

Nếu ứng dụng của bạn hỗ trợ thao tác thu phóng, xoay hoặc di chuyển, bạn phải cung cấp thông tin biến đổi hiện tại cho InProgressStrokes. Điều này giúp các nét vẽ mới vẽ khớp với vị trí và tỷ lệ của các nét vẽ hiện có.

Bạn thực hiện việc này bằng cách truyền một Matrix đến tham số pointerEventToWorldTransform. Ma trận này phải biểu thị nghịch đảo của phép biến đổi mà bạn áp dụng cho canvas nét vẽ đã hoàn thành.

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

Xuất nét vẽ

Bạn có thể cần xuất cảnh nét vẽ dưới dạng tệp hình ảnh tĩnh. Điều này hữu ích khi chia sẻ bản vẽ với các ứng dụng khác, tạo hình thu nhỏ hoặc lưu phiên bản cuối cùng của nội dung mà không thể chỉnh sửa.

Để xuất một cảnh, bạn có thể kết xuất các nét vẽ của mình thành một bitmap ngoài màn hình thay vì trực tiếp lên màn hình. Sử dụng Android's Picture API. Thành phần này cho phép bạn ghi lại bản vẽ trên canvas mà không cần thành phần giao diện người dùng hiển thị.

Quy trình này bao gồm việc tạo một thực thể Picture, gọi beginRecording() để lấy một Canvas, sau đó dùng CanvasStrokeRenderer hiện có để vẽ từng nét lên Canvas đó. Sau khi ghi lại tất cả các lệnh vẽ, bạn có thể dùng Picture để tạo Bitmap. Sau đó, bạn có thể nén và lưu tệp này.

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

Đối tượng dữ liệu và các trình trợ giúp trình chuyển đổi

Xác định cấu trúc đối tượng tuần tự hoá phản ánh các đối tượng Ink API cần thiết.

Sử dụng mô-đun lưu trữ của Ink API để mã hoá và giải mã StrokeInputBatch.

Đối tượng chuyển dữ liệu
@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,
}
Người chuyển đổi
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,
    )
  }
}