mcpField
I
ink-app-builder

ink-app-builder

github.com/android开发10.0k47 次安装2 天前更新
代码仓库

关于 ink-app-builder

这项技能指导编码 Agent 使用 `androidx.ink` 构建 Android 绘图应用,涵盖依赖配置、Compose 绘图表面及笔画处理流程。

主要功能

  1. 01Android Ink 依赖与 SDK 配置
  2. 02Compose 湿墨与干墨渲染
  3. 03库存画笔选择与切换
  4. 04橡皮擦、撤销与重做指导
  5. 05笔画持久化与位图导出

适用场景

  1. 01为 Android 应用添加 `androidx.ink`
  2. 02构建 Compose 手写绘图界面
  3. 03实现笔画编辑与历史记录
  4. 04保存或导出绘图数据

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/android/skills --skill ink-app-builder

安装后在 Claude Code 中输入/ink-app-builder确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
drawing-and-brushes.md
editing-and-architecture.md
persistence-and-advanced.md
references/android/develop/ui/compose/touch-input/stylus-input/
ink-api-brush-apis.md
ink-api-draw-stroke.md
ink-api-geometry-apis.md
ink-api-setup.md
ink-api-state-preservation.md

详细介绍

Android Ink API(androidx.ink)是一个模块化的 Jetpack 库,用于在 Android 上构建高性能、低延迟的手绘体验。它采用湿墨水和干墨水两阶段模型:

  • 湿墨水:用户绘制时实时进行超低延迟渲染,由 InProgressStrokes 可组合项处理。
  • 干墨水:由应用使用 CanvasStrokeRenderer 渲染已完成且不可变的 Stroke 对象。

设置和依赖项

Ink API 要求 minSdk = 26,对应 Android 8.0(API 级别 26)及更高版本。所有 androidx.ink 模块在 gradle/libs.versions.toml 中必须使用完全相同的版本。修改项目构建文件前,请先征得用户许可。

版本选择(1.0.0 稳定版与 1.1.0-alpha03 及更高版本)
  • 对于依赖 StockBrushes 或通过 BrushFamily.decode 解码预先序列化的 .brush 资源的生产应用,请使用 1.0.0(稳定版)。在 1.0.0 稳定版中,BrushFamily.encode、BrushFamily.decode、AndroidBrushFamilySerialization 和 BrushFamilyDecodeCallback 需要使用 @OptIn(ExperimentalInkCustomBrushApi::class)。
  • 如果应用在 Kotlin 代码中以编程方式构造自定义画笔(BrushFamily、BrushCoat、BrushTip、BrushPaint、BrushBehavior),请使用 1.1.0-alpha03 及更高版本。从 1.1.0-alpha02 及更高版本开始,BrushFamily 序列化不再需要 @OptIn(而在 1.1.0-alpha05 及更高版本中,ExperimentalInkCustomBrushApi 为 @RestrictTo(LIBRARY_GROUP),因此绝不要在 1.1.0-alpha03 及更高版本的代码中使用它进行注解)。
版本目录(gradle/libs.versions.toml)
[versions]
ink = "<latest_version>"
inputMotionPrediction = "<latest_version>"

[libraries]
# Ink 核心模块
androidx-ink-authoring         = { module = "androidx.ink:ink-authoring",         version.ref = "ink" }
androidx-ink-brush             = { module = "androidx.ink:ink-brush",             version.ref = "ink" }
androidx-ink-geometry          = { module = "androidx.ink:ink-geometry",          version.ref = "ink" }
androidx-ink-nativeloader      = { module = "androidx.ink:ink-nativeloader",      version.ref = "ink" }
androidx-ink-rendering         = { module = "androidx.ink:ink-rendering",         version.ref = "ink" }
androidx-ink-strokes           = { module = "androidx.ink:ink-strokes",           version.ref = "ink" }
androidx-ink-storage           = { module = "androidx.ink:ink-storage",           version.ref = "ink" }

# Compose 集成模块
androidx-ink-authoring-android = { module = "androidx.ink:ink-authoring-android", version.ref = "ink" }
androidx-ink-authoring-compose = { module = "androidx.ink:ink-authoring-compose", version.ref = "ink" }
androidx-ink-brush-compose     = { module = "androidx.ink:ink-brush-compose",     version.ref = "ink" }
androidx-ink-geometry-compose  = { module = "androidx.ink:ink-geometry-compose",  version.ref = "ink" }

# 动作预测(低延迟触控笔跟踪所必需)
androidx-input-motionprediction = { module = "androidx.input:input-motionprediction", version.ref = "inputMotionPrediction" }

# 持久化(kotlinx.serialization + Room 与 KSP)
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
androidx-room-runtime      = { module = "androidx.room:room-runtime",  version.ref = "room" }
androidx-room-ktx          = { module = "androidx.room:room-ktx",      version.ref = "room" }
androidx-room-compiler     = { module = "androidx.room:room-compiler", version.ref = "room" }

