狀態保留與永久儲存空間

狀態保留和永久儲存空間是墨水筆跡應用程式的重要環節,尤其是在 Compose 中。筆刷屬性和構成筆劃的點等核心資料物件相當複雜,不會自動保留。這需要有策略地在設定變更等情況下儲存狀態,並將使用者的繪圖永久儲存至資料庫。

保留狀態

在 Jetpack Compose 中,UI 狀態通常是使用 rememberrememberSaveable 管理。雖然 rememberSaveable 可在設定變更期間自動保留狀態,但內建功能僅限於基本資料型別,以及實作 ParcelableSerializable 的物件。

如果是含有複雜屬性的自訂物件 (例如 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) }

永久儲存空間

如要啟用儲存、載入文件和即時協作等功能,請以序列化格式儲存筆劃和相關資料。使用 Ink API 時,必須手動序列化和去序列化。

如要準確還原筆劃,請儲存筆劃的 BrushStrokeInputBatch

Storage 模組可簡化最複雜部分的緊湊序列化作業:StrokeInputBatch

如何儲存筆劃:

  • 使用儲存空間模組的編碼函式,將 StrokeInputBatch 序列化。 儲存產生的二進位資料。
  • 分別儲存筆劃 Brush 的必要屬性:
    • 代表筆刷系列的列舉 &mdash 雖然可以序列化執行個體,但對於使用有限筆刷系列選取的應用程式而言,這並非有效率的做法
    • 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,即可在畫布上錄製繪圖,不必顯示 UI 元件。

這個程序包括建立 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,
    )
  }
}