本文记录 compose-input-mask 接入 OpenHarmony 的完整过程,覆盖输入掩码库盘点、Compose 鸿蒙制品线验证、真实引擎编译、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。

compose-input-mask 是基于 Compose text 层(VisualTransformation / TransformedText / OffsetMapping)的 Kotlin Multiplatform 输入掩码库:给定一个掩码模板(如 dd/mm/yyyy)和固定分隔符集合(如 /),它把用户输入的原始文本(11012001)格式化为带分隔符的显示文本(11/01/2001),同时保证光标位置在原始值与显示值之间正确换算;空输入可显示占位模板,三段着色(占位色 / 光标前 / 光标后)由 MaskStyle 控制,且不修改原始值(上游 1.x 设计,输入过滤由调用方负责)。本次适配与社区早期路线(qrose 的"零依赖内核提取")有本质区别:社区 Maven 上已经出现完整的 Compose 鸿蒙制品线(org.jetbrains.compose.ui:ui:1.12.0-0.1.0-07,发布 ohosArm64 / ohosX64 klib 变体,ui-text / ui-graphics / runtime 整线齐全),因此不需要提取内核——真实的引擎(universalTransformation、universalTransformedText、MaskStyle)被原样编译成 OpenHarmony KLIB;更进一步,这个库的全部公开 API 都是值级类型、没有任何 @Composable 函数,桥零复刻:ArkTS 看到的每一个掩码显示串、每一个光标映射,都来自上游 universalTransformedText() 本尊的同步调用。

项目地址: AtomGit/oh-tpc/compose-input-mask

上游仓库: 3moly/compose-input-mask

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配

compose-input-mask 为 Kotlin Multiplatform 应用提供"原始值 ↔ 格式化显示"这一件事的完整答案:universalTransformation(mask, maskCharToKeep) 一行拿到 VisualTransformation 挂到 TextField 上,maskCharsToKeep 里的字符视为固定分隔符、其余位置依次消费输入,超长输入自动截断显示,OffsetMapping.originalToTransformed / transformedToOriginal 让光标在两套下标间正确换算,空输入 + placeholder 走占位符分支,MaskStyle 三段着色控制占位色 / 光标前 / 光标后。KMP 项目把这些能力放在 commonMain,由 Android、iOS、桌面共享。OpenHarmony 应用同样需要这套输入格式化能力,但上游构建(Kotlin 1.9.20 / Compose 1.5.11 / Gradle 8.5)不会生成 OpenHarmony 变体。

如果只把示例页面改成 ArkTS,页面可以显示输入框,却无法证明掩码引擎在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证掩码引擎、Compose text 层依赖、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。

障碍具体问题
目标缺失必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。
工具链不一致上游 Kotlin 1.9.20 / Gradle 8.5 / AGP 8.2.1 与鸿蒙工具链无法共存于一个 Gradle 构建。
依赖矩阵引擎依赖 Compose 的 runtime / ui(text 层全在 ui 里),必须找到带 ohos 变体的制品线才能解析;这条线还决定 Kotlin 钉版。
语言边界不同ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。
VisualTransformation 跨不了桥它是 Compose TextField 的内部机制,ArkUI 没有对应物;能跨桥等价暴露的是"显示串 + 光标映射"这两个输出。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在:掩码引擎的 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。掩码语义完全由上游原版实现负责,ArkTS 只负责调用 Native、解析 JSON 和展示结果。

1.2 库提供的能力

Demo 提供一个真实的掩码变换会话,对应桥接的全部入口:

  • 掩码变换(apply):输入原始文本 + 掩码模板 + 分隔符集合,universalTransformedText() 本尊计算 TransformedText,返回格式化显示串(11012001 → 11/01/2001);
  • 双色分界(split):originalToTransformed(text.length),即输入完成处——之前的字符用光标前色、其余(掩码尾部模板)用光标后色,与上游 AnnotatedString 两段 SpanStyle 严格一致;
  • 占位符态(isPlaceholder):空输入 + placeholder 时整体用占位色渲染模板;
  • 光标映射检查:o2t[] / t2o[] 双向滑杆 + 全量数组展示,光标换算在页面上可逐下标验证;
  • 版本握手:inputMaskVersion() 返回 fork 版本号,来自真实 Native 调用而非静态文字。

响应包含:

字段作用
ok操作是否成功,失败时 error 带原文。
transformed格式化显示文本(上游 TransformedText.text.text)。
split / isPlaceholder双色分界下标 / 占位符态标志,ArkTS 据此渲染三段着色。
o2t[] / t2o[]OffsetMapping 的全量采样:originalToTransformed(i)(i ∈ [0, text.length])与 transformedToOriginal(j)(j ∈ [0, transformed.length])。

