本文记录 Capturable 接入 OpenHarmony 的完整过程,覆盖截图类库盘点、可移植内核界定、鸿蒙协程制品线选型、真实引擎编译、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。

Capturable 是 PatilShreyas 维护的 Android Jetpack Compose 截图库:rememberCaptureController() 拿控制器,Modifier.capturable(controller) 标记要截取的 Composable,captureAsync() 异步拿到 ImageBitmap。库只有两个生产文件,但价值密度极高——它的产品不是"取像素"这个原语,而是其上的截图请求引擎:请求进 Channel(UNLIMITED) 缓冲(上游 issue #202 用 SharedFlow 丢请求换来的教训)、每个请求一个 CompletableDeferred、服务循环 flatMapLatest 逐个完成或失败且循环不死、严格 FIFO。本次适配把这套引擎原样编译成 OpenHarmony KLIB / 动态库,ArkUI 页面以 componentSnapshot 供给像素,演示"组件 → 队列 → PNG → 预览"的完整截图闭环。与存储类库不同,本库上游是纯 Android 工程(AGP + Jetpack Compose,无任何 commonMain),因此适配多了一步"源码集拆分":Compose 层隔离、引擎层泛型化——这是 Android 三方库走 cmp_qrose 模式的标准姿势。

项目地址: AtomGit/oh-tpc/Capturable.git

上游仓库: PatilShreyas/Capturable

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

1.1 为什么做 OpenHarmony 平台 Android 三方库适配

Capturable 为 Android 开发者回答"怎么把 Composable 变成图片":控制器解耦 UI 与截图时机、captureAsync() 从回调安全调用、Deferred.await() 拿结果、渲染失败异常沿请求链传播。这些能力全部写在 androidx.compose.ui 之上——GraphicsLayer 记录、ImageBitmap 出帧、Modifier.Node 挂接绘制。鸿蒙应用同样需要"把组件变成图片"(分享海报、票据存图、表单快照),但 ArkUI 只给了 componentSnapshot 这个一次性原语:它不排队、不缓冲、没有 Deferred 生命周期。如果只把示例页面改成 ArkTS,页面可以显示按钮,却无法证明上游的请求引擎在 OpenHarmony ARM64 设备上完成编译、链接和执行。

障碍具体问题
目标是纯 Android 工程上游是 AGP + Jetpack Compose 单平台库(Kotlin 2.1.0 / AGP 8.6.1),没有 commonMain 可直接复用,必须先做源码集拆分。
渲染层强绑定GraphicsLayer / ImageBitmap / Modifier.Node / DrawModifierNode 全部在 androidx.compose.ui 里;社区 CMP 鸿蒙线是 Compose 1.9.2 + Kotlin 2.2,与上游 Compose BOM 2024.10 无法混编。
依赖矩阵引擎依赖 kotlinx-coroutines(Channel / Deferred / StateFlow / flatMapLatest),需要 ohos 变体才能解析。
语言边界不同ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。
像素语义跨不了桥GraphicsLayer.toImageBitmap() 是 Compose 概念,鸿蒙侧像素必须由 ArkUI componentSnapshot 供给,引擎只管编排。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在:截图请求引擎的 ohos 编译(上游语义泛型化、零改写)、源码集拆分(引擎进 commonMain、Compose 层隔离 composeMain)、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。排队语义完全由上游引擎负责,像素获取由 ArkTS 负责,两侧只交换 JSON。

1.2 库提供的能力

Demo 提供一个真实的截图会话,对应桥接的全部入口:

  • 握手(info):返回 fork 版本、上游版本、Kotlin 与协程版本,来自真实 Native 调用而非静态文字;
  • 截图(capture / await):componentSnapshot 取票据组件像素 → 引擎 captureAsync() 入队 → FIFO 服务循环 → PNG 编码 → Base64 回传 → 页面预览,一次调用走完上游全链路;
  • 连拍(capture / await:false):一次入队 3 个请求立即返回 requestId,result 轮询按入队顺序依次完成——Deferred 异步语义的直观演示;
  • 队列观测(stats):issued / served / errored / pending 四个计数实时可查;
  • 会话管理(create / dispose):会话销毁与惰性重建。

响应包含:

字段作用
ok / error操作是否成功,失败时 error 带原文。
op / requestId / state回显本次操作、请求句柄与状态(queued / pending / done / error)。
width / height / pngBytes / encodeMs结果帧尺寸、PNG 字节数与编码耗时。
issued / served / errored / pending会话队列计数。
pngBase64PNG 帧的 Base64 文本。

引擎侧完整能力(Channel(UNLIMITED) 缓冲、CompletableDeferred 生命周期、flatMapLatest 服务循环、completeExceptionally 异常传播、FIFO)全部在上游语义的基础上随编译进入 .so,JVM 侧保留 5 项 jvmTest 交叉验证用例(含 #202 缓冲回归),证明 ohos .so 里执行的排队逻辑没有漂移。

1.3 实现适配

维度要求
代码复用上游请求队列语义(Channel 容量、trySend、Deferred 完成/异常、flatMapLatest 循环)逐行保留,仅把帧类型 ImageBitmap 泛型化为 T;上游 Compose 源码原样隔离在 composeMain,一字未改。
平台目标根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。
构建隔离根构建替换为极简 ohos 构建(cmp_qrose 模式);上游 Android Demo :app 留盘不进构建。
桥接稳定1 个 C ABI(CapturableBridge)+ UTF-8 JSON,会话式有状态桥;异常永远不跨 FFI。
依赖纪律协程版本主体与上游完全一致(1.10.2),只把仓库换到 ohos 制品线(1.10.2-1.0.0,换线不换版)。
UI 完整单页覆盖全部桥接入口:票据卡、截图预览、连拍 ×3、队列状态、会话释放、操作日志和版本握手。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit。

本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。Capturable 的价值分层在"编排"而非"渲染":像素原语从 GraphicsLayer 换成 componentSnapshot 是平台分工,编排引擎一行不改才是适配的核心。

二、实现路线图

第 1 阶段:项目初始化     ── 盘点模块与依赖边界、界定可移植引擎、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、协程 ohos 制品线换线不换版
第 3 阶段:桥协议建模     ── 会话式 JSON 契约、capture/result 两态语义、错误通道
第 4 阶段:原生桥接       ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主     ── componentSnapshot 像素管线、票据页、连拍日志和版本握手
第 6 阶段:示例与验证     ── HAP、签名、设备安装、真机运行和效果图

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
capturable/src/commonMain/          新增:截图请求引擎(上游语义泛型化,唯一进构建的库源码集)
capturable/src/commonTest/          新增:5 项 JVM 交叉验证用例
capturable/src/composeMain/         上游 Capturable.kt + controller/CaptureController.kt(原样隔离,不编译)
capturable/src/composeTest/         上游 JVM 单元测试(隔离)
capturable/src/composeAndroidTest/  上游仪器测试(隔离)
example/nativeApp/                  Kotlin/Native 动态库(Bridge + Pixels + PngEncoder)
example/harmonyApp/                 DevEco Stage、CMake N-API 和 ArkTS 页面
README.OpenHarmony.md / _CN.md      鸿蒙适配说明(中英双份)
README.upstream.md                  上游原文保存
app/ docs/ art/ spotless/           上游 Android Demo / Dokka 站点 / 图片 / 版权头,留盘不进构建

构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :capturable 与 :example:nativeApp,rootProject.name = "ohos_capturable"。上游的 AGP classpath、dokka、vanniktech 发布插件、spotless 全部随根构建替换剥离——本 fork 不发 Maven Central。

1.2 工具链矩阵
项目配置
Kotlin MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(Temurin,org.gradle.java.home 钉在 gradle.properties)
OpenHarmony 目标ohosArm64 + ohosX64(jvm() 保留作交叉验证)
Gradle8.14.1(上游 wrapper 8.7 随 fork 替换;2.2.21 旧线必须降 8.x,不能沿用上游)
kotlinxcoroutines 1.10.2-1.0.0、atomicfu 0.31.0-1.0.0(ohos 制品线)
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.capturable
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot"
java -version
1.3 源码依赖度盘点
上游文件依赖形态处置
controller/CaptureController.kt类主体:纯 coroutines(Channel / CompletableDeferred / receiveAsFlow);类签名挂 GraphicsLayer,文件尾挂 @Composable rememberCaptureController()拆分:队列语义泛型化进 commonMain;GraphicsLayer 构造与 remember 部分留在 composeMain 原文件
Capturable.ktModifier.Node + DrawModifierNode + GraphicsLayer.record + drawLayer,纯 Compose整体隔离 composeMain;其中 observeCaptureRequestsAndServe() 循环语义抽进 commonMain 引擎
src/test/.../CaptureControllerTest.ktmockk 模拟 GraphicsLayer 的 2 项 JVM 用例隔离 composeTest;语义由 commonTest 5 项用例继承覆盖
src/androidTest/.../CapturableTest.ktcompose ui-test 仪器测试隔离 composeAndroidTest

值得单独说明:这个库的可移植面非常集中——上游"怎么安全地截"的全部答案就是 Channel(UNLIMITED) + CompletableDeferred + flatMapLatest 这约 50 行纯协程代码,其余全是渲染。泛型化只做一件事:把帧类型 ImageBitmap 换成形参 T,Channel 容量、trySend 时机、异常传播路径一字不改。#202 的教训注释也原样保留在引擎源码里。

1.4 页面边界
标题区          Capturable · OpenHarmony / 引擎握手行(Kotlin 引擎已连接 · capturable 3.0.1-ohos.1…)
票据卡          BOOKING CONFIRMED / 《Capturable 大电影》/ IMAX · F7·F8 · 128 MIN
                21×21 演示图案(Canvas 确定性绘制,注明非真实二维码)/ 每秒跳动的开演时间
按钮区          📸 截图预览(capture)/ ⚡ 连拍 ×3(captureAsync)/ 📊 队列状态 / 🗑 释放会话
结果区          截取结果 PNG 预览 + 元信息(requestId · 尺寸 · 字节数 · 编码耗时)
日志区          带时间戳的操作日志(componentSnapshot 完成 / 入队 / result / stats)

页面启动时先调 {"op":"info"} 做握手,票据卡用 .id('ticket') 标记为截图目标,实时时钟证明"截的是当下状态"。

第 2 阶段:目标与依赖打通

2.1 制品线验证(本适配的成败手)

先查社区 Maven(https://maven.eazytec-cloud.com)上协程有没有与上游同版本的 ohos 制品。用 .module 元数据验证:

org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2-1.0.0
  variants: ohosArm64Api-published / ohosX64Api-published / jvmApi-published / jvmRuntime-published
org.jetbrains.kotlinx:atomicfu:0.31.0-1.0.0
  variants: 同上(jvm + ohos 双线齐全)

两个关键结论:其一,coroutines 版本主体与上游声明逐字相同(上游 1.10.2,ohos 线 1.10.2-1.0.0),commonMain 里一行依赖坐标都不用改,这就是"换线不换版";其二,ohos 制品同时发布 jvm 变体,jvm() 交叉验证可以用同一个版本号,不需要双线隔离(Kamera 适配先例正是这对坐标)。

2.2 工具链钉版(为什么是 2.2.21-1.0.0 + Gradle 8.14.1)

coroutines 1.10.2-1.0.0 基于 Kotlin 2.2 stdlib 构建,社区此前批准的钉法 HarmonyOS Kotlin 2.2.21-1.0.0(qrose / Flippable / FlexibleBottomSheet / telephoto 用的)正好可以直接读它的元数据;上游源码本来就是 Kotlin 2.1.0 编的,2.2.21 与之同代,源码级兼容成本为零。这与存储类新线(KStore 钉 2.4.20-0.1.0-07)是两条工具链线:2.2.21 旧线必须把 Gradle 降到 8.14.1,所以上游 wrapper 8.7 → 8.14.1 随 fork 替换(直接拷自 cmp_qrose 的 wrapper,gradle-wrapper.jar 同步更新),而不是像 2.4.20 线那样沿用上游 9.x。

settings.gradle.kts 把 eazytec 仓库写在最前:

pluginManagement {
    repositories {
        maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/")
        maven("https://maven.eazytec-cloud.com/nexus/content/groups/harmonyos")
        gradlePluginPortal()
        google()
        mavenCentral()
    }
}

rootProject.name = "ohos_capturable"

include(":capturable")
include(":example:nativeApp")
2.3 编译真实引擎

capturable/build.gradle.kts 收敛为纯 KMP 模块:

plugins {
    alias(libs.plugins.kotlin.multiplatform)
}

kotlin {
    applyDefaultHierarchyTemplate()
    jvmToolchain(21)

    jvm()          // 交叉验证用
    ohosArm64()
    ohosX64()

    sourceSets {
        commonMain.dependencies {
            api(libs.kotlinx.coroutines.core)   // 1.10.2-1.0.0
            api(libs.atomicfu)                  // 0.31.0-1.0.0
        }
        commonTest.dependencies {
            implementation(kotlin("test"))
        }
    }
}

实测:

./gradlew :capturable:compileKotlinJvm        # BUILD SUCCESSFUL
./gradlew :capturable:compileKotlinOhosArm64 :capturable:compileKotlinOhosX64   # BUILD SUCCESSFUL

引擎与 Compose 层的拆分靠 Gradle 源码集天然完成:composeMain / composeTest / composeAndroidTest 不是注册的源码集,默认层级模板不会把它们挂到任何目标上——上游源文件在鸿蒙构建里"存在于仓库、缺席于编译"。

2.4 测试的处置:ohos 测试任务没有运行器

与家族先例相同,处置是两条纪律的组合:语义验证统一在 JVM 跑(jvmTest),根构建全局禁用 ohos 测试任务:

subprojects {
    tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
        .configureEach { enabled = false }
}

上游原测试文件(mockk 版)零改动保留在 composeTest,语义由 commonTest 的 5 项用例继承覆盖(见 6.2),ohos 侧只承担编译与链接。

第 3 阶段:桥协议建模

3.1 JSON 契约
dev.shreyaspatil.capturable.controller/**(上游引擎语义,泛型化编译)
        ↓ CaptureController<EncodedFrame> / Channel(UNLIMITED) / flatMapLatest 服务循环
Bridge.kt(@CName CapturableBridge,会话式扁平 JSON 进出)
        ↓ UTF-8 JSON
N-API capturableBridge(request)
        ↓
ArkUI Index.ets(单页滚动)

请求与响应:

请求 : {"op":"info"}
       {"op":"create","id":"default"}
       {"op":"capture","id":"default","pixels":"<Base64>","width":724,"height":1032,
        "stride":2896,"format":"bgra","scale":1,"await":true|false}
       {"op":"result","id":"default","requestId":"r-2"}
       {"op":"stats","id":"default"}
       {"op":"dispose","id":"default"}
响应 : {"ok":true,"op":"capture","requestId":"r-2","state":"done","width":724,"height":1032,
        "pngBytes":48213,"encodeMs":6,"issued":2,"served":2,"errored":0,"pending":0,"pngBase64":"..."}
       {"ok":false,"error":"..."}

两个不对称设计:await:true 是同步一次一调用(页面点"截图预览"立即拿结果);await:false 立即返回 requestId,完成结果落会话内结果表、result 轮询取走——这正是上游"先 captureAsync() 拿 Deferred、稍后 await()"两段式用法在桥上的映射。响应只给扁平标量(ArkTS 类型化上下文对嵌套对象不友好,FlexibleBottomSheet 适配的教训)。

3.2 会话与帧的配对建模

桥内会话这样开:

private class Session(val id: String) {
    private val controller = CaptureController<EncodedFrame>()       // 上游引擎
    private val controllerState = MutableStateFlow(controller)
    val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
    private val pendingFrames = Channel<RawFrame>(Channel.UNLIMITED) // ArkTS 供给的像素帧

    init {
        scope.observeCaptureRequestsAndServe(controllerState) {      // 上游服务循环
            PixelPipeline.render(pendingFrames.receive())            // FIFO:帧与请求按序配对
        }
    }
}

为什么有状态(KStore 是无状态两口进出):截图请求有生命周期——captureAsync() 入队的瞬间没有像素,像素在 ArkTS 侧 componentSnapshot 完成后才到。引擎的 flatMapLatest 循环本来就是在"等一个将来才有的帧",桥用 pendingFrames Channel 把"将来"建模出来,请求与帧严格 FIFO 配对,连拍语义因此天然正确。会话内跨线程状态全部走 Channel / StateFlow / atomicfu,会话表本身只在 ArkTS 单线程调用前提下使用(与 Flex / Kamera 桥一致)。

suspend 收口用 runBlocking 包进同步 C ABI:NAPI 线程阻塞等结果,服务循环跑在 Dispatchers.Default,两侧不同线程、互不干扰。

3.3 错误通道

每个导出整体 try/catch,Kotlin 异常永远不跨 FFI:

@CName("CapturableBridge")
fun capturableBridge(request: String): String = try {
    /* op 分发 */
} catch (t: Throwable) {
    """{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
}

非法 JSON、缺 pixels / width / height、尺寸越界、字节长度与 stride × height 不符、不支持的 op、渲染管线异常,全部走同一个 ok:false 通道;await:false 路径的异步失败落结果表为 state:"error",ArkTS 侧统一渲染。

第 4 阶段:原生桥接(技术难点)

4.1 三层桥接
ArkTS
  │ JSON string
  ▼
C++ N-API entry(libcapturable_napi.so)
  │ const char*
  ▼
Kotlin/Native C ABI(libohoscapturable.so)
  │ CapturableBridge
  ▼
UTF-8 JSON + DisposeString

直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;只导出整数又会让页面复制排队语义,所以选择 C ABI + JSON。

4.2 Kotlin/Native 导出函数
@CName("CapturableBridge")
fun capturableBridge(request: String): String   // info / create / capture / result / stats / dispose 一口进
C ABI 符号ArkTS 方法覆盖内核
CapturableBridgecapturableBridge(req)captureAsync() 入队 + FIFO 服务循环 + complete/completeExceptionally + 会话生命周期
libohoscapturable_symbols(NAPI 内部使用)KN 运行时符号表(DisposeString)

版本握手不单列符号,走 {"op":"info"} 从引擎侧常量返回——与 KStore 的 kstoreVersion() 等价,少一个导出面。

4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
    {"capturableBridge", nullptr, NapiCapturableBridge, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};

每个回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohoscapturable_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohoscapturable_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与业务符号),不拖整份生成头。

4.4 CMake 和类型声明
add_library(ohoscapturable SHARED IMPORTED)
set_target_properties(ohoscapturable PROPERTIES
  IMPORTED_LOCATION ${KN_SO}
  IMPORTED_NO_SONAME TRUE)

add_library(capturable_napi SHARED napi_init.cpp)
target_link_libraries(capturable_napi PUBLIC
  libace_napi.z.so
  libhilog_ndk.z.so
  ohoscapturable)

OHOS_ARCH 为空或 libohoscapturable.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。

export const capturableBridge: (request: string) => string;

第 5 阶段:ArkUI 宿主

5.1 组件快照与像素上送
private async snapshotTicket(): Promise<SnapshotData> {
  const pm: image.PixelMap =
    await this.getUIContext().getComponentSnapshot().get('ticket');   // 像素原语(平台分工)
  const info: image.ImageInfo = await pm.getImageInfo();
  const width = info.size.width;
  const height = info.size.height;
  const stride = info.stride > 0 ? info.stride : width * 4;           // 行对齐步进
  const buffer = new ArrayBuffer(pm.getPixelBytesNumber());
  await pm.readPixelsToBuffer(buffer);                                // BGRA 字节流
  const pixels = new util.Base64Helper().encodeToStringSync(new Uint8Array(buffer));
  pm.release();
  return { pixels: pixels, width: width, height: height, stride: stride };
}

像素进引擎后再出帧:

const resp = JSON.parse(capturableBridge(JSON.stringify({
  op: 'capture', id: SESSION_ID, pixels: snap.pixels,
  width: snap.width, height: snap.height, stride: snap.stride,
  format: 'bgra', await: true
})));
const bytes = new util.Base64Helper().decodeSync(resp.pngBase64);
const frame = await image.createImageSource(bytes.buffer).createPixelMap();   // PNG → PixelMap 预览

Kotlin 侧像素管线(Pixels.kt):Base64 解码 → 尺寸/步进/字节长度校验 → 逐行按 stride 解包、BGRA/RGBA 通道序归一为 ARGB → 可选整数 box 降采样(scale 1–8)→ PngEncoder 出 PNG → Base64 回传。全程零三方图像库。

5.2 页面交互

页面自上而下:引擎握手行、票据卡(实时时钟每秒刷新)、四按钮(截图深色主按钮 / 连拍与队列状态次级 / 释放会话描边)、结果区(PNG 预览 + requestId · 尺寸 · 字节数 · 编码耗时)、日志区。连拍流程:componentSnapshot 一次 → 三次 await:false 入队(日志逐条显示 captureAsync 入队 r-N(队列 k))→ 按 r-1 → r-2 → r-3 顺序轮询 result(每条 ✅ W×Hpx · PNG xB · xms)→ 末帧上屏 → stats 刷新状态行。FIFO 顺序肉眼可见。

5.3 错误边界和防抖

ok:false 时错误原文进日志区;snapshotTicket / decodePng 全部 try/catch 收进日志;busy 状态在处理期间禁用全部按钮防连点;票据快照尺寸非法(0×0)直接抛错提示,不产生 Native 调用。

第 6 阶段:示例与验证

6.1 工程结构
example/
├── nativeApp/
│   ├── build.gradle.kts                      双 ABI sharedLib(baseName=ohoscapturable)+ cxxShared + copyKnSo*
│   └── src/ohosMain/kotlin/
│       ├── Bridge.kt                         @CName CapturableBridge + 会话 + MiniJson(继承自 cmp_qrose 审核模式)
│       ├── Pixels.kt                         RawFrame 校验 / stride 解包 / BGRA-RGBA / box 降采样
│       └── PngEncoder.kt                     零依赖 PNG(stored-deflate)
└── harmonyApp/
    ├── build-profile.json5                   signingConfigs 留空(签名由 DevEco 自动生成回填)
    ├── AppScope/app.json5                    bundleName org.terminator.ohos.capturable、versionName 3.0.1-ohos.1
    └── entry/
        ├── build-profile.json5               externalNativeOptions + abiFilters [arm64-v8a, x86_64]
        ├── libs/{arm64-v8a,x86_64}/          linkDebugShared* 经 copyKnSo* 回填的三件套(gitignore)
        ├── src/main/cpp/CMakeLists.txt       IMPORTED 引入 KN so、$ORIGIN rpath
        ├── src/main/cpp/napi_init.cpp        N-API 导出、参数检查、DisposeString
        ├── src/main/cpp/include/libohoscapturable_api.h
        ├── src/main/cpp/types/libcapturable_napi/{index.d.ts, oh-package.json5}
        └── src/main/ets/pages/Index.ets      ArkUI 验收页面
6.2 构建、签名和安装
.\gradlew.bat :capturable:jvmTest                                          # 5 项交叉验证
.\gradlew.bat :example:nativeApp:linkDebugSharedOhosArm64 `
             :example:nativeApp:linkDebugSharedOhosX64                     # 双 ABI so 自动回填 entry/libs

cd example\harmonyApp
hvigorw assembleHap --mode module -p product=default --no-daemon          # 需 java 在 PATH

签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书材料写入本机配置,不进入仓库(仓库内 build-profile.json5 的 signingConfigs 保持空数组,勾选自动签名后由 IDE 回填带 material 的完整配置)。

hdc list targets -v
hdc -t <设备序列号> install -r \
  entry/build/default/outputs/default/entry-default-signed.hap
hdc -t <设备序列号> shell aa start \
  -a EntryAbility -b org.terminator.ohos.capturable -m entry

四、完整代码对照

4.1 整体架构

dev.shreyaspatil.capturable.controller/**(上游引擎语义,commonMain 泛型化编译)
    │ CaptureController<T> / CaptureRequest<T> / observeCaptureRequestsAndServe
example/nativeApp:Bridge + Pixels + PngEncoder
    │ CapturableBridge(会话 / FIFO 帧配对 / 像素管线 / PNG)
libcapturable_napi.so
    │ capturableBridge
Index.ets
    │ componentSnapshot 供像素 + JSON parse + @State 更新
    ▼
ArkUI 真机页面(单页滚动:票据 / 截图 / 连拍 / 队列 / 日志)

4.2 文件清单

文件职责
settings.gradle.kts极简 ohos 构建、eazytec Maven 置前、ohos_capturable、:app 移出
gradle/libs.versions.tomlKotlin 2.2.21-1.0.0 + coroutines / atomicfu ohos 制品线钉版
build.gradle.kts(根)版本 3.0.1-ohos.1、全局禁用 Ohos*Test* 任务
capturable/build.gradle.kts纯 KMP:jvm + ohosArm64 + ohosX64
capturable/src/commonMain/.../CaptureController.kt引擎(上游语义泛型化,含 #202 注释)
capturable/src/commonTest/.../CaptureControllerTest.kt5 项交叉验证
example/nativeApp/build.gradle.kts双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填
nativeApp/.../Bridge.kt / Pixels.kt / PngEncoder.kt会话桥 / 像素管线 / PNG 编码
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/pages/Index.etsArkUI 验收页面
README.OpenHarmony_CN.md鸿蒙适配主文档(29 节)

4.3 关键 API 对照

层次Android(上游)ohos 桥(本 fork)
引擎rememberCaptureController(){"op":"create"}(会话即控制器)
引擎captureAsync(): Deferred<ImageBitmap>{"op":"capture","await":false} → requestId
引擎deferred.await(){"op":"result","requestId":...} 轮询
引擎Channel(UNLIMITED) 缓冲(#202)同一实现,commonTest 回归钉住
引擎flatMapLatest 服务循环observeCaptureRequestsAndServe 同语义
引擎completeExceptionally 传播{"ok":false,"error":...} / state:"error"
像素Modifier.capturable() + GraphicsLayerArkTS componentSnapshot.get('ticket')
像素toImageBitmap()像素管线 → PngEncoder.encodeArgb
结果ImageBitmappngBase64 → createPixelMap()
Native—(无)CapturableBridge C ABI JSON 导出
ArkTS—(无)capturableBridge(req) + @State 刷新

4.4 ArkTS 与 Kotlin 的边界

const resp = JSON.parse(capturableBridge(JSON.stringify({
  op: 'capture', id: 'default',
  pixels: base64Pixels, width: 724, height: 1032, stride: 2896,
  format: 'bgra', await: false
})));
// resp.requestId === 'r-2';稍后 op:'result' 取回 pngBase64
val requestId = "r-" + counter.incrementAndGet()
val deferred = controller.captureAsync()      // 上游引擎:入队 + Deferred
pendingFrames.trySend(raw)                    // ArkTS 供来的像素帧,FIFO 配对
// 服务循环(上游语义):receive() → PixelPipeline.render → deferred.complete / completeExceptionally

两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构。

五、关键决策说明

决策 1:移植排队引擎,而不是让 ArkTS 重写截图

componentSnapshot 只解决"取像素";上游库的真正产品是它之上的请求编排——#202 的缓冲回归、Deferred 生命周期、异常不中断循环、FIFO。用 ArkTS 手写一遍等于重新踩一遍上游踩过的坑,且无法跟随上游演进。泛型化移植让这约 50 行语义成为唯一事实源,JVM 用例钉住它。

决策 2:工具链钉 2.2.21-1.0.0 线,Gradle 替换为 8.14.1

coroutines 1.10.2-1.0.0 基于 2.2 stdlib,社区已批的 2.2.21-1.0.0 钉法(qrose / Flippable 线)直接可用;上游 Kotlin 2.1.0 同代零降级。这条线的代价是 Gradle 必须是 8.x——上游 wrapper 8.7 直接替换为 cmp_qrose 同款 8.14.1。与 KStore 的 2.4.20 线(沿用 Gradle 9.x)是两条不可互套的经验:工具链版本跟着协程制品线的 stdlib 元数据走。

决策 3:composeMain 隔离 + :app 留盘(cmp_qrose 模式)

上游是纯 Android 工程,渲染层不可能为鸿蒙编译;把两个生产文件原样移进 composeMain(未注册源码集),引擎进 commonMain——上游将来任何 Compose 层改动都可以无冲突 cherry-pick。上游 Android Demo :app 同理留盘不进 settings,README 明确警告勿 sync。

决策 4:有状态会话桥 + await:false / result 两段式

截图请求天然有生命周期:入队时像素还没到。桥用 pendingFrames Channel 建模"将来的帧",与请求 FIFO 配对;await:false + result 轮询把上游 captureAsync() / await() 的两段式语义原样暴露给 ArkTS,连拍演示因此能展示真实的队列深度变化,而不是把一切都压扁成同步调用。

决策 5:jvmTest 交叉验证 + 上游测试零改动

引擎逻辑在 commonMain,JVM 与 ohos 跑同一份代码;5 项 jvmTest(含 #202 缓冲回归)是"内核没漂"的闸门。上游 mockk 版测试文件一个字节不动地隔离保留,ohos 测试任务无运行器就全局禁用——测试零改动,交叉验证的有效性才成立。

六、测试与验证

6.1 测试环境

本次验证使用 Windows x64、JDK 21(Temurin jdk-21.0.12.101-hotspot)、DevEco Studio 26 命令行工具(hvigor / ohpm / Node)、OpenHarmony SDK 与鸿蒙真机(arm64)。

6.2 JVM 交叉验证

./gradlew :capturable:jvmTest

5 项用例全部通过(commonTest,跑在 JVM):

用例钉住什么
captureAsync_beforeCollector_buffersRequests上游 issue #202 回归:无消费者时连发两请求均缓冲、Deferred 保持 active,后收集者一次拿全
captureRequests_canBeCollectedMultipleTimes上游第二测试等价物:收集器取消后再收集,新请求仍可达
serveCaptureRequests_completesDeferredWithRenderedFrame服务循环用 render 结果 complete deferred
renderFailure_completesExceptionally_andLoopKeepsServingrender 抛异常→该请求 exceptional、循环继续服务后续
requests_areServedInFifoOrder三请求严格按序完成

依赖换线没有 API 漂移:coroutines 1.10.2 换成 1.10.2-1.0.0 线后版本主体不变,这正是"换线不换版"的直接证据。

6.3 HAP 验证

:capturable:compileKotlinOhosArm64 / OhosX64: BUILD SUCCESSFUL
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL(46s,含 CAdapter 生成)
llvm-nm -D: CapturableBridge / libohoscapturable_symbols 均为全局 T 符号
copyKnSoArm64 / copyKnSoX64: 双 ABI 三件套回填 entry/libs
ohpm install: completed
BuildNativeWithNinja: finished(双 ABI 编出 libcapturable_napi.so)
CompileArkTS: finished
PackageHap: finished
BUILD SUCCESSFUL
install bundle successfully
start ability successfully

entry-default-unsigned.hap 约 7.7 MB(8,066,449 B),内含双 ABI 各三件套;HAP 内 strip 后 KN so arm64 约 2.5MB、x86_64 约 2.4MB(debugShared 未 strip 时分别约 5.2MB / 4.8MB,含 Kotlin/Native 运行时与 coroutines),NAPI 包装层仅约 64KB。

6.4 功能用例

  1. 启动后状态行出现 Kotlin 引擎已连接 · capturable 3.0.1-ohos.1(上游 3.0.1 · Kotlin 2.2.21-1.0.0),日志出现 info → lib=capturable fork=3.0.1-ohos.1(来自真实 Native 调用,非静态文字);
  2. 票据卡实时时钟每秒刷新;
  3. 点"截图预览":日志依次出现 componentSnapshot 完成:W×Hpx(stride S) 与 capture r-N ✅ PNG xB · 编码 xms · 端到端 xms;结果区出现 PNG 预览,内容与票面一致、时钟定格为点击瞬间;
  4. 点"连拍 ×3":三条 captureAsync 入队 r-N(队列 k) 后,r-1 → r-2 → r-3 严格按序完成,末帧上屏,状态行刷新 issued=3 served=3 errored=0 pending=0;
  5. 点"队列状态":四计数与日志一致;
  6. 点"释放会话":日志确认销毁,再次截图自动重建会话(惰性 getOrPut)。

6.5 验证结论

请求引擎、coroutines 运行时、像素管线、PNG 编码、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的截图会话:版本握手、单帧截图闭环、连拍 FIFO 队列与队列观测(见下节效果图)。

七、运行效果

7.1 真机截图

在这里插入图片描述

7.2 命令速查

git clone https://atomgit.com/oh-tpc/Capturable.git
cd Capturable
export JAVA_HOME="/path/to/jdk-21"
./gradlew :capturable:compileKotlinOhosArm64 :capturable:compileKotlinOhosX64   # klib
./gradlew :capturable:jvmTest                                                  # 5 项交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
hvigorw assembleHap --mode module -p product=default --no-daemon
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.capturable -m entry
hdc shell hilog | grep Capturable

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 只改 ArkTS 页面不算适配,必须让 :capturable 真正编译成 ohosArm64 / ohosX64 目标并由 :example:nativeApp 链出动态库;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. @CName 符号不会挂到 ArkTS 模块上——ArkTS 永远导入 libcapturable_napi.so,KN so 只被 CMake IMPORTED 链接,两跳缺一不可;
  4. componentSnapshot 命名空间层没有 create(CompileArkTS 直接报 Property 'create' does not exist),已挂载组件用 get(id),推荐写法 this.getUIContext().getComponentSnapshot().get(id);
  5. 命令行 hvigorw assembleHap 在 PackageHap 阶段报 00308018 spawn java ENOENT——把 JDK 21 的 bin 加进 PATH 即可,不是工程问题;
  6. 上游 .gitignore 的 /build 是根锚定模式,盖不住 example/nativeApp/build/(KLIB 中间产物会混进提交)——嵌套 build 目录要单独追加;
  7. DevEco 打开工程后生成的 example/harmonyApp/.idea/、.clangd、.clang-tidy 同样要追加 gitignore;
  8. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套缺一不可;
  9. ArkUI PixelMap 缓冲默认 BGRA 通道序,像素管线按 format 字段归一,颜色 R/B 反了先查这里;
  10. 高密度屏上快照有行对齐步进(ImageInfo.stride 可能大于 width × 4),像素长度校验必须按 stride × height 而不是 width × height × 4;
  11. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  12. 签名材料由 DevEco 自动写入 build-profile.json5,仓库内保持 signingConfigs 空数组,证书绝不进入 AtomGit。

8.2 已知问题

  • Compose 层(Modifier.capturable() / rememberCaptureController() / GraphicsLayer)不参与鸿蒙编译,鸿蒙应用不能直接使用 Compose API——这是平台边界,不是待修缺陷;
  • 像素必须由宿主供给:引擎不持有 UI,ArkTS 侧负责 componentSnapshot;
  • PNG 为 stored-deflate(无压缩 zlib)实现,大图体积大于真压缩器;
  • capture 超时(30s)后该请求的 pending 计数不会回落(Deferred 未完成,引擎语义如此);
  • 会话表在 ArkTS 单线程调用前提下免锁,跨 Worker 并发调用同一会话未做承诺;
  • 上游全 git 历史均无 PDF / 文件输出能力,本 fork 不新增;
  • getContext 类 API 在新 SDK 上有 deprecation 警告(已用 getUIContext() 系列替代)。

8.3 未来优化方向

  • 补 uitest 自动化截图脚本与 CI(ohos 编译、ELF 符号检查、HAP 构建);
  • 大图场景下用 N-API ArrayBuffer 通道替代 Base64 文本,降低数据面膨胀;
  • 评估 .har 形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套;
  • 观察上游 v4 动向(GraphicsLayer API 演进),composeMain 目录隔离已保证可无冲突同步;
  • 补充模拟器(x86_64)与其他设备 ABI 的验收矩阵。

九、总结

9.1 核心难点回顾

上游 Capturable(纯 Android,两个生产文件)
    → 界定可移植内核:Channel(UNLIMITED) + CompletableDeferred + flatMapLatest(#202 语义)
    → 源码集拆分:引擎 commonMain 泛型化 / Compose 层 composeMain 隔离
    → 工具链对齐 Kotlin 2.2.21-1.0.0(Gradle 8.14.1 替换 wrapper)
    → coroutines 1.10.2-1.0.0 ohos 制品线(换线不换版)
    → ohosArm64 / ohosX64 KLIB + 会话式 C ABI(CapturableBridge)
    → C++ N-API(DisposeString 纪律)
    → ArkTS:componentSnapshot 供像素 + JSON 解析
    → ArkUI 真机单页验收(截图 / 连拍 FIFO / 队列观测 / 会话释放)

9.2 封装层次

  • 引擎层:上游原版请求队列语义(缓冲、Deferred、服务循环、异常传播、FIFO),泛型化编译;
  • 管线层:Base64 解码、stride 解包、通道序归一、box 降采样、零依赖 PNG 编码;
  • Native 层:生成双 ABI 动态库并通过 1 个 C ABI 输出会话式 JSON;
  • N-API 层:完成参数检查、字符串转换和 Native 内存释放;
  • ArkTS 层:组件快照、页面状态、请求分发、日志与错误文本;
  • DevEco 层:完成 CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. 先给库的价值分层再定路线:截图库的价值不在渲染(那是平台原语),而在编排(那是上游用真实 issue 换来的语义)。分清"平台分工"与"库的产品",可移植面自然浮现——本库 50 行引擎就是全部答案;
  2. 工具链线跟着协程制品走,且不能互套:coroutines 1.10.2-1.0.0 对应 2.2.21 线 + Gradle 8.14.1;存储类新线对应 2.4.20 + Gradle 9.x。钉版前先看制品的 stdlib 元数据,历史结论只能参考不能照抄;
  3. 有状态桥要诚实建模生命周期:截图请求"入队时无像素、像素稍后到"是本质异步,桥用帧 Channel + FIFO 配对把它显式化,再把 await:false/result 两段式暴露出去——Demo 的连拍按钮因此演示的是真语义,不是演出来的队列。

9.4 适配成果

当前适配已完成:

  • Capturable 3.0.1 请求引擎的双 ABI Kotlin/Native 编译(上游队列语义零改写,仅帧类型泛型化);
  • HarmonyOS coroutines 制品线在截图类库上的完整落地(1.10.2-1.0.0 + atomicfu 0.31.0-1.0.0,换线不换版);
  • 1 个 C ABI 符号和 C++ N-API 包装层(llvm-nm 验证全局 T 符号);
  • DevEco Stage ArkUI 单页验收页面(截图预览、连拍 ×3 FIFO、队列观测、会话释放、版本握手、操作日志);
  • 5 项 JVM 交叉验证全绿 + HAP 构建(双 ABI 六件套 so,约 7.7MB)、签名、设备安装和真机验证;
  • AtomGit 项目文档、OpenHarmony README(中英双份)、本文和本地效果图。

适配过程 FAQ

以下问题来自 ohosArm64 目标接入、NAPI 桥接、HAP 构建与真机验收的实际流程,按出现频率排序。

Q1:DevEco Studio Sync 失败或 ohpm 依赖下载超时,怎么处理?

  • 确认 DevEco Studio 版本与工程 build-profile.json5 的 compatibleSdkVersion 一致;
  • 在 File > Settings > Build, Execution, Deployment > Ohpm 配置可用的 ohpm 仓库镜像或代理;
  • 删除 oh_modules、oh-package-lock.json5 与 .hvigor 缓存后重新 Sync;
  • 公司网络需要代理时,同时给 ohpm 与 hvigor 配置 http.proxy。

Q2:CMake 报 Missing .../libxxx.so 或找不到 Kotlin/Native 动态库?

  • 先在 example 目录执行 ./gradlew :nativeApp:linkDebugSharedOhosArm64(模拟器用 linkDebugSharedOhosX64);
  • 确认 libxxx.so 与 libc++_shared.so 已复制到 ohos/entry/libs/<abi>/;
  • 检查 entry/build-profile.json5 的 abiFilters 与设备 ABI 是否一致(真机 arm64-v8a,模拟器 x86_64);
  • 清理 entry/.cxx 与 entry/build 后重试。

Q3:ArkTS 报 Cannot find module 'libxxx_napi.so' 或调用返回 undefined?

  • 确认 CMake 的 add_library(<target> SHARED ...) 名称与 import ... from 'libxxx_napi.so' 完全一致;
  • 解包 HAP,确认 libs/<abi>/libxxx_napi.so 已打包;
  • 删除 entry/build、entry/.cxx 与 oh_modules 后重新构建;
  • 检查 module.json5 的 srcEntry 与页面路径是否正确。

Q4:真机安装报 signature verification failed 或 install failed?

  • 在 DevEco Studio 的 Signing Configs 配置证书、Profile 与密码;
  • bundleName 必须与签名 profile 中的 bundle name 一致;
  • 设备需加入 profile 的 deviceIds,调试证书有效期需覆盖当前时间;
  • 用 hdc list targets 确认设备在线,再执行 hdc install -r xxx-signed.hap。

Q5:应用启动白屏、闪退或 NAPI 调用崩溃?

  • 用 hdc hilog | grep -i "napi\|kotlin" 查看原生日志;
  • 确认 libc++_shared.so 与 Kotlin/Native 工具链版本匹配,且 ABI 与设备一致;
  • 检查 C ABI 返回的字符串是否由 Native 侧分配、ArkTS 侧复制后是否调用 XxxFree 释放;
  • 在 DevEco 的 Native 调试配置中附加 lldb,定位空指针与越界访问。

Q6:Gradle 报 ohosArm64 目标不存在或 Kotlin/Native 编译失败?

  • 使用鸿蒙发行版 Kotlin(如 2.2.21-1.0.0),并在 settings.gradle.kts 配置 eazytec 仓库;
  • Gradle 建议 8.x、JDK 21,版本不匹配时先统一工具链;
  • 确认 kotlin { ohosArm64() } 已应用,且 local.properties 的 SDK 路径有效;
  • 清理 ~/.gradle/caches 中对应版本的 Kotlin/Native 缓存后重试。

Q7:HAP 构建报 SDK 版本不匹配或 compatibleSdkVersion 错误?

  • 对齐 DevEco Studio、HarmonyOS SDK 与 build-profile.json5 中的 compatibleSdkVersion;
  • 升级 DevEco Studio 后同步升级 hvigor 与 SDK,避免 modelVersion 与 IDE 不匹配;
  • 命令行构建时设置 DEVECO_SDK_HOME 指向 DevEco 的 sdk 目录。

Q8:命令行 hvigor 构建失败,但 DevEco 里能构建?

  • 使用 DevEco 自带的 node 与 hvigor:
    "$DEVECO/Contents/tools/node/bin/node" "$DEVECO/Contents/tools/hvigor/bin/hvigorw.js" ...;
  • 设置 JAVA_HOME、NODE_HOME、DEVECO_SDK_HOME;
  • 加上 --no-daemon 排除守护进程缓存影响,并清理 entry/build 与 .hvigor。

参考文档

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