Zachowanie stanu i pamięć trwała

Zachowywanie stanu i trwałe przechowywanie to niebanalne aspekty aplikacji do pisania odręcznego, zwłaszcza w Compose. Podstawowe obiekty danych, takie jak właściwości pędzla i punkty tworzące pociągnięcie, są złożone i nie są automatycznie zapisywane. Wymaga to przemyślanej strategii zapisywania stanu w sytuacjach takich jak zmiany konfiguracji i trwałe zapisywanie rysunków użytkownika w bazie danych.

Zachowywanie stanu

W Jetpack Compose stan interfejsu jest zwykle zarządzany za pomocą funkcji remember i rememberSaveable. Chociaż rememberSaveable oferuje automatyczne zachowywanie stanu podczas zmian konfiguracji, jego wbudowane możliwości są ograniczone do prostych typów danych i obiektów, które implementują Parcelable lub Serializable.

W przypadku obiektów niestandardowych zawierających złożone właściwości, takie jak Brush, musisz zdefiniować mechanizmy serializacji i deserializacji za pomocą niestandardowego narzędzia do zapisywania stanu. Definiując niestandardowy Saver dla obiektu Brush, możesz zachować podstawowe atrybuty pędzla, gdy nastąpią zmiany konfiguracji, jak pokazano w tym brushStateSaver przykładzie.

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

Następnie możesz użyć niestandardowego Saver, aby zachować wybrany stan pędzla:

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

Pamięć trwała

Aby włączyć funkcje takie jak zapisywanie i wczytywanie dokumentów oraz potencjalną współpracę w czasie rzeczywistym, przechowuj pociągnięcia i powiązane z nimi dane w formacie serializowanym. W przypadku interfejsu Ink API konieczne jest ręczne serializowanie i deserializowanie.

Aby dokładnie przywrócić pociągnięcie, zapisz jego BrushStrokeInputBatch.

Moduł Storage upraszcza kompaktową serializację najbardziej złożonej części: StrokeInputBatch.

Aby zapisać pociągnięcie:

  • Zserializuj StrokeInputBatch za pomocą funkcji kodowania modułu pamięci. Przechowuj wynikowe dane binarne.
  • Zapisz osobno najważniejsze właściwości pędzla:
    • Wyliczenie reprezentujące rodzinę pędzli – chociaż instancję można serializować, nie jest to wydajne w przypadku aplikacji, które używają ograniczonego wyboru rodzin pędzli.
    • 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
  )
}

Aby wczytać obiekt pociągnięcia:

  • Pobierz zapisane dane binarne dla StrokeInputBatch i zdeserializuj je za pomocą funkcji decode() modułu pamięci.
  • Pobierz zapisane właściwości Brush i utwórz pędzel.
  • Utwórz ostatnie pociągnięcie za pomocą odtworzonego pędzla i zdeserializowanego obiektu 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)
    }
    

Obsługa powiększania, przesuwania i obracania

Jeśli Twoja aplikacja obsługuje powiększanie, przesuwanie lub obracanie, musisz podać bieżącą transformację do InProgressStrokes. Dzięki temu nowo narysowane pociągnięcia będą pasować do pozycji i skali istniejących pociągnięć.

W tym celu przekaż wartość Matrix do parametru pointerEventToWorldTransform. Macierz powinna reprezentować odwrotność przekształcenia, które stosujesz do gotowego obszaru roboczego z pociągnięciami.

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

Eksportowanie pociągnięć

Może być konieczne wyeksportowanie sceny z pociągnięciami pędzla jako statycznego pliku obrazu. Jest to przydatne do udostępniania rysunku innym aplikacjom, generowania miniatur lub zapisywania ostatecznej, nieedytowalnej wersji treści.

Aby wyeksportować scenę, możesz wyrenderować pociągnięcia pędzla do bitmapy poza ekranem zamiast bezpośrednio na ekranie. Użyj funkcji Android's Picture API, która umożliwia nagrywanie rysunków na obszarze roboczym bez konieczności korzystania z widocznego komponentu interfejsu.

Proces ten obejmuje utworzenie instancji Picture, wywołanie beginRecording() w celu uzyskania Canvas, a następnie użycie istniejącego CanvasStrokeRenderer do narysowania każdego pociągnięcia na tym Canvas. Po zarejestrowaniu wszystkich poleceń rysowania możesz użyć ikony Picture, aby utworzyć Bitmap, który możesz następnie skompresować i zapisać w pliku.

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

Obiekty danych i pomocnicze funkcje konwertera

Określ strukturę obiektu serializacji, która odzwierciedla potrzebne obiekty interfejsu Ink API.

Użyj modułu pamięci interfejsu Ink API, aby kodować i dekodować StrokeInputBatch.

Obiekty przenoszenia danych
@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,
}
Użytkownicy dokonujący konwersji
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,
    )
  }
}