引擎侧完整能力(掩码填充、超长截断、全 keep、占位符、VisualTransformation.filter() 通路)全部在上游 commonMain 里原样随编译进入 .so,JVM 侧保留 6 项 jvmTest 交叉验证用例,证明 ohos .so 里执行的掩码逻辑没有漂移。

1.3 实现适配

维度要求
代码复用compose-input-mask 的全部 commonMain 文件原样编译,不提取、不重写;零新增 Kotlin 源文件、零 expect/actual(上游本身就没有)。
平台目标根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。
构建隔离根构建替换为极简 ohos 构建(cmp_qrose 模式);上游 sample(Android/iOS/js/desktop 示例)留盘不进构建,其角色由 example/harmonyApp 接管。
桥接稳定使用 2 个 C ABI 和 UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。
依赖纪律jvm 交叉验证线钉上游自己的 Compose 1.5.11(正是上游 1.0.0 构建所用的线),ohos 线钉社区 1.12.0-0.1.0-07——两条线各自的"版本主体"都与上游世代对齐,上游测试零改动全绿。
UI 完整双 Tab 覆盖全部桥接入口:5 个预设掩码 + 自定义 mask/keep、原始输入、双色掩码显示、光标映射检查器、版本握手、关于页。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit。

本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。compose-input-mask 本身不渲染 UI——它输出"显示串 + 光标映射",ArkTS Demo 只是把桥暴露的掩码会话画出来。

二、实现路线图

第 1 阶段:项目初始化     ── 盘点模块与依赖边界、确认 Compose ohos 制品线、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.4.20-0.1.0-07 钉版、依赖按线拆
第 3 阶段:桥协议建模     ── 掩码请求/响应 JSON 契约、split/o2t/t2o 语义、错误通道
第 4 阶段:原生桥接       ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主     ── 预设 chips、原始输入、双色显示、光标映射检查器和版本握手
第 6 阶段:示例与验证     ── HAP、签名、设备安装、真机运行和效果图

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
compose-input-mask/                    上游掩码引擎(进 ohos 构建,jvm() + ohosArm64 + ohosX64)
example/nativeApp/                     Kotlin/Native 动态库(Bridge + MiniJson,直调真实引擎)
example/harmonyApp/                    DevEco Stage、CMake N-API 和 ArkTS 页面
README.OpenHarmony.md / _CN.md         鸿蒙适配说明(中英双份)
README.upstream.md                     上游原文保存
sample/ docs/ versions.properties      上游示例、截图与 refreshVersions 遗留,留盘不进构建

构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :compose-input-mask 与 :example:nativeApp,rootProject.name = "ohos_compose_input_mask"。上游的 AGP / compose 插件 classpath、vanniktech 发布配置、Android target、iOS framework、js browser 全部随根构建替换剥离——本 fork 不发 Maven Central。被替换的上游构建文件(settings / build / gradle.properties / libs.versions.toml / 库构建 / wrapper 配置)逐一备份为 *.upstream。

1.2 工具链矩阵
项目配置
Kotlin MultiplatformHarmonyOS Kotlin 2.4.20-0.1.0-07(eazytec Maven)
JDK21(org.gradle.java.home 指向 Eclipse Temurin 21)
OpenHarmony 目标ohosArm64 + ohosX64(jvm() 保留作交叉验证)
Gradle9.6.1(上游 wrapper 是 8.5,随 2.4.20 工具链线升级;KLoading / KStore 同款组合)
Compose(ohos 线)1.12.0-0.1.0-07(社区线,runtime/ui 含 ui-text/ui-graphics 整线)
Compose(jvm 交叉验证线)1.5.11(Maven Central 上游线——正是上游 1.0.0 构建所用的线)
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.inputmask
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot"
java -version

注意 gradle.properties 里的 org.gradle.java.home 只管构建期 JVM,不管 wrapper 启动——会话里不设 JAVA_HOME 的话 gradlew.bat 第一步就报 JAVA_HOME is not set。

1.3 源码依赖度盘点
模块commonMain 文件依赖形态处置
compose-input-mask3(MaskStyle.kt / universalTransformation.kt / universalTransformedText.kt)compose.runtime(@Stable)+ compose.ui(AnnotatedString / SpanStyle / Color / VisualTransformation / TransformedText / OffsetMapping),零平台代码整体编译(jvm + 双 ohos)

expect 声明全库 0 处(对比 KStore 的 1 处):上游没有任何 androidMain / iosMain / jsMain 源集,也没有平台资源,所以鸿蒙侧一个 actual 都不用补——整个 fork 没有为库新增任何 Kotlin 源文件。另一个关键盘点是 @Composable 函数 0 个:全部公开 API 是普通函数与值类。这直接决定了桥的形态(见第 3、4 阶段):动画库(KLoading / bubbles-compose)的时序写在 composition 里,桥只能复刻帧数学;而 universalTransformedText(text, mask, …) -> TransformedText 是纯函数,桥可以原样调用。

