关于 ink-app-builder
这项技能指导编码 Agent 使用 `androidx.ink` 构建 Android 绘图应用,涵盖依赖配置、Compose 绘图表面及笔画处理流程。
主要功能
- 01Android Ink 依赖与 SDK 配置
- 02Compose 湿墨与干墨渲染
- 03库存画笔选择与切换
- 04橡皮擦、撤销与重做指导
- 05笔画持久化与位图导出
适用场景
- 01为 Android 应用添加 `androidx.ink`
- 02构建 Compose 手写绘图界面
- 03实现笔画编辑与历史记录
- 04保存或导出绘图数据
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ android/ skills --skill ink-app-builder安装后在 Claude Code 中输入/ink-app-builder确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/drawing-and-brushes.mdediting-and-architecture.mdpersistence-and-advanced.mdreferences/android/develop/ui/compose/touch-input/stylus-input/ink-api-brush-apis.mdink-api-draw-stroke.mdink-api-geometry-apis.mdink-api-setup.mdink-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崩溃。