用途
该技能用于在 Compose 中把一个可组合项离屏渲染成 ImageBitmap,使按钮可以将其保存或分享为图片。它把捕获原语移植到公共代码中,而不是依赖仅限 Android 的实现。
主要能力
整体由三部分组成,除捕获本身外,每部分都是 expect/actual 配对:
- capturable() 修饰符与 CaptureController:公共代码,无平台拆分。它把 composable 自身的绘制过程记录到 GraphicsLayer,并在需要时以 ImageBitmap 返回。
fun Modifier.capturable(controller: CaptureController): Modifier = this then CapturableModifierNodeElement(controller)
// inside the modifier node
override fun ContentDrawScope.draw() {
currentGraphicsLayer.record { this@draw.drawContent() }
drawLayer(currentGraphicsLayer)
}
内容绘制方式与之前完全一致——先记录图层,再直接绘制到可见画布上——因此把它附加到节点上不会改变外观,直到真正调用 captureAsync()。
-
ImageBitmap.toPngByteArray():生成 PNG,而不是应用现有 bitmap-to-bytes 函数可能产生的其他格式。
-
saveImageToDevice() / shareImage():Android 上使用 MediaStore 加分享面板;Desktop 上写入下载目录并“在文件管理器中显示”。
使用方式与注意事项
可移植性判断:一个没有 Kotlin Multiplatform 元数据的捕获库仍可能可移植——在排除它之前先检查它导入了什么。某个知名捕获库只发布单个 Android .aar,没有 KMP 元数据,因此 KMP 构建的非 Android 目标无法将其解析为依赖;但其代码中只有一个失效的 Android 导入出现在 KDoc 引用里,其余运行调用都是 androidx.compose.ui.graphics.layer,已经是公共代码安全的。阅读移植被拒依赖所需的少量文件,比假设“仅 Android 构件”等于“仅 Android 代码”并从头编写机制更好。
捕获请求通过 Channel 而非热 Flow 传递:在控制器创建瞬间触发的请求,可能在绘制节点附加并开始收集之前到达;热 SharedFlow 会丢弃该发射,调用方的 Deferred 永远不会完成——它会静默挂起,没有异常可查。无界 Channel 会缓冲它,因此无论附加还是请求先到,另一方仍能得到服务:
private val requests = Channel<CaptureRequest>(capacity = Channel.UNLIMITED)
不要假设捕获需要固定像素尺寸:最大宽度上限才能让图片在所有设备上清晰可读。字面固定宽度会在比它小的手机上裁剪,在更大的手机上浪费空间。应给被捕获的 composable 一个上限(Modifier.widthIn(max = ...)),让捕获采用组合产生的实际宽度。上限的作用是限制宽窗口,而不是让每台设备以不同方式缩放:该代码库自身的 ShareLyricsCardMaxWidth = 340.dp,其 KDoc 指出 340dp 放不进 360dp 手机减去 20dp 边距后的空间,因此一旦窗口超过上限加边距,手机和平板都会落在相同的 340dp 卡片上——dp 尺寸在此停止变化,只有密度继续变化。只有比这更窄的窗口才会真正缩小卡片。
传给被捕获子树的图片必须已经解析完成:捕获在点击时同步触发;此时仍在通过网络获取的 Painter 或异步图片加载器会被捕获为空状态所绘制的内容——一个空白方块,而不是重试。修复方式不是“在捕获流程内等待加载”,而是结构性的:向 composable 传入一个 ImageBitmap,该图片已由同一屏幕的其他部分为其他目的(模糊背景、主色扫描)解析完成——在尚未解码艺术图的屏幕上,捕获入口根本不存在,因此没有需要等待的东西。
捕获的是卡片,绝不是其周围的界面元素:用 capturable() 包裹的 composable 必须恰好是你打算交给别人的表面——渲染在其上方或下方的进度条或保存/分享行不属于用户想发送的图片。把修饰符应用到最内层内容节点,而不是同时布局控件的容器。
通过新函数转换为 PNG,而不是改变现有函数的返回内容:清晰文字背后的纯色正是 JPEG 基于块的编码最容易产生环形伪影的地方,而且 JPEG 没有透明度——圆角会变成填充黑色而不是被裁剪。如果现有 toByteArray() 已经返回 JPEG 字节,且应用中还有其他地方调用它,重新定义其格式会静默改变每个现有调用方的输出。应在旁边新增 toPngByteArray()。
保存到共享存储与通过应用自身缓存分享是不同的信任边界,只有其中一个可能被拒绝。Android 分享路径写入应用缓存目录,并向系统选择器提供 FileProvider URI——不涉及权限,因为没有离开应用私有存储。保存会向 MediaStore.Images 插入一行,仅在 API 29 以下需要运行时授予的 WRITE_EXTERNAL_STORAGE;从 API 29 起的分区存储允许应用插入自己的新图片而完全不需要权限。清单声明应与之匹配(android:maxSdkVersion="28")——在代码只在 29 以下请求它的情况下,未设上限的权限是应用无法再触发的声明,而且权限审查会在每个 OS 版本上对此作答。app-backup-to-zip-mediastore 在 API 29+ 上写入相同的零权限 MediaStore 路径;本功能是同时还要承担 29 之前那一半的那个。
Desktop 没有分享面板,也没有可靠的“显示并选中这个确切文件”:诚实的替代方案是:把文件写入下载文件夹,然后打开其所在文件夹。首先尝试 Desktop.open,但即使启动时正确探测过也不保证有效——关于该探测为何必须在任何原生库加载之前运行,见 compose-desktop-runtime-hardening——因此其后应备有按 OS 的命令回退:macOS 上用 open -R <file>,Windows 上用 explorer /select,<file>,Linux 上仅通过 xdg-open/gio open 打开所在文件夹,因为没有 Linux 文件管理器就“打开该文件夹并选中那个文件”的标志达成一致。
验证方法
从应用仓库根目录运行以下命令——路径属于该代码库;请根据你的布局调整。
共享捕获代码没有平台导入——即“已移植,而非仅 Android”的声明:
grep -n "^import android\.\|^import java\.awt" composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/capture/Capturable.kt
通过条件:无输出。转义点很重要——没有它,android 也会匹配每个 androidx.* 导入,而那是公共代码安全的,不是此检查的目的。
捕获区域内的 composable 从不加载自己的图片:
grep -n "AsyncImage\|rememberAsyncImagePainter\|SubcomposeAsyncImage" composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/lyrics/ShareLyricsCard.kt
通过条件:无输出——艺术图必须作为已解码的 bitmap 参数传入。
清单权限与运行时门控一致(此命令的工作目录为 androidApp/src/main):
grep -A1 "WRITE_EXTERNAL_STORAGE" AndroidManifest.xml
通过条件:该标签中出现 android:maxSdkVersion,且与你运行时检查分支的 API 级别相同——没有上限的裸声明意味着两者正在漂移。
PNG 与应用现有字节数组函数是两个函数,而不是一个改变了形态:
grep -rn "toByteArray()\|toPngByteArray()" --include="*.kt" . | grep -v '/build/'
通过条件:树中某处同时存在两个名称的调用点——仍在使用旧名称的调用方依赖旧格式,这就是它必须继续工作的原因。
在比上限加自身边距更窄的窗口(360dp 手机)和更宽的窗口(平板)上捕获同一屏幕。窄捕获的 dp 宽度必须低于上限;宽捕获必须恰好落在上限上——两个仅密度不同的宽捕获是正确的,不是 bug。
适用场景
适用于需要在 Compose 中把界面内容(如歌词卡片)捕获为图片并保存或分享的场景,尤其是 Kotlin Multiplatform 项目,需要公共代码实现捕获、PNG 导出以及 Android/Desktop 双端保存分享。
限制
资料未提供该技能的具体安装命令、依赖版本、收费或价格信息。Desktop 端没有分享面板,只能写入下载目录并打开所在文件夹;Linux 上无法可靠地“打开文件夹并选中文件”。保存到共享存储的权限行为受 Android API 级别影响。