状態の保持と永続ストレージ

状態の保持と永続ストレージは、特に Compose では、インクアプリの重要な側面です。ブラシのプロパティやストロークを形成するポイントなどのコアデータ オブジェクトは複雑で、自動的に永続化されません。これには、構成の変更などのシナリオで状態を保存したり、ユーザーの描画をデータベースに永続的に保存したりするための、慎重な戦略が必要です。

状態の保持

Jetpack Compose では、通常、rememberrememberSaveable を使用して UI の状態を管理します。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) }

永続ストレージ

ドキュメントの保存、読み込み、リアルタイム コラボレーションなどの機能を有効にするには、ストロークと関連データをシリアル化された形式で保存します。Ink API の場合は、手動でシリアル化とシリアル化解除を行う必要があります。

ストロークを正確に復元するには、その BrushStrokeInputBatch を保存します。

  • Brush: 数値フィールド(サイズ、イプシロン)、色、BrushFamily が含まれます。
  • StrokeInputBatch: 数値フィールドを含む入力ポイントのリスト。

Storage モジュールは、最も複雑な部分である StrokeInputBatch のシリアル化をコンパクトに簡素化します。

ストロークを保存するには:

  • ストレージ モジュールのエンコード関数を使用して StrokeInputBatch をシリアル化します。結果のバイナリデータを保存します。
  • ストロークのブラシの重要なプロパティを個別に保存します。
    • ブラシ ファミリーを表す列挙型 &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 に提供する必要があります。これにより、新しく描画したストロークが既存のストロークの位置とスケールに一致します。

これを行うには、MatrixpointerEventToWorldTransform パラメータに渡します。この行列は、完成したストローク キャンバスに適用する変換の逆変換を表す必要があります。

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