compose.ui 值得单独说明:掩码引擎需要的全部类型——AnnotatedString、SpanStyle、Color、VisualTransformation、TransformedText、OffsetMapping——都在 ui 依赖的 ui-text / ui-graphics 里,没有一个需要 Skiko 渲染后端。过去挡住 UI 库适配的"Painter 编不过 ohos"问题在这里不存在,这是本库适配面天然很小的根本原因。

1.4 页面边界
标题区          INPUT-MASK · OPENHARMONY / 副标题(KMP 输入掩码库 · Kotlin/Native ohosArm64/ohosX64 · 上游引擎真实调用)
Tab 切换        掩码演示 / 关于
预设区          5 个掩码 chips(日期 dd/mm/yyyy、生日 __.__.____、电话、卡号、时间)+ 自定义
输入区          原始文本 TextInput(可选"仅数字输入"过滤——模拟上游 1.x"调用方过滤"语义)
开关区          仅数字输入 / 空输入占位符
显示区          双色掩码大字(split 前黑后灰;占位态整体灰)+ 原始值/显示值长度对照
映射区          两个 Slider(originalToTransformed / transformedToOriginal)+ o2t/t2o 全量数组
关于区          版本握手、库简介、架构分层、仓库与社区链接

页面启动时先调 inputMaskVersion() 做握手,再对空输入做一次 applyMask()——页面进来就能看到占位符态。

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

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

先查社区 Maven(https://maven.eazytec-cloud.com)上有没有 Compose 的 ohos 制品。用 .module 元数据验证:

org.jetbrains.compose.ui:ui:1.12.0-0.1.0-07
  variants: ohosArm64ApiElements-published / ohosX64ApiElements-published
           / desktopApiElements-published / metadataApiElements
  metadataApiElements dependencies:
    org.jetbrains.compose.runtime:runtime:1.12.0-0.1.0-07
    org.jetbrains.compose.ui:{ui-text, ui-graphics, ui-unit, ui-util, …}:1.12.0-0.1.0-07
    org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0-0.1.0-07 / atomicfu:0.33.0-0.1.0-07

三个关键结论:其一,ui 会带动 ui-text / ui-graphics / ui-unit / ui-util 与 runtime 整线,掩码引擎需要的全部类型都有 ohos 变体——不需要抽核,抽核等于把上游手抄一遍,反而引入漂移风险;其二,该线同时发布 desktop 变体,但不发布 jvm 交叉验证需要的 *-desktop 子制品(foundation 等模块缺失),jvm 必须钉上游自己的线(见 2.3);其三,这条 Compose 线与 KStore 验证过的 kotlinx 线同代,工具链钉 2.4.20-0.1.0-07 与社区同款,无需新考证。

2.2 工具链钉版(为什么是 2.4.20-0.1.0-07 + Gradle 9.6.1)

社区两条 HarmonyOS Kotlin 线:2.2.21-1.0.0(qrose 时代,须降 Gradle 8.14.1)与 2.4.20-0.1.0-07(KStore / KLoading 验证过,支持 Gradle 9.x)。本适配的依赖全部在 2.4.20 世代(coroutines 1.11.0-0.1.0-07、Compose 1.12.0-0.1.0-07),钉 2.4.20-0.1.0-07;上游 wrapper 8.5 随之升到 9.6.1。

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_compose_input_mask"

include(":compose-input-mask")
include(":example:nativeApp")
2.3 编译真实引擎

compose-input-mask/build.gradle.kts 收敛为纯 KMP 模块,依赖按"线"拆:

kotlin {
    applyDefaultHierarchyTemplate()
    jvm { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } }   // 交叉验证用
    ohosArm64()
    ohosX64()

    sourceSets {
        ohosMain.dependencies {
            api(libs.compose.runtime)          // 社区线 1.12.0-0.1.0-07
            api(libs.compose.ui)
        }
        jvmMain.dependencies {
            api(libs.compose.runtime.jvm)      // 上游线 1.5.11(Maven Central)
            api(libs.compose.ui.jvm)
        }
        commonTest.dependencies {
            implementation(kotlin("test"))
        }
    }
}

jvm 为什么钉 1.5.11:交叉验证的意义在于"用上游自己的构建线证明源码没漂"——1.5.11 正是上游 1.0.0 的 versions catalog 所钉的 compose 版本,换线不换版本。commonMain 保持零声明(commonMain stays line-agnostic),两条线各自在自己的源集上解析。不挂 compose compiler 插件:本库没有 @Composable 函数,插件只会空转——这也比动画库少了一个版本对齐面。实测:

