שימור מצב ואחסון מתמיד

שמירת מצב ואחסון קבוע הם היבטים חשובים של אפליקציות לציור בדיו דיגיטלי, במיוחד ב-Compose. אובייקטי הנתונים הבסיסיים, כמו מאפייני המברשת והנקודות שיוצרות קו, הם מורכבים ולא נשמרים אוטומטית. לכן נדרשת אסטרטגיה מחושבת לשמירת מצב במהלך תרחישים כמו שינויים בהגדרות ושמירה קבועה של ציורים של משתמשים במסד נתונים.

State preservation

In Jetpack Compose, UI state is typically managed using remember and rememberSaveable. While rememberSaveable offers automatic state preservation across configuration changes, its built-in capabilities are limited to primitive data types and objects that implement Parcelable or Serializable.

For custom objects that contain complex properties, such as Brush, you must define explicit serialization and deserialization mechanisms using a custom state saver. By defining a custom Saver for the Brush object, you can preserve the brush's essential attributes when configuration changes occur, as shown in the following brushStateSaver example.

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

You can then use the custom Saver to preserve the selected brush state:

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

אחסון קבוע

כדי להפעיל תכונות כמו שמירה, טעינה ושיתוף פעולה בזמן אמת, צריך לאחסן את הקווים והנתונים המשויכים בפורמט סריאלי. ב-Ink API, צריך לבצע סריאליזציה ודה-סריאליזציה באופן ידני.

כדי לשחזר קו בצורה מדויקת, שומרים את Brush וStrokeInputBatch שלו.

  • Brush: כולל שדות מספריים (גודל, אפסילון), צבע ו-BrushFamily.
  • StrokeInputBatch: רשימה של נקודות קלט עם שדות מספריים.

מודול האחסון מפשט את הסריאליזציה של החלק המורכב ביותר: StrokeInputBatch.

כדי לשמור קו:

  • מבצעים סריאליזציה של StrokeInputBatch באמצעות פונקציית הקידוד של מודול האחסון. אחסון הנתונים הבינאריים שמתקבלים.
  • שומרים בנפרד את המאפיינים החיוניים של המברשת של הקו:
    • ה-enum שמייצג את משפחת המברשות &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
        )
    }
}

ייצוא של משיכות מכחול

You might need to export your stroke scene as a static image file. This is useful for sharing the drawing with other applications, generating thumbnails, or saving a final, uneditable version of the content.

To export a scene, you can render your strokes to an offscreen bitmap instead of directly to the screen. Use Android's Picture API, which lets you record drawings on a canvas without needing a visible UI component.

The process involves creating a Picture instance, calling beginRecording() to get a Canvas, and then using your existing CanvasStrokeRenderer to draw each stroke onto that Canvas. After you record all the drawing commands, you can use the Picture to create a Bitmap, which you can then compress and save to a 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)
}

עזרים לאובייקט נתונים ולכלי המרה

להגדיר מבנה של אובייקט סריאליזציה שמשקף את האובייקטים הנדרשים של 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,
    )
  }
}