[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp                  = { id = "com.google.devtools.ksp",                   version.ref = "ksp" }
App 模块配置(app/build.gradle.kts)
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.compose)
    alias(libs.plugins.kotlin.serialization)
    alias(libs.plugins.ksp) // 在 Kotlin 中使用 Room 时必须配置
}

android {
    defaultConfig {
        minSdk = 26
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
    buildFeatures {
        compose = true
    }
}

dependencies {
    implementation(libs.androidx.ink.authoring)
    implementation(libs.androidx.ink.brush)
    implementation(libs.androidx.ink.geometry)
    implementation(libs.androidx.ink.nativeloader)
    implementation(libs.androidx.ink.rendering)
    implementation(libs.androidx.ink.strokes)
    implementation(libs.androidx.ink.storage)

    implementation(libs.androidx.ink.authoring.android)
    implementation(libs.androidx.ink.authoring.compose)
    implementation(libs.androidx.ink.brush.compose)
    implementation(libs.androidx.ink.geometry.compose)

    implementation(libs.androidx.input.motionprediction)

    implementation(libs.kotlinx.serialization.json)
    implementation(libs.androidx.room.runtime)
    implementation(libs.androidx.room.ktx)
    ksp(libs.androidx.room.compiler) // 在 Kotlin 中绝不要使用 annotationProcessor
}

核心绘图画布与默认画笔

使用 Brush.createWithComposeColor(来自 ink-brush-compose)创建标准的压力感应画笔 Brush。epsilon 参数定义了两个输入点之间被视为视觉上不同的最小距离,单位为笔画空间单位(一般绘图使用 0.1f,精细工具使用 0.01f;Brush 强制要求 0f < epsilon <= size):

/**
 * Creates a default pressure-sensitive pen [Brush].
 *
 * The `epsilon` parameter sets the geometric tolerance (minimum point distance
 * in stroke units) controlling how far a stroke can be zoomed before triangle
 * artifacts appear (`0f < epsilon <= size`).
 */
fun createDefaultPenBrush(): Brush {
    return Brush.createWithComposeColor(
        family = StockBrushes.pressurePen(),
        color = Color.Black,
        size = 5f,
        epsilon = 0.1f,
    )
}

在一个 Box 内叠放两个绘图画布图层:一个 Compose Canvas,在 nativeCanvas.withSave 内使用 CanvasStrokeRenderer 渲染已完成的干笔画 Stroke 对象;另一个是位于上层的 InProgressStrokes 可组合项,用于低延迟采集湿墨迹。在 DrawingSurface 内封装一个同步的 pendingStrokes Compose Snapshot State 缓冲区,这样 onStrokesFinished 触发时,干画布会在完全相同的 UI 线程运行循环中失效,从而避免湿墨迹切换为干笔画时出现 1 帧闪烁:

@Composable
fun DrawingSurface(
    strokes: List<Stroke>,
    canvasStrokeRenderer: CanvasStrokeRenderer,
    currentBrush: Brush,
    onGetNextBrush: () -> Brush,
    onStrokesFinished: (List<Stroke>) -> Unit,
    isEraserMode: Boolean,
    modifier: Modifier = Modifier,
    textureStore: AppTextureBitmapStore = LocalTextureStore.current,
    onErase: (Float, Float) -> Unit = { _, _ -> },
    onEraseStart: () -> Unit = {},
    onEraseEnd: () -> Unit = {},
) {
    val cacheGen by textureStore.generation.collectAsStateWithLifecycle()

    // Holds newly finished strokes synchronously for the single frame before
    // asynchronous StateFlow collection delivers the updated strokes list.
    var pendingStrokes by remember { mutableStateOf<List<Stroke>>(emptyList()) }

    SideEffect {
        if (pendingStrokes.isNotEmpty()) {
            pendingStrokes = if (strokes.isEmpty()) {
                emptyList()
            } else {
                pendingStrokes.filter { pending -> strokes.none { it === pending } }
            }
        }
    }

    val strokesToDraw = remember(strokes, pendingStrokes) {
        if (pendingStrokes.isEmpty()) {
            strokes
        } else {
            strokes + pendingStrokes.filter { pending -> strokes.none { it === pending } }
        }
    }
    val identityMatrix = remember { Matrix() }

    val currentOnErase by rememberUpdatedState(onErase)
    val currentOnEraseStart by rememberUpdatedState(onEraseStart)
    val currentOnEraseEnd by rememberUpdatedState(onEraseEnd)

    Box(modifier = modifier) {
        // Layer 1: Dry ink - completed strokes + same-frame pending handoff strokes
        Canvas(modifier = Modifier.fillMaxSize()) {
            val nativeCanvas = drawContext.canvas.nativeCanvas
            strokesToDraw.fastForEach { stroke ->
                nativeCanvas.withSave {
                    canvasStrokeRenderer.draw(
                        canvas = this,
                        stroke = stroke,
                        strokeToScreenTransform = identityMatrix,
                    )
                }
            }
        }

        // Layer 2: Eraser touch handler OR Wet ink (InProgressStrokes)
        if (isEraserMode) {
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .pointerInput(Unit) {
                        detectDragGestures(
                            onDragStart = { currentOnEraseStart() },
                            onDragEnd = { currentOnEraseEnd() },
                            onDragCancel = { currentOnEraseEnd() },
                            onDrag = { change, _ ->
                                currentOnErase(change.position.x, change.position.y)
                                change.consume()
                            },
                        )
                    }
            )
        } else {
            key(cacheGen) {
                InProgressStrokes(
                    defaultBrush = currentBrush,
                    nextBrush = onGetNextBrush,
                    onStrokesFinished = { newStrokes ->
                        pendingStrokes = pendingStrokes + newStrokes
                        onStrokesFinished(newStrokes)
                    },
                    textureBitmapStore = textureStore,
                )
            }
        }
    }
}