.\gradlew.bat :compose-input-mask:compileKotlinJvm :compose-input-mask:compileKotlinOhosArm64 :compose-input-mask:compileKotlinOhosX64   # BUILD SUCCESSFUL in 56s

引擎的全部 commonMain 文件一个没动,也没有任何新增源文件——这是"零 actual、零新增"的适配面。

2.4 测试依赖的处置:上游测试跑在 JVM 上

上游 commonTest(TransformedTextTest)用了 AnnotatedString 与 VisualTransformation——依赖两条 compose 线之一才能编译。处置是两条纪律的组合:测试统一在 JVM 跑(jvmTest 的编译类包含 jvmMain 的 api 依赖,即上游线 1.5.11),根构建全局禁用 ohos 测试任务:

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

这样上游测试文件零改动保留,ohos 侧只承担编译与链接。附带一条已知边界:双线模式下 compileCommonMainKotlinMetadata 不可用(commonMain 零声明,元数据编译看不到任何一条线)——验收路径(compileKotlinJvm / compileKotlinOhos* / jvmTest)均为平台直编 commonMain,不经过该任务,与 KLoading / bubbles-compose 行为一致。

第 3 阶段:桥协议建模

3.1 JSON 契约
com.threemoly.composemask/**(上游引擎,原样编译)
        ↓ universalTransformedText(text, mask, maskCharsToKeep, placeholder)
Bridge.kt(@CName × 2,扁平 JSON 进出)
        ↓ UTF-8 JSON
N-API inputMaskVersion() / inputMaskApply()
        ↓
ArkUI Index.ets(双 Tab 演示)

请求与响应:

请求 : {"text":"11012001","mask":"dd/mm/yyyy","keep":"/","placeholder":"dd/mm/yyyy"}
       (placeholder 可缺省;keep 为固定分隔符字符集合,空串 = 无固定字符)
响应 : {"ok":true,"text":"11012001","mask":"dd/mm/yyyy","transformed":"11/01/2001",
        "split":10,"isPlaceholder":false,"o2t":[0,1,2,3,4,5,6,7,8,9,10],"t2o":[0,1,2,2,3,4,5,6,7,8,9,10]}
失败 : {"ok":false,"error":"..."}

两个不对称设计:split 直接取 originalToTransformed(text.length)——输入完成处之前的字符属于用户输入(光标前色),其余是掩码尾部模板(光标后色),与上游 AnnotatedString 两段 SpanStyle 的分界严格一致,不重新发明分界规则;o2t / t2o 是 OffsetMapping 的全量采样(长度分别为 text.length+1 与 transformed.length+1),光标语义是本库的核心价值,数组让 Demo 滑杆与自动化对拍都可直接使用。

3.2 载荷为什么全是扁平标量

KStore 的载荷是 JsonElement(存储天然是 JSON);掩码引擎的输入输出全是字符串与整数,所以桥保持"只认顶层标量"的 MiniJson 状态机(qrose / KLoading 同款),不为桥引入 kotlinx.serialization——少一个与 HarmonyOS Kotlin 版本对齐的依赖面。keep 用字符串承载字符集合("() -"),桥侧 toList().distinct() 还原上游 List<Char> 语义。

3.3 错误通道

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

@CName("InputMaskApply")
fun inputMaskApply(request: String): String = try {
    /* 解析请求 → 调上游引擎 → 序列化响应 */
} catch (t: Throwable) {
    """{"ok":false,"error":"${escape(t.message ?: t.toString())}"}"""
}

非法 JSON、空掩码、引擎异常,全部走同一个 ok:false 通道,ArkTS 侧统一渲染到状态区,HAP 不崩。

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

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

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

4.2 Kotlin/Native 导出函数
@CName("InputMaskVersion")
fun inputMaskVersion(request: String): String =
    """{"ok":true,"version":"1.0.0-ohos.1","upstream":"1.0.0"}"""

