การเก็บรักษาสถานะและพื้นที่เก็บข้อมูลถาวร

การรักษาสถานะและการจัดเก็บข้อมูลแบบถาวรเป็นแง่มุมที่สำคัญของแอปการเขียนด้วยลายมือ โดยเฉพาะใน Compose ออบเจ็กต์ข้อมูลหลัก เช่น พร็อพเพอร์ตี้ของแปรงและจุดที่สร้างสโตรก มีความซับซ้อนและไม่ได้คงอยู่โดยอัตโนมัติ ซึ่งต้องมีกลยุทธ์ที่รอบคอบในการบันทึกสถานะในระหว่างสถานการณ์ต่างๆ เช่น การเปลี่ยนแปลงการกำหนดค่าและการบันทึกภาพวาดของผู้ใช้ลงในฐานข้อมูลอย่างถาวร

การเก็บรักษาสถานะ

ใน Jetpack Compose โดยทั่วไปแล้วจะมีการจัดการสถานะ UI โดยใช้ remember และ rememberSaveable แม้ว่า rememberSaveable จะมีการรักษาสถานะอัตโนมัติเมื่อมีการเปลี่ยนแปลงการกำหนดค่า แต่ความสามารถในตัวของ Parcelableหรือ Serializableจะจำกัดไว้ที่ประเภทข้อมูลดั้งเดิมและออบเจ็กต์ที่ใช้

สําหรับออบเจ็กต์ที่กําหนดเองซึ่งมีพร็อพเพอร์ตี้ที่ซับซ้อน เช่น Brush คุณต้องกําหนดกลไกการทำ Serialization และ Deserialization อย่างชัดเจน โดยใช้เครื่องมือบันทึกสถานะที่กำหนดเอง การกำหนด 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 คุณจะต้องทำ Serialization และ Deserialization ด้วยตนเอง

หากต้องการกู้คืนลายเส้นอย่างถูกต้อง ให้บันทึก Brush และ StrokeInputBatch

  • Brush: มีฟิลด์ตัวเลข (ขนาด เอปซิลอน) สี และ BrushFamily
  • StrokeInputBatch: รายการจุดอินพุตที่มีฟิลด์ตัวเลข

โมดูลพื้นที่เก็บข้อมูลช่วยลดความซับซ้อนในการจัดลำดับส่วนที่ซับซ้อนที่สุดอย่าง 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 เมทริกซ์ควรแสดงค่าผกผันของการเปลี่ยนรูปที่คุณ ใช้กับ Canvas ของเส้นที่วาดเสร็จแล้ว

@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 ซึ่งช่วยให้คุณบันทึกภาพวาดบน Canvas ได้โดยไม่ต้องมี คอมโพเนนต์ UI ที่มองเห็นได้

กระบวนการนี้เกี่ยวข้องกับการสร้างอินสแตนซ์ 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,
}
ผู้ทำ Conversion
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,
    )
  }
}