## 工作流与任务路由

请根据请求的任务遵循以下条件说明:

  • 步骤 1(依赖项和 SDK 设置):添加 Ink API 依赖项或配置 minSdk 时,请遵循本文件中的设置代码块以及 Ink API 设置指南。确认已包含 ink-nativeloader、ink-authoring-android 和 androidx.input:input-motionprediction。
  • 步骤 2(绘图表面和笔迹渲染):使用湿墨(InProgressStrokes)和干墨(CanvasStrokeRenderer)创建 Compose 绘图界面时,请实现本文件中的 DrawingSurface,并查看 绘制笔迹。对于 InProgressStrokes 的参数详情、BlendMode.Multiply 高亮层合成以及 TextureBitmapStore 生成失效处理,必须遵循 drawing-and-brushes.md。
  • 步骤 3(内置画笔和画笔切换):创建、修改或切换内置画笔(StockBrushes.pressurePen、marker、highlighter、dashedLine 或 emojiHighlighter)时,请查看 画笔 API,并且必须遵循 drawing-and-brushes.md,以了解 epsilon 几何约束、高亮笔透明度保留以及 Android 14(API 级别 34)的 emojiHighlighter 规则。
  • 步骤 4(橡皮擦、撤销与重做,以及 MVVM 架构):实现基于几何的笔迹橡皮擦、线性撤销和重做历史栈,或 MVVM ViewModel 和 Hilt 连接时,请查看 几何 API,并且必须遵循 editing-and-architecture.md。
  • 步骤 5(持久化、位图导出和手势共存):使用 Room 保存和加载笔迹(StrokeInputBatch.encode 和 decode)、在配置更改后保留 UI 状态、将绘图导出为 Bitmap 或 PNG 文件,或将长按拖动手势与绘图结合(pointerInputWithSiblingFallthrough)时,请查看 状态保留和持久化存储,并且必须遵循 persistence-and-advanced.md。
  • 以编程方式创建自定义画笔:对于自定义 BrushFamily 的创建、自定义笔尖形状或行为节点图,请推荐 ink-custom-brush-builder 技能。

反模式

  • 切勿在 Compose 屏幕中使用 InProgressStrokesView ---这是基于 View 的 API。请改用 InProgressStrokes 可组合项。
  • 切勿省略 ink-nativeloader 或 ink-authoring-android ---缺少 ink-nativeloader 会抛出 UnsatisfiedLinkError,缺少 ink-authoring-android 会在运行时抛出 ClassNotFoundException。
  • 切勿直接使用 View.OnTouchListener 或 MotionEvent ---InProgressStrokes 可组合项会在内部捕获并处理触摸和触控笔输入。
  • 切勿使用 Canvas.drawPath 手动绘制笔画,也不要遍历笔画点---始终调用 CanvasStrokeRenderer.draw(canvas, stroke, transform)。
  • 切勿调用 CanvasStrokeRenderer.create(forcePathRendering = true) ---两个参数的 forcePathRendering 重载带有 @RestrictTo(LIBRARY_GROUP) 限制。请使用公开的 CanvasStrokeRenderer.create(textureStore) 工厂,它会自动检测软件 Bitmap 画布,并回退到路径渲染。
  • 切勿仅依赖在 onStrokesFinished 中异步收集 StateFlow ---InProgressStrokes 会在 UI 线程上 onStrokesFinished 返回后立即移除已完成的湿笔画,而 collectAsStateWithLifecycle 要到后续事件循环才更新,因此会导致湿笔画到干笔画之间闪烁 1 帧。请始终在同一个 UI 线程事件循环中同步修改 Compose Snapshot State,并使用 DrawingSurface 中所示的 pendingStrokes 缓冲区。
  • 切勿将笔画存储为位图以便撤销、重做或持久化到数据库---请在内存中存储 Stroke 对象,并将带有画笔元数据的 StrokeInputBatch 字节序列化后存储到磁盘。
  • 切勿在 Kotlin 中使用 annotationProcessor 处理 Room ---请始终应用 com.google.devtools.ksp 插件,并使用 ksp(libs.androidx.room.compiler),以避免启动时出现 AppDatabase_Impl does not exist 崩溃。

评论

支持 Markdown