상태 보존 및 영구 스토리지

상태 보존 및 영구 스토리지는 잉크 앱의 중요한 측면입니다. 이를 위해서는 구성 변경과 같은 시나리오에서 상태를 저장하고 사용자의 그림을 데이터베이스에 영구적으로 저장하기 위한 신중한 전략이 필요합니다.

상태 보존

뷰 기반 앱에서 UI 상태는 다음의 조합을 사용하여 관리됩니다.

UI 상태 저장을 참고하세요.

영구 스토리지

문서 저장, 로드, 잠재적인 실시간 공동작업과 같은 기능을 사용 설정하려면 획과 관련 데이터를 직렬화된 형식으로 저장하세요. Ink API의 경우 수동 직렬화 및 역직렬화가 필요합니다.

획을 정확하게 복원하려면 BrushStrokeInputBatch을 저장하세요.

저장소 모듈은 가장 복잡한 부분인 StrokeInputBatch을 간결하게 직렬화합니다.

획을 저장하려면 다음 단계를 따르세요.

  • 스토리지 모듈의 인코딩 함수를 사용하여 StrokeInputBatch를 직렬화합니다. 결과 바이너리 데이터를 저장합니다.
  • 스트로크의 브러시 필수 속성을 별도로 저장합니다.
    • 브러시 패밀리를 나타내는 열거형입니다. 인스턴스를 직렬화할 수 있지만 제한된 브러시 패밀리 선택을 사용하는 앱에는 효율적이지 않습니다.
    • 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에 제공해야 합니다. 이렇게 하면 새로 그린 획이 기존 획의 위치와 크기에 일치합니다.

MatrixpointerEventToWorldTransform 매개변수에 전달하여 이 작업을 실행할 수 있습니다. 매트릭스는 완성된 획 캔버스에 적용하는 변환의 역수를 나타내야 합니다.

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

획 내보내기

획 장면을 정적 이미지 파일로 내보내야 할 수도 있습니다. 이는 그림을 다른 애플리케이션과 공유하거나, 썸네일을 생성하거나, 수정할 수 없는 최종 버전의 콘텐츠를 저장하는 데 유용합니다.

장면을 내보내려면 스트로크를 화면에 직접 렌더링하는 대신 오프스크린 비트맵으로 렌더링하면 됩니다. 표시되는 UI 구성요소 없이 캔버스에 그림을 기록할 수 있는 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,
    )
  }
}