@CName("InputMaskApply")
fun inputMaskApply(request: String): String = try {
    val json = MiniJson.parse(request)
    val result = universalTransformedText(          // 上游引擎本体
        text = json.str("text"),
        mask = json.str("mask"),
        maskCharsToKeep = json.str("keep").toList().distinct(),
        placeholder = json.str("placeholder").takeIf { json.has("placeholder") && it.isNotEmpty() },
    )
    val transformed = result.text.text              // TransformedText → 显示串
    val o2t = IntArray(json.str("text").length + 1) { result.offsetMapping.originalToTransformed(it) }
    val t2o = IntArray(transformed.length + 1) { result.offsetMapping.transformedToOriginal(it) }
    /* …拼装 {"ok":true,"transformed":…,"split":…,"o2t":[…],"t2o":[…]} */
} catch (t: Throwable) { /* ok:false */ }
C ABI 符号ArkTS 方法覆盖内核
InputMaskApplyinputMaskApply(req)universalTransformedText 构造 + 掩码填充 + OffsetMapping
InputMaskVersioninputMaskVersion(req)fork 版本握手(真机版本行的数据来源)
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
    {"inputMaskVersion", nullptr, NapiInputMaskVersion, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
    {"inputMaskApply", nullptr, NapiInputMaskApply, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};

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

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

add_library(inputmask_napi SHARED napi_init.cpp)
target_link_libraries(inputmask_napi PUBLIC
  libace_napi.z.so
  ohosinputmask)

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

export const inputMaskVersion: (request: string) => string;
export const inputMaskApply: (request: string) => string;

第 5 阶段:ArkUI 宿主

5.1 触发变换
private applyMask(): void {
  const req: Record<string, string> = {
    'text': this.rawText,
    'mask': this.activeMask(),
    'keep': this.activeKeep()
  };
  if (this.usePlaceholder && this.activeMask().length > 0) {
    req['placeholder'] = this.activeMask();
  }
  const res = JSON.parse(inputMaskApply(JSON.stringify(req))) as MaskResponse;
  this.res = res;                  // @State 驱动双色显示 + 映射区刷新
  if (!res.ok) { this.lastError = res.error ?? ''; }
}

TextInput.onChange 里先做调用方过滤("仅数字输入"开关——上游 1.x 语义:VisualTransformation 不改原始值),再触发 applyMask();预设切换、占位符开关、自定义 mask/keep 修改同样即时重算。桥是同步 C 调用(一次变换是 O(mask 长度) 的字符扫描,微秒级),onChange 级调用频率毫无压力。

5.2 页面交互

掩码演示 Tab 自上而下:预设 chips(5 个 + 自定义,自定义展开 mask/keep 两个输入框)、双开关(仅数字输入 / 空输入占位符)、原始输入框(+ 填入示例按钮)、双色掩码大字(split 前黑后灰,占位态整体灰)、原始值/显示值长度对照行、光标映射区(两个 Slider 分别驱动 originalToTransformed(cursorOrig) 与 transformedToOriginal(cursorTrans) 的实时读数,下方 monospace 展示 o2t / t2o 全量数组)。关于 Tab 展示版本握手行、库简介、ArkTS → NAPI → C ABI → Kotlin 引擎 → Compose text 层 分层说明与仓库 / 社区链接。

5.3 对比度和错误边界

双色分界用近黑(#111827)与浅灰(#9CA3AF)拉开对比,占位态复用浅灰——对应上游 MaskStyle.Default 的黑/灰/灰观感;ok:false 时错误原文进状态区(浅红底卡片),Native 调用本身抛错(理论不该发生)同样被 try/catch 收进状态区。

第 6 阶段:示例与验证

6.1 工程结构
example/
├── nativeApp/
│   ├── build.gradle.kts                      双 ABI sharedLib(baseName=ohosinputmask)+ cxxShared + copyKnSo*
│   └── src/ohosMain/kotlin/Bridge.kt         @CName × 2 + MiniJson(直调上游引擎)
└── harmonyApp/
    ├── build-profile.json5                   signingConfigs 留空(签名由 DevEco 自动生成回填)
    ├── AppScope/app.json5                    bundleName org.terminator.ohos.inputmask、versionName 1.0.0-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/libohosinputmask_api.h
        ├── src/main/cpp/types/libinputmask_napi/{index.d.ts, oh-package.json5}
        └── src/main/ets/pages/Index.ets      ArkUI 验收页面(双 Tab)
6.2 构建、签名和安装
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot"
.\gradlew.bat :compose-input-mask:jvmTest :compose-input-mask:compileKotlinOhosArm64 :compose-input-mask:compileKotlinOhosX64
.\gradlew.bat :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64

签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书写入本机 ~/.ohos/config,不进入仓库(仓库内 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.inputmask -m entry

四、完整代码对照

4.1 整体架构

com.threemoly.composemask/**(上游引擎原样编译)
    │ universalTransformation / universalTransformedText / MaskStyle
Bridge.kt + MiniJson
    │ InputMaskApply / InputMaskVersion
libinputmask_napi.so
    │ inputMaskApply / inputMaskVersion
Index.ets
    │ JSON parse + @State 更新
    ▼
ArkUI 真机页面(双 Tab:掩码演示 / 关于)

4.2 文件清单

文件职责
settings.gradle.kts极简 ohos 构建、eazytec Maven 置前、ohos_compose_input_mask、sample 移出
gradle/libs.versions.tomlKotlin 2.4.20-0.1.0-07 + 双 Compose 线钉版(ohos 1.12.0-0.1.0-07 / jvm 1.5.11)
build.gradle.kts(根)版本 1.0.0-ohos.1、全局禁用 Ohos*Test* 任务
compose-input-mask/build.gradle.kts纯 KMP:jvm() + ohosArm64 + ohosX64,依赖按线拆
example/nativeApp/build.gradle.kts双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填
nativeApp/.../Bridge.kt2 个 C ABI、直调上游引擎、MiniJson、错误通道
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鸿蒙适配主文档(20 节)

4.3 关键 API 对照

层次API作用
引擎universalTransformation(mask, maskCharToKeep/maskCharsToKeep, placeholder, style)生产 VisualTransformation,挂到 Compose TextField
引擎universalTransformedText(text, mask, …)核心实现:TransformedText(AnnotatedString, OffsetMapping)
引擎MaskStyle(placeholderColor, beforeCursorColor, afterCursorColor)三段着色值类
NativeInputMaskApply / InputMaskVersionC ABI JSON 导出
N-APIinputMaskApply(req) / inputMaskVersion(req)向 ArkTS 暴露能力
ArkTSapplyMask()触发变换并刷新 UI

4.4 ArkTS 与 Kotlin 的边界

const res = JSON.parse(inputMaskApply(JSON.stringify({
  text: '11012001', mask: 'dd/mm/yyyy', keep: '/'
}))) as MaskResponse;
// res.transformed === '11/01/2001',res.split === 10
// res.o2t[8] === 10:原始末尾光标 → 显示串下标
val result = universalTransformedText(          // 上游函数,原样编译进 so
    text = "11012001", mask = "dd/mm/yyyy",
    maskCharsToKeep = listOf('/'),
)
result.text.text        // "11/01/2001"
result.offsetMapping.originalToTransformed(8)   // 10

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

五、关键决策说明

决策 1:真编译整库而不是提取零依赖内核

社区 Compose 线(1.12.0-0.1.0-07)已含掩码引擎需要的全部 text 层类型(AnnotatedString / SpanStyle / Color / OffsetMapping),抽核的必要性已经消失——再抽等于把上游 150 行核心逻辑手抄一遍,反而引入漂移风险。真编译让掩码填充、超长截断、占位符、光标映射全部是上游原件。

决策 2:工具链钉 Kotlin 2.4.20-0.1.0-07,Gradle 升 9.6.1

依赖全部在 2.4.20 世代(coroutines 1.11.0-0.1.0-07、Compose 1.12.0-0.1.0-07),与 KStore / KLoading 同线;Gradle 按"2.4.20 线支持 9.x"的经验从上游 8.5 升到 9.6.1,JDK 21 钉进 org.gradle.java.home。

决策 3:根构建替换(cmp_qrose 模式),sample/docs 留盘

上游根构建(AGP / compose classpath / vanniktech 发布)对鸿蒙没有保留价值,整个替换为极简 ohos 构建;sample(Android/iOS/js/desktop 示例)依赖被剥离的目标,留盘不进 settings.gradle.kts——其存在方式只有这一种。

决策 4:依赖按线拆,jvm 钉上游自己的 1.5.11

社区线不发布 *-desktop 子制品,jvm 目标必须走上游线才有交叉验证意义;而交叉验证要证明的是"源码没漂",所以钉上游 1.0.0 所用的 1.5.11(换线不换版本),而不是追新。

决策 5:桥零复刻,split 取 originalToTransformed(text.length)

库无 @Composable,API 是值级纯函数——桥不需要也不应该复刻(对比 KLoading 桥侧整列帧数学复刻)。分界规则不重新发明:直接取上游 originalToTransformed(text.length),与 AnnotatedString 两段 SpanStyle 严格一致。

决策 6:jvmTest 交叉验证 + 测试零改动

引擎逻辑在 commonMain,JVM 与 ohos 跑同一份代码;6 项 jvmTest 跑的就是上游原测试文件(含超长截断、全 keep、filter() 通路),是"内核没漂"的闸门。测试文件一个字节不动,交叉验证的有效性才成立。

六、测试与验证

6.1 测试环境

本次验证使用 Windows(x86_64)、JDK 21(Eclipse Temurin)、DevEco Studio 5.1.0 Release、OpenHarmony 命令行工具(hvigor / hdc)与鸿蒙设备(arm64)。

6.2 JVM 交叉验证

.\gradlew.bat :compose-input-mask:jvmTest

6 项用例全部通过(上游原测试文件,零改动):

测试类用例钉住什么
TransformedTextTesttest_blank空文本 + 空掩码 → 空显示
test_transformed_text22335555 + __.__.____ → 22.33.5555(分隔符插入)
test_transformed_text_filled超长输入 2233555566 → 截断为 22.33.5555
test_empty_mask_but_text有文本无掩码 → 空显示
test_empty_mask_but_all_mask_chars_kept全 keep(. 与 _)→ 掩码整体显示
test_empty_mask_but_all_mask_chars_kept_2VisualTransformation.filter(AnnotatedString("88")) 通路 → 88.__.____

换线不换版的直接证据:同一批源码对上游线 1.5.11 与社区线 1.12.0-0.1.0-07 各自编译通过,上游测试照跑全绿。

6.3 HAP 验证

:compose-input-mask:compileKotlinOhosArm64 / OhosX64: BUILD SUCCESSFUL
:example:nativeApp:linkDebugSharedOhosArm64 / OhosX64: BUILD SUCCESSFUL (2m 41s)
llvm-nm -D(cpf-llvm-19 工具链): InputMaskApply / InputMaskVersion / libohosinputmask_symbols 均为全局 T 符号(双 ABI)
BuildNativeWithCmake: finished(双 ABI 编出 libinputmask_napi.so)
CompileArkTS: finished
PackageHap: finished
BUILD SUCCESSFUL
install bundle successfully
start ability successfully

libohosinputmask.so arm64 约 27.8 MB、x86_64 约 26.5 MB(debug 未裁剪,含 Kotlin/Native 运行时 + 整条社区 Compose 线——这是"整库编译"路线的体积代价,HAP 打包 strip 后显著缩小);每个 ABI 目录三件套(libinputmask_napi.so + libohosinputmask.so + libc++_shared.so 约 1.9 / 1.7 MB)。

6.4 功能用例

  1. 启动后关于页 / 握手出现 1.0.0-ohos.1(来自真实 Native 调用,非静态文字);
  2. 选「日期 dd/mm/yyyy」预设,输入 11012001,显示区出现 11/01/2001——前段近黑、后段浅灰,与 MaskStyle 双色语义一致;
  3. 原始值行显示 raw "11012001"(长度 8),证明 VisualTransformation 未改原始值(上游 1.x 设计);
  4. 拖动上方滑杆:originalToTransformed(3) = 4(原始第 3 位对应显示串 01|/… 处),下方滑杆反向验证 transformedToOriginal(3) = 2(显示串 / 前一位回到原始 2)——o2t/t2o 数组与手工推算一致;
  5. 继续输入至 110120012(9 位),显示仍为 11/01/2001(超长截断),滑杆读数不越界;
  6. 清空输入,「空输入占位符」开启时整体浅灰显示 dd/mm/yyyy(占位符态),关闭时显示区为空;
  7. 切换生日 / 电话 / 卡号 / 时间预设及「自定义」(如 +## (###) ###-##-## + keep ()+- ),格式化即时生效;
  8. 「仅数字输入」关闭后输入字母,字母照常进掩码槽(上游对字符类型不做限制的诚实暴露);
  9. 全程无崩溃、无 ok:false(状态区留空)。

6.5 验证结论

掩码引擎、Compose text 层依赖、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙设备上成功安装并进入前台,页面展示了真实的掩码会话:版本握手、格式化变换、双色显示、占位符态与光标映射闭环(见下节效果图)。

七、运行效果

7.1 真机截图

在这里插入图片描述

7.2 命令速查

git clone https://atomgit.com/oh-tpc/compose-input-mask.git
cd compose-input-mask
export JAVA_HOME="/path/to/jdk-21"        # Windows 会话内 $env:JAVA_HOME 指向 Temurin 21
./gradlew :compose-input-mask:compileKotlinOhosArm64 :compose-input-mask:compileKotlinOhosX64   # klib
./gradlew :compose-input-mask:jvmTest                                                            # 6 项交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.inputmask -m entry
hdc shell hilog | grep InputMaskDemo

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP 适配,必须让 :compose-input-mask 真正编译成 ohosArm64 / ohosX64 动态库;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. 社区 Compose 线不发布 *-desktop 子制品——jvm 交叉验证必须钉上游线 1.5.11,版本主体写对、线选错,解析照样失败;
  4. gradle.properties 的 org.gradle.java.home 只管构建期 JVM,不管 wrapper 启动;会话不设 JAVA_HOME,gradlew.bat 第一步就报 JAVA_HOME is not set;
  5. 双线模式下 compileCommonMainKotlinMetadata 不可用(commonMain 零声明,元数据编译看不到任何一条线)——验收路径均为平台直编,不经过该任务;不要对该模块跑 gradlew build;
  6. build-profile.json5 的 signingConfigs 条目缺 material 字段会被 hvigor schema 拒绝——仓库内留空数组且 product 引用 "default",勾选自动签名后由 IDE 回填完整配置;
  7. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套缺一不可;
  8. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  9. ArkTS 不能 import KN so(CAdapter 不导出 @CName 业务函数),只 import CMake 包装的 libinputmask_napi.so;nm_modname / so 名 / import 名 / index.d.ts 导出名四处必须一致;
  10. 上游 sample/docs/workflow 留盘但不进构建;被替换的构建文件备份为 *.upstream,与 entry/libs/ 产物一样不进提交(*.so / *.hap 已进 gitignore)。

8.2 已知问题

  • VisualTransformation 是 Compose TextField 内部机制,ArkUI 无对应物:鸿蒙侧等价暴露的是"显示串 + 光标映射",输入框内边输边格式化需要业务方用 caretPosition + t2o/o2t 自行实现(本 Demo 定位是验收引擎,不做输入框内注入);
  • 上游 1.x 设计:掩码不改原始值,输入过滤(仅数字、限长)由调用方完成——桥协议同样只接收调用方过滤后的 text;
  • 掩码不做语义校验(dd 不检查合法日期),只做格式化显示——与上游行为一致;
  • MaskStyle 颜色不跨桥:桥返回 split/isPlaceholder 分段信息,颜色由 ArkTS 侧自定(Demo 用上游 Default 的黑/灰/灰同款观感);
  • android / js / wasmJs / apple 目标剥离(源文件留在树里),发布配置一并剥离,本 fork 不发 Maven Central;
  • 社区 Compose 线出现 composition host / ohos 运行时后,本 fork 可把 Demo 切到真实 Compose 渲染,库侧无需任何改动。

8.3 未来优化方向

  • 补充 uitest 自动化截图脚本(dumpLayout + uiInput 输入预设样本 + snapshot_display),把功能用例脚本化;
  • 评估在 Demo 里用 caretPosition 实现输入框内实时格式化(光标换算走 o2t/t2o),演示更完整的 TextField 语义;
  • 评估 .har 形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套;
  • 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
  • 补充模拟器(x86_64)与其他设备 ABI 的验收矩阵。

九、总结

9.1 核心难点回顾

上游 compose-input-mask(全部 commonMain,零 expect/actual,零 @Composable)
    → 验证 Compose ohos 制品线(ui/ui-text/runtime 1.12.0-0.1.0-07)
    → 工具链对齐 Kotlin 2.4.20-0.1.0-07(Gradle 8.5 → 9.6.1)
    → 根构建替换(cmp_qrose 模式,sample 留盘)
    → 依赖按线拆:ohosMain 社区线 / jvmMain 上游线 1.5.11
    → ohosArm64 / ohosX64 KLIB(零新增源文件)
    → C ABI(@CName × 2)+ 扁平 JSON + OffsetMapping 全量采样
    → C++ N-API(DisposeString 纪律)
    → ArkTS JSON 解析
    → ArkUI 真机双 Tab 验收(变换 / 双色显示 / 占位符 / 光标映射 / 版本握手)

9.2 封装层次

  • 引擎层:上游原版 universalTransformation / universalTransformedText / MaskStyle;
  • Native 层:生成双 ABI 动态库并通过 2 个 C ABI 输出 JSON;
  • N-API 层:完成参数检查、字符串转换和 Native 内存释放;
  • ArkTS 层:管理页面状态、调用方输入过滤、JSON 解析、双色渲染与映射交互;
  • DevEco 层:完成 CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. 先验证生态制品线再定适配路线:Compose ohos 线补齐后,text 层库的正确答案就是"真编译整库 + 直调引擎"——qrose 时代的抽核结论不能沿用;路线选择必须基于当天的制品可达性;
  2. 交叉验证的线要钉上游自己的版本:jvm 线用上游 1.0.0 所依赖的 compose 1.5.11 而不是追新,"换线不换版本"才让 6 项上游测试零改动全绿、才有"内核没漂"的说服力;
  3. "值级 API 的库"是桥接强度的天花板:无 @Composable 意味着桥不需要复刻任何行为——每个输出都可追溯到上游函数本体,这是比"复刻帧数学"更强的一档验收形态。

9.4 适配成果

当前适配已完成:

  • compose-input-mask 1.0.0 真实引擎的双 ABI Kotlin/Native 编译(上游 commonMain 文件零重写、零新增源文件、零 expect/actual);
  • Compose 鸿蒙制品线在输入掩码库上的完整落地(社区线 1.12.0-0.1.0-07 编 ohos klib + 上游线 1.5.11 做 jvm 交叉验证);
  • 2 个 C ABI 符号和 C++ N-API 包装层(llvm-nm 验证全局 T 符号,双 ABI);
  • DevEco Stage ArkUI 双 Tab 验收页面(掩码变换、双色显示、占位符、光标映射检查器、版本握手);
  • 6 项 JVM 交叉验证全绿(上游原测试文件)+ HAP 构建(双 ABI 六件套 so)、签名、设备安装和真机验证;
  • 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开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