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

Potresti dover esportare la scena del tratto come file immagine statica. Questa funzionalità è utile per condividere il disegno con altre applicazioni, generare miniature o salvare una versione finale e non modificabile dei contenuti.

Per esportare una scena, puoi eseguire il rendering dei tratti in una bitmap off-screen anziché direttamente sullo schermo. Utilizza Android's Picture API, che ti consente di registrare disegni su una tela senza bisogno di un componente UI visibile.

La procedura prevede la creazione di un'istanza Picture, la chiamata di beginRecording() per ottenere un Canvas e l'utilizzo del CanvasStrokeRenderer esistente per disegnare ogni tratto su quel Canvas. Dopo aver registrato tutti i comandi di disegno, puoi utilizzare Picture per creare un Bitmap, che puoi comprimere e salvare in un 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)
}

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