Conservazione dello stato e archiviazione permanente

La conservazione dello stato e l'archiviazione permanente sono aspetti non banali delle app di inchiostro digitale, soprattutto in Compose. Gli oggetti dati principali, come le proprietà del pennello e i punti che formano un tratto, sono complessi e non vengono salvati automaticamente. Ciò richiede una strategia deliberata per il salvataggio dello stato durante scenari come modifiche alla configurazione e salvataggio permanente dei disegni di un utente in un database.

Conservazione dello stato

In Jetpack Compose, lo stato dell'UI viene in genere gestito utilizzando remember e rememberSaveable. Sebbene rememberSaveable offra la conservazione automatica dello stato in caso di modifiche alla configurazione, le sue funzionalità integrate sono limitate a tipi di dati primitivi e oggetti che implementano Parcelable o Serializable.

Per gli oggetti personalizzati che contengono proprietà complesse, ad esempio Brush, devi definire meccanismi espliciti di serializzazione e deserializzazione utilizzando un salvataggio dello stato personalizzato. Definendo un Saver personalizzato per l'oggetto Brush, puoi conservare gli attributi essenziali del pennello quando si verificano modifiche alla configurazione, come mostrato nell'esempio brushStateSaver seguente.

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

Puoi quindi utilizzare il Saver personalizzato per mantenere lo stato del pennello selezionato:

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

Archiviazione permanente

Per abilitare funzionalità come il salvataggio, il caricamento e la potenziale collaborazione in tempo reale, memorizza i tratti e i dati associati in un formato serializzato. Per l'API Ink, sono necessarie la serializzazione e la deserializzazione manuali.

Per ripristinare con precisione un tratto, salva il relativo Brush e StrokeInputBatch.

Il modulo Storage semplifica la serializzazione compatta della parte più complessa: il StrokeInputBatch.

Per salvare un tratto:

  • Serializza StrokeInputBatch utilizzando la funzione di codifica del modulo di archiviazione. Archivia i dati binari risultanti.
  • Salva separatamente le proprietà essenziali del pennello del tratto:
    • L'enumerazione che rappresenta la famiglia di pennelli. Anche se l'istanza può essere serializzata, questa operazione non è efficiente per le app che utilizzano una selezione limitata di famiglie di pennelli.
    • 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
  )
}

Per caricare un oggetto tratto:

  • Recupera i dati binari salvati per StrokeInputBatch e deserializzali utilizzando la funzione decode() del modulo di archiviazione.
  • Recupera le proprietà Brush salvate e crea il pennello.
  • Crea il tratto finale utilizzando il pennello ricreato e il StrokeInputBatch deserializzato.

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

Gestire zoom, panoramica e rotazione

Se la tua app supporta lo zoom, lo spostamento o la rotazione, devi fornire la trasformazione attuale a InProgressStrokes. In questo modo, i tratti appena disegnati corrispondono alla posizione e alla scala dei tratti esistenti.

Per farlo, passa un Matrix al parametro pointerEventToWorldTransform. La matrice deve rappresentare l'inverso della trasformazione che applichi al canvas dei tratti finiti.

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

Esportare i tratti

您可能需要将笔画场景导出为静态图片文件。这对于与其他应用分享绘画、生成缩略图或保存最终的不可编辑版本的内容非常有用。

如需导出场景,您可以将笔画渲染到屏幕外位图,而不是直接渲染到屏幕。使用 Android's Picture API,这样您就可以在画布上录制绘画,而无需使用可见的界面组件。

该过程包括创建 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)
}

Helper per oggetti dati e convertitori

Definisci una struttura di oggetti di serializzazione che rispecchi gli oggetti API Ink necessari.

Utilizza il modulo di archiviazione dell'API Ink per codificare e decodificare StrokeInputBatch.

Oggetti di trasferimento dei dati
@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,
}
Utenti che hanno completato una conversione
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,
    )
  }
}