الحفاظ على الحالة ومساحة التخزين الدائمة

يُعدّ الحفاظ على الحالة والتخزين الدائم من الجوانب المهمة في تطبيقات الكتابة بالحبر الرقمي، خاصةً في Compose. إنّ عناصر البيانات الأساسية، مثل خصائص الفرشاة والنقاط التي تشكّل ضربة فرشاة، معقّدة ولا يتم الاحتفاظ بها تلقائيًا. ويتطلّب ذلك استراتيجية مدروسة لحفظ الحالة أثناء سيناريوهات مثل تغييرات الإعدادات وحفظ رسومات المستخدم بشكل دائم في قاعدة بيانات.

الاحتفاظ بالحالة

في Jetpack Compose، تتم إدارة حالة واجهة المستخدم عادةً باستخدام remember و rememberSaveable. على الرغم من أنّ rememberSaveable توفّر ميزة الحفاظ التلقائي على الحالة عند إجراء تغييرات في الإعدادات، تقتصر إمكاناتها المضمّنة على أنواع البيانات الأساسية والعناصر التي تنفّذ Parcelable أو Serializable.

بالنسبة إلى الكائنات المخصّصة التي تحتوي على خصائص معقّدة، مثل Brush، يجب تحديد آليات تسلسلية وتفكيك تسلسلي صريحة باستخدام أداة حفظ حالة مخصّصة. من خلال تحديد Saver مخصّص لكائن Brush، يمكنك الحفاظ على السمات الأساسية للفرشاة عند حدوث تغييرات في الإعدادات، كما هو موضّح في مثال 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، يجب إجراء التسلسل والتسلسل العكسي يدويًا.

لاستعادة ضربة بدقة، احفظ Brush وStrokeInputBatch.

  • 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. يساعد ذلك في مطابقة موضع وحجم الخطوط الجديدة مع الخطوط الحالية.

يمكنك إجراء ذلك من خلال تمرير 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
        )
    }
}

تصدير ضربات المفاتيح

قد تحتاج إلى تصدير مشهد ضربات الفرشاة كملف صورة ثابتة. ويكون ذلك مفيدًا لمشاركة الرسم مع تطبيقات أخرى أو إنشاء صور مصغّرة أو حفظ نسخة نهائية غير قابلة للتعديل من المحتوى.

لتصدير مشهد، يمكنك عرض ضربات الفرشاة على صورة نقطية خارج الشاشة بدلاً من عرضها على الشاشة مباشرةً. استخدِم 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)
}

أدوات مساعدة لكائنات البيانات والمحوّلات

حدِّد بنية كائن تسلسلية تتطابق مع كائنات 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,
    )
  }
}