开源鸿蒙平台 CMP 三方库「otp-field」适配全流程
本文记录
otp-field接入 OpenHarmony 的完整过程,覆盖 Compose UI 库的策略提取、鸿蒙工具链钉版、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。
otp-field是一个 Compose Multiplatform 的分段式 OTP / PIN 输入框库:整个输入框背后只有一个透明BasicTextField,可见的分段盒子由库自己绘制,因此系统剪贴板的一次粘贴能填满所有盒子,自动前进/回退由值长度驱动。本次适配与 KStore 那类「整库真编译」不同:上游的价值一半在 Compose UI、一半在纯 Kotlin 输入策略,而 Compose 侧与社区 CMP 1.9.2 工具链对不齐。因此适配把 Composable 内部的handleChange输入策略1:1 提取成 Compose-free 的OtpFieldEngine编进 ohos——逐字符过滤、单字符输入/删除判定、剪贴板粘贴「全有或全无」、PIN 模式短暂明文位、onComplete恰好触发一次的语义、样式档位与抖动关键帧全部是上游原件;Compose UI 原样留在composeMain等工具链对齐,ArkUI 页面按桥返回的策略状态重画分段盒子,作为验收宿主。
项目地址: AtomGit/oh-tpc/otp-field
上游仓库: NadeemIqbal/otp-field
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
otp-field 为 Compose Multiplatform 应用提供「验证码输入」这一件事的完整答案:OtpField(length, onComplete) 一行出六个分段盒子,state-hoisted 重载把 value / onValueChange 交给调用方,粘贴三态(合法填满 / 超长截断 / 非法整段拒绝)、PIN 遮蔽 + 刚输位短暂明文、错误态水平抖动、三档样式全部内置。KMP 项目把这些能力放在 commonMain,由 Android、iOS、桌面、Web 共享。OpenHarmony 应用同样需要这套输入体验,但上游构建(Kotlin 2.3.21 / Compose Multiplatform 1.10.3 / Gradle 9.5.0 / AGP 9.2.0)不会生成 OpenHarmony 变体。
如果只把示例页面改成 ArkTS,页面可以显示盒子,却无法证明上游的输入策略在 OpenHarmony ARM64 设备上完成编译、链接和执行——「能输入数字」不是验收,「拒绝得和上游一致」才是。适配需要同时验证策略引擎、字符过滤语义、粘贴三态判定、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。 |
| 工具链不一致 | 上游 Kotlin 2.3.21 / Compose 1.10.3 / Gradle 9.5.0 / AGP 9.2.0 与鸿蒙工具链无法共存于一个 Gradle 构建。 |
| Compose 不可编 | BasicTextField、Animatable、FocusRequester、MaterialTheme 全在 androidx.compose 里,社区 CMP 1.9.2 没有对应 ohos 制品,Composable 无法编进 ohos。 |
| 策略藏在 UI 里 | 上游一半策略写在 Composable 的 handleChange 里(revealedIndex、恰好一次的 onComplete),不提取就过不了桥。 |
| 类型绑定 Compose | charValidatorFor 吃 KeyboardType、OtpStyleSpec 拿 Shape、OtpFieldColors 是 Color 模型,都要在拆分时逐个判定。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在:输入策略的 1:1 提取与 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。输入判定完全由上游策略负责,ArkTS 只负责调桥、解析 JSON 和把盒子画出来。
1.2 库提供的能力
Demo 提供一个真实的分段输入会话,对应桥接的全部入口:
- 输入(process):
OtpFieldEngine.processWithMeta(old, raw)把上游handleChange完整跑一遍——合法追加返回新值和revealedIndex(PIN 模式刚输位),非法字符拒绝,多字符增长按粘贴判定「全有或全无」,进入满位时给出justCompleted(onComplete时刻); - 长度校验(validate):
length <= 0抛出的上游异常文案原样跨桥变成 JSONerror,HAP 不崩; - 样式(style):
Boxed/Underlined/Rounded三档对应的drawBox/drawUnderline/ 默认圆角(8dp / 16dp / 0); - 抖动(shake):上游
shakeOffsets()关键帧[0, -12, 10, -8, 6, -3, 0]+ 每帧 45ms 原样透出,ArkUI 逐帧回放; - 版本握手(version):
0.1.0-ohos.1来自真实 Native 调用而非静态文字。
响应包含:
| 字段 | 作用 |
|---|---|
ok | 操作是否成功,失败时 error 带原文。 |
op | 回显本次操作。 |
accepted / value | process 是否接受本次编辑;接受时返回过滤、截断后的新值。 |
revealedIndex | PIN 模式下刚输入位的下标(-1 表示无)。 |
complete / justCompleted | 当前是否满位;是否刚刚由不满转满(onComplete 只在这一拍触发)。 |
引擎侧完整能力(字符过滤、粘贴三态、满位替换不重复触发、样式规格、抖动关键帧)全部随 OtpFieldEngine 编进 .so,JVM 侧保留 21 项 jvmTest 交叉验证用例(其中 13 项从上游 OtpFieldLogicTest 逐条移植),证明 ohos .so 里执行的输入策略没有漂移。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | 输入策略从上游 OtpFieldInternal.kt + handleChange 1:1 提取成 OtpFieldEngine,判定逻辑一行不改;上游 Compose UI 五个文件原样迁入 composeMain,不提取、不重写、不删除。 |
| 平台目标 | 根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。 |
| 构建隔离 | 根构建替换为极简 ohos 构建(cmp_qrose 模式);上游 sample/ 与 skikoTest 源集留盘不进构建。 |
| 桥接稳定 | 使用 1 个 C ABI 和 UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。 |
| 依赖纪律 | 引擎零第三方依赖(纯 Kotlin stdlib),不引入 kotlinx-serialization,桥用继承自 cmp_qrose 审核模式的手写 MiniJson。 |
| UI 完整 | 四 Tab 覆盖全部桥接入口:三档样式完整输入闭环 + 策略面板(握手、校验、样式规格、抖动关键帧、文本过滤)。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。上游的 Composable 与社区 CMP 工具链对不齐,适配面天然落在「策略复用、UI 隔离」:ArkTS Demo 持有
value(与 Compose 调用方完全一致),把每次编辑交给桥判定,再按策略状态把盒子画出来。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点模块与源码依赖边界、按「是否依赖 androidx.compose」切源码集、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、引擎提取与测试移植
第 3 阶段:桥协议建模 ── 五 op JSON 契约、handleChange 元策略整条过桥、错误通道
第 4 阶段:原生桥接 ── @CName C ABI、C++ N-API、参数校验和 DisposeString
第 5 阶段:ArkUI 宿主 ── 分段盒子、数字键盘、粘贴演示、错误抖动和策略面板
第 6 阶段:示例与验证 ── HAP、签名、设备安装、真机运行和效果图
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
otp-field/ 上游库模块(进 ohos 构建,jvm() + ohosArm64 + ohosX64)
otp-field/src/commonMain/ OtpFieldEngine(新增,策略 1:1 提取)+ OtpFieldStyle(纯枚举,保留)
otp-field/src/composeMain/ 上游 Compose UI 五文件原样迁入(ohos 不编,保留对照)
otp-field/src/commonTest/ OtpFieldEngineTest(21 用例,jvmTest 跑)
otp-field/src/skikoTest/ 上游 Compose UI 测试,留盘不进构建
example/nativeApp/ Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/ DevEco Stage、CMake N-API 和 ArkTS 四 Tab 页面
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份)
README.upstream.md 上游原文保存
sample/ 上游 Compose 示例工程,留盘不进构建
构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :otp-field 与 :example:nativeApp,rootProject.name = "ohos_otpfield"。上游的 AGP / Compose / vanniktech 发布插件、compose-multiplatform classpath 全部随根构建替换剥离——本 fork 不发 Maven Central。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(Eclipse Temurin,org.gradle.java.home 钉入 gradle.properties) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm() 保留作交叉验证) |
| Gradle | 8.14.1(2.2.21 线的配套版本,wrapper 随根构建替换) |
| 第三方依赖 | 无(引擎只用 Kotlin stdlib;不拉 kotlinx-serialization 进 KN) |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.otpfield |
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点
上游 commonMain 只有五个文件,按「是否依赖 androidx.compose」逐个判定:
| 文件 | 依赖形态 | 处置 |
|---|---|---|
OtpField.kt | Composable、BasicTextField、Animatable、FocusRequester | 迁入 composeMain(原样保留) |
OtpFieldColors.kt | androidx.compose.ui.graphics.Color 九槽色板 | 迁入 composeMain |
OtpFieldDefaults.kt | MaterialTheme、Dp、Shape | 迁入 composeMain |
OtpFieldInternal.kt | 混合:processOtpInput 是纯函数,charValidatorFor 吃 KeyboardType,OtpStyleSpec 拿 Shape | 迁入 composeMain(原样保留) |
OtpFieldStyle.kt | 纯枚举,零依赖 | 留在 commonMain(桥按名解析,composeMain 共用) |
策略提取放在 engine 子包,全仓唯一新增 Kotlin 源文件:
// otp-field/src/commonMain/kotlin/io/github/nadeemiqbal/otpfield/engine/OtpFieldEngine.kt
class OtpFieldEngine(
val length: Int,
val inputFilter: OtpInputFilter = OtpInputFilter.Digits,
) {
init { requireValidOtpLength(length) } // 上游异常文案原样保留
val isCharValid: (Char) -> Boolean = charValidatorFor(inputFilter)
fun process(old: String, new: String): String? =
processInput(old, new, length, isCharValid) // 1:1 上游 processOtpInput
fun processWithMeta(old: String, new: String): OtpProcessResult
// 1:1 上游 handleChange:accepted / value / revealedIndex / complete / justCompleted
companion object {
fun charValidatorFor(filter: OtpInputFilter): (Char) -> Boolean
fun shakeOffsets(): List<Float> // [0, -12, 10, -8, 6, -3, 0]
fun shakeStepDurationMillis(): Int // 45
fun drawBoxFor(style: OtpFieldStyle): Boolean
fun drawUnderlineFor(style: OtpFieldStyle): Boolean
fun defaultCornerRadiusDpFor(style: OtpFieldStyle): Int // 8 / 16 / 0
}
}
两个类型映射在提取时定死:上游 KeyboardType 的数字族(Number / NumberPassword / Decimal / Phone)收敛成 OtpInputFilter.Digits,其余键盘类型收敛成 OtpInputFilter.Text;上游 resolveOtpStyle 的 Shape 不跨桥,引擎只透出 drawBox / drawUnderline / 默认圆角三个纯标量。
1.4 页面边界
标题区 OtpField / 副标题行(A polished segmented OTP / PIN input — policy in Kotlin/Native)
Tab 切换 Boxed / Underlined / Rounded / Policy(对应上游 sample 的三档样式 Tab + 策略面板)
盒子区 length 个 48×56 分段盒(圆角按样式档位,Underlined 只画底线),活动盒高亮 + 光标
读出行 Value / Completed · 桥响应状态行(accepted / value / revealed / justCompleted)
长度区 4 / 6 / 8 三档芯片(切换清空重新输入)
开关区 Obscure(PIN 遮蔽,刚输位明文 600ms 后回遮)
操作区 Trigger error / Paste valid / Paste invalid 三按钮
键盘区 1-9、⌫、0 屏上数字键盘(全部走桥的 process)
策略面板 version 握手、validate(6)/validate(0)、三档样式规格表、shake 关键帧、filter:text 演练
页面启动时先调 version op 做握手,四个 Tab 共享同一套桥调用封装。
第 2 阶段:目标与依赖打通
2.1 工具链钉版(为什么是 2.2.21-1.0.0)
社区已审的钉法是 HarmonyOS Kotlin 2.2.21-1.0.0(qrose / number-keyboard 用的)。本适配没有依赖压力——引擎零第三方依赖——所以直接沿用社区成熟钉法:Kotlin 2.2.21-1.0.0、Gradle 8.14.1、JDK 21。上游 Kotlin 2.3.21 + Compose 1.10.3 + AGP 9.2.0 那套在鸿蒙上直接 sync 会在配置期就死,不值得尝试调和。
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_otpfield"
include(":otp-field")
include(":example:nativeApp")
2.2 根构建替换与源码集拆分
库模块构建收敛为纯 KMP:
kotlin {
applyDefaultHierarchyTemplate()
jvmToolchain(21)
jvm() // 交叉验证用
ohosArm64()
ohosX64()
sourceSets {
commonMain.dependencies {} // 引擎零依赖
commonTest.dependencies {
implementation(kotlin("test"))
}
}
}
四个 Compose 文件用 git mv 原样迁入 src/composeMain/——composeMain 不是任何 target 的源集,不会被 ohos 编译,文件里的 @Immutable 等 Compose 注解不用单独剥。拆分时最典型的一刀在 OtpFieldInternal.kt:它一半是纯函数(processOtpInput / requireValidOtpLength / shakeOffsets)、一半绑着 Compose 类型(KeyboardType / Shape)。处置是「文件整体迁 composeMain 保上游原貌,引擎在 engine 子包按语义 1:1 重写」——composeMain 不参与编译,同名函数不构成符号冲突。
2.3 编译验证
./gradlew :otp-field:jvmTest # 21/21 BUILD SUCCESSFUL
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 # BUILD SUCCESSFUL
./gradlew :example:nativeApp:linkDebugSharedOhosX64 # BUILD SUCCESSFUL
链接期 clang 有一条 CAdapter 生成代码(api.cpp 背板)的警告,与 qrose / number-keyboard 记录的相同,.so 正常链出。
2.4 上游测试的处置:随 Compose UI 退出编译
上游 OtpFieldLogicTest 引用了 KeyboardType 与 OtpFieldColors,这两个类型已随 UI 迁入 composeMain,旧测试文件继续留在 commonTest 会直接卡死 compileTestKotlinJvm。处置不是删测试,而是按引擎 API 1:1 重写:上游 14 个用例中的 13 个逻辑用例逐条移植(同一输入断言同一结果),OtpFieldColors.copy 用例属纯 Compose 语义不移植;另补 handleChange 元策略(reveal / justCompleted)、文本过滤、样式规格三组新用例,共 21 项。测试覆盖没有净损失,交叉验证的有效性反而更完整。
第 3 阶段:桥协议建模
3.1 JSON 契约
io.github.nadeemiqbal.otpfield.engine/**(OtpFieldEngine,进 ohos 编译)
↓ processWithMeta / requireValidOtpLength / 样式与抖动规格
Bridge.kt(@CName × 1,扁平 JSON 进出)
↓ UTF-8 JSON
N-API otpFieldBridge()
↓
ArkUI Index.ets(四 Tab)
五个 op 的请求与响应:
请求 : {"op":"process","value":"123","raw":"1234","length":6,"filter":"digits"}
{"op":"validate","length":0}
{"op":"style","style":"Boxed"}
{"op":"shake"} {"op":"version"}
响应 : {"ok":true,"op":"process","accepted":true,"value":"1234","changed":true,
"revealedIndex":3,"complete":false,"justCompleted":false}
{"ok":false,"error":"OtpField length must be greater than 0; was 0."}
{"ok":true,"op":"style","style":"Boxed","drawBox":true,"drawUnderline":false,
"defaultCornerRadiusDp":8}
{"ok":true,"op":"shake","offsets":[0,-12,10,-8,6,-3,0],"stepDurationMillis":45}
{"ok":true,"op":"version","version":"0.1.0-ohos.1","upstream":"0.1.0"}
process 是唯一入口:单字符输入、删除、整段粘贴全部走它,桥按上游规则自动区分——new 比 old 长超过 1 位即视为粘贴。桥无状态,value 由 ArkTS 持有(与上游 state-hoisted 语义完全一致),被拒绝时 accepted:false,UI 保持原状。
3.2 handleChange 元策略为什么整条过桥
上游的价值不止「过滤后的字符串」。handleChange 里藏着两条最容易在重写时漂移的语义:
revealedIndex:仅当本次编辑是「恰好追加一个字符」时,指向新字符(processed.length == old.length + 1),PIN 模式靠它实现「刚输位短暂明文」;粘贴和删除一律 -1;justCompleted:仅在字段由不满转满的那一拍为真——满位字段内替换字符(如123456→923456)上游会接受,但onComplete不重复触发。
引擎把这些判定整体放进 processWithMeta,桥原样透出。ArkTS 侧 justCompleted=true 才触发业务回调,accepted=false 时什么都不做——这本身就是上游语义。
3.3 错误通道
导出函数整体 try/catch,Kotlin 异常永远不跨 FFI:
@CName("OtpFieldBridge")
fun otpFieldBridge(request: String): String = try {
/* op 分发 */
} catch (t: Throwable) {
"""{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
}
validate(0) 会把上游 requireValidOtpLength 的原始异常文案带回——OtpField length must be greater than 0; was 0.——契约级的文案跨桥而不是崩 HAP。非法 JSON、未知 op、未知样式名,全部走同一个 ok:false 通道。
第 4 阶段:原生桥接(技术难点)
4.1 三层桥接
ArkTS
│ JSON string
▼
C++ N-API entry(libotpfield_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libohosotpfield.so)
│ OtpFieldBridge
▼
UTF-8 JSON + DisposeString
HarmonyOS Kotlin 的 CAdapter 不会把 @CName 业务函数挂到 ArkTS 模块上——libohosotpfield.so 里 llvm-nm 能看到 OtpFieldBridge,但 ArkTS 直接 import KN so 看不到它,必须用 CMake 包一层 NAPI so。
4.2 Kotlin/Native 导出函数
@CName("OtpFieldBridge")
fun otpFieldBridge(request: String): String // process / validate / style / shake / version 一口进
| C ABI 符号 | ArkTS 方法 | 覆盖内核 |
|---|---|---|
OtpFieldBridge | otpFieldBridge(req) | OtpFieldEngine 全部策略 + OtpFieldStyle 规格 + 抖动关键帧 + 版本握手 |
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
{"otpFieldBridge", nullptr, NapiOtpFieldBridge, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};
回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohosotpfield_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohosotpfield_api.h 是 Kotlin/Native 在 debugShared/ 目录生成的 C 头文件,链接后拷入 cpp/include/ 并入库,只取 OtpFieldBridge 声明与 ExportedSymbols 结构两部分。
4.4 CMake 和类型声明
add_library(ohosotpfield SHARED IMPORTED)
set_target_properties(ohosotpfield PROPERTIES
IMPORTED_LOCATION ${KN_SO}
IMPORTED_NO_SONAME TRUE)
add_library(otpfield_napi SHARED napi_init.cpp)
target_link_libraries(otpfield_napi PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
ohosotpfield)
OHOS_ARCH 为空或 libohosotpfield.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。Gradle 侧 copyKnSo* 任务挂在 linkDebugShared* 的 finalizedBy 上,自动把 KN so 与对应 triple 的 libc++_shared.so 回填 entry/libs/<abi>/。
export const otpFieldBridge: (request: string) => string;
第 5 阶段:ArkUI 宿主
5.1 状态与触发操作
aboutToAppear(): void {
this.fetchVersion(); // 版本握手:来自真实 Native 调用
}
private dispatchProcess(tab: TabKey, raw: string): void {
const req: BridgeRequest = { op: 'process', value: this.currentValueOf(tab),
raw, length: this.lengthFor(tab), filter: 'digits' };
const res = JSON.parse(otpFieldBridge(JSON.stringify(req))) as ProcessResult;
// accepted=true 才更新 value;revealedIndex 进 @State;justCompleted=true 才触发完成回调
}
所有输入(数字键、⌫、两个粘贴按钮)都汇入 dispatchProcess——包括删除和粘贴,这证明输入策略跑在 Kotlin/Native 里。错误抖动从 shake op 拿关键帧数组,按 stepDurationMillis 逐帧设 translate.x,播完整组再收 isError(关键帧首尾都是 0,字段必须回到原位)。
5.2 页面交互
三个样式 Tab(Boxed / Underlined / Rounded)自上而下:标题区、分段盒子行、Value / Completed 读出行、桥响应状态行(如 process ok accepted=true value=123456 revealed=5 justCompleted=true)、长度芯片(4 / 6 / 8,切换清空)、Obscure 开关、三操作按钮、屏上数字键盘。Paste valid 一次填满触发 justCompleted;Paste invalid(12ab34…)整段拒绝不半填;Trigger error 全盒变红并按桥回关键帧抖动。Policy Tab 展示版本握手、validate(6) / validate(0)(后者展示上游异常文案跨桥)、三档样式规格表(drawBox / drawUnderline / 圆角)、shake 关键帧和 filter: text 文本过滤演练(AB12 接受、A B 因空格拒绝)。
5.3 对比度和错误边界
活动盒蓝色高亮(2px 边框 + 光标),错误态全盒红色,触发按钮红色描边,语义与破坏性对齐;PIN 遮蔽显示 •,revealedIndex 位短暂明文 600ms 后由 ArkTS 定时器回遮。ok:false 时错误原文进状态行,Native 调用本身抛错(理论不该发生)同样被 try/catch 收进 hilog。
第 6 阶段:示例与验证
6.1 工程结构
example/
├── nativeApp/
│ ├── build.gradle.kts 双 ABI sharedLib(baseName=ohosotpfield)+ cxxShared + copyKnSo*
│ └── src/ohosMain/kotlin/example/nativeapp/
│ └── Bridge.kt @CName + MiniJson(扁平 JSON,继承自 cmp_qrose 审核模式)
└── harmonyApp/
├── build-profile.json5 signingConfigs 留空(签名由 DevEco 自动生成回填)
├── AppScope/app.json5 bundleName org.terminator.ohos.otpfield、versionName 0.1.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/libohosotpfield_api.h
├── src/main/cpp/types/libotpfield_napi/{index.d.ts, oh-package.json5}
└── src/main/ets/pages/Index.ets ArkUI 四 Tab 验收页面
6.2 构建、签名和安装
./gradlew :otp-field:jvmTest # 21 项交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
# DevEco Studio 打开 example/harmonyApp(不要打开仓库根目录),构建 HAP
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,仓库内 build-profile.json5 的 signingConfigs 保持空数组、product 引用名为 default 的签名配置,勾选后由 IDE 回填完整证书——证书绝不进入 AtomGit。
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.otpfield -m entry
四、完整代码对照
4.1 整体架构
io.github.nadeemiqbal.otpfield.engine/**(策略引擎,随 ohos 编译)
│ OtpFieldEngine / OtpInputFilter / OtpProcessResult
io.github.nadeemiqbal.otpfield/**(OtpFieldStyle 枚举,桥与 composeMain 共用)
│ Boxed / Underlined / Rounded
Bridge.kt + MiniJson
│ OtpFieldBridge
libotpfield_napi.so
│ otpFieldBridge
Index.ets
│ JSON parse + @State 更新
▼
ArkUI 真机四 Tab 页面(三档样式闭环 + 策略面板)
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts | 极简 ohos 构建、eazytec Maven 置前、ohos_otpfield、sample 移出 |
gradle/libs.versions.toml | 只剩 Kotlin 2.2.21-1.0.0 一个版本、一个插件别名 |
build.gradle.kts(根) | 版本 0.1.0-ohos.1、全局禁用 Ohos*Test* 任务 |
otp-field/build.gradle.kts | 纯 KMP:jvm() + ohosArm64 + ohosX64,commonMain 零依赖 |
otp-field/src/composeMain/** | 上游 Compose UI 五文件原样保留(ohos 不编) |
otp-field/src/commonMain/.../engine/OtpFieldEngine.kt | 唯一新增源文件(策略 1:1 提取) |
otp-field/src/commonTest/.../engine/OtpFieldEngineTest.kt | 21 项交叉验证(13 项从上游逐条移植) |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 |
nativeApp/.../Bridge.kt | 1 个 C ABI、五 op 分发、MiniJson、错误通道 |
harmonyApp/.../cpp/napi_init.cpp | N-API 导出、参数检查、DisposeString |
harmonyApp/.../cpp/CMakeLists.txt | IMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套 |
harmonyApp/.../ets/pages/Index.ets | ArkUI 四 Tab 验收页面 |
README.OpenHarmony_CN.md | 鸿蒙适配主文档(16 节) |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 引擎 | OtpFieldEngine(length, inputFilter) | 构造即校验长度(上游异常文案原样) |
| 引擎 | process(old, new) | 粘贴三态 / 单字符编辑判定(1:1 processOtpInput) |
| 引擎 | processWithMeta(old, new) | handleChange 全量元策略:accepted / revealedIndex / justCompleted |
| 引擎 | charValidatorFor(filter) | Digits 只收数字,Text 收非空白可打印字符 |
| 引擎 | shakeOffsets() / shakeStepDurationMillis() | 抖动关键帧与单帧时长 |
| 引擎 | drawBoxFor / drawUnderlineFor / defaultCornerRadiusDpFor | 三档样式规格(8 / 16 / 0 dp) |
| Native | OtpFieldBridge | C ABI JSON 导出 |
| N-API | otpFieldBridge(req) | 向 ArkTS 暴露能力 |
| ArkTS | dispatchProcess(tab, raw) | 触输入判定并刷新盒子与状态行 |
4.4 ArkTS 与 Kotlin 的边界
// 一次 6 位粘贴:raw 一次给整段,桥判粘贴、返回满位转换
const res = JSON.parse(otpFieldBridge(JSON.stringify({
op: 'process', value: '', raw: '123456', length: 6, filter: 'digits'
})));
// res.accepted === true, res.justCompleted === true —— onComplete 时刻
// 桥内:上游 handleChange 语义原样执行
val engine = OtpFieldEngine(length = json.int("length", 6),
inputFilter = parseFilter(json.str("filter")))
val r = engine.processWithMeta(old = json.str("value"), new = json.str("raw"))
两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构;ArkTS 侧不做任何二次过滤——桥返回的 value 已经是「过滤 + 截断」后的值,任何在 ArkTS 补写的过滤都是回归。
五、关键决策说明
决策 1:策略 1:1 提取而不是在 ArkTS 重写
上游的差异点全在策略:粘贴整段拒绝不半填、满位后替换字符不重复触发完成、revealedIndex 只认单字符追加。在 ArkTS 重写一遍等于把这些语义手抄一次,漂移风险全在调用侧。提取到 commonMain 的 OtpFieldEngine 后,同一份判定同时服务 JVM 测试与 ohos .so,21 项用例就是「策略没漂」的闸门。
决策 2:工具链沿社区成熟钉法(Kotlin 2.2.21-1.0.0 + Gradle 8.14.1)
引擎零第三方依赖,没有 KStore 那种「版本跟着制品走」的约束,直接沿用 qrose / number-keyboard 已过审的钉法。Gradle 经验按工具链线区分:2.2.21 线配 8.14.1,wrapper 随根构建一并替换,不与上游 9.5.0 混用。
决策 3:根构建替换(cmp_qrose 模式),sample 与 skikoTest 留盘
上游根构建(AGP / Compose / vanniktech 发布 / wasm 目标)对鸿蒙没有保留价值,整个替换为极简 ohos 构建。上游 sample/(Compose 示例工程)与 skikoTest 源集留盘不进 settings.gradle.kts——它们的存在方式只有对照这一种,拿来 sync 只会在配置期报错。
决策 4:handleChange 元策略整条过桥
只透「过滤后的字符串」会丢掉库的价值。revealedIndex 与 justCompleted 是上游 UX 的两根钉子(PIN 短暂明文、恰好一次完成),必须由引擎给出、由桥透出、由 ArkTS 原样消费。满位替换接受但不触发完成——这条在 6.4 功能用例里单独验收。
决策 5:jvmTest 交叉验证 + 测试 1:1 移植
引擎逻辑在 commonMain,JVM 与 ohos 跑同一份代码。上游 13 个逻辑用例逐条移植(同一输入断言同一结果)加 8 个新策略用例共 21 项,是「策略没漂」的直接证据;上游 OtpFieldLogicTest 因引用 Compose 类型退出编译,处置是移植而非删除——测试覆盖没有净损失。
六、测试与验证
6.1 测试环境
本次验证使用 Windows x64、JDK 21(Eclipse Temurin,org.gradle.java.home 钉版)、DevEco Studio 5.1.0 Release、OpenHarmony 命令行工具(hvigor / hdc)与鸿蒙真机(arm64)。
6.2 JVM 交叉验证
./gradlew :otp-field:jvmTest
21 项用例全部通过:
| 用例组 | 用例数 | 钉住什么 |
|---|---|---|
| 长度校验 | 3 | length<=0 异常文案、构造期校验、合法长度通过 |
| 字符过滤 | 2 | Digits 只收数字;Text 收非空白可打印、拒空白与控制符 |
| 粘贴三态 | 4 | 合法填满、超长截断、非法整段拒绝、满位后继续输入拒绝 |
| 单字符编辑 | 4 | 合法追加、非法拒绝、删除缩短、无变化返回 null |
| 元策略 | 5 | reveal 指向刚输位、删除/粘贴无 reveal、justCompleted 仅满位转换触发、拒绝时回显旧值且保持 complete |
| 样式与抖动 | 3 | 三档 drawBox/drawUnderline/圆角规格、关键帧首尾为 0 且双向摆动、单帧 45ms |
其中 13 项从上游 OtpFieldLogicTest 逐条移植,同一输入断言同一结果——这是「策略没漂」的直接证据。
6.3 HAP 验证
:otp-field:jvmTest: BUILD SUCCESSFUL(21/21)
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
llvm-nm -D: OtpFieldBridge 为全局 T 符号(双 ABI 一致)
llvm-readelf -d: NEEDED 全部为短系统名(libc++_shared / libc / libqos / libace_napi.z …)
BuildNativeWithCmake: finished(双 ABI 编出 libotpfield_napi.so)
CompileArkTS: finished
PackageHap: finished
install bundle successfully
libohosotpfield.so debugShared 未 strip 时 arm64 约 2.34 MB、x86_64 约 1.98 MB(含 Kotlin/Native 运行时),各 ABI 目录配齐 libc++_shared.so(约 1.85 / 1.73 MB)与 libotpfield_napi.so 三件套。
6.4 功能用例
- 启动后 Policy Tab 出现版本握手
engine 0.1.0-ohos.1 (upstream 0.1.0)(来自真实 Native 调用,非静态文字); - Boxed Tab 逐位输入
1–6,状态行每次显示accepted=true revealed=<刚输位>; - 点
Paste valid一次填满123456,状态行出现justCompleted=true——onComplete时刻被正确传递(见第七节效果图); - 点
Paste invalid(含字母的 6 位串),状态行显示拒绝、六个盒子保持原值——整段拒绝不半填; - 填满后再按数字键被拒;替换满位字段中的一个字符被接受但
justCompleted=false——完成回调不重复触发; - 打开 Obscure,已输位显示
•,刚输位明文 600ms 后回遮; - 点
Trigger error,六个盒子变红并按关键帧左右摆动后回到原位,错误文案出现; - 切换长度 4 / 6 / 8,值与完成态清空重新开始;
- Policy Tab 点
validate(0),状态行显示上游异常文案OtpField length must be greater than 0; was 0.,HAP 不崩; filter: text演练:AB12接受,A B因空格拒绝。
6.5 验证结论
策略引擎、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的分段输入会话:版本握手、逐位输入、粘贴三态、满位转换、PIN 遮蔽、错误抖动与异常文案跨桥(见下节效果图)。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/otp-field.git
cd otp-field
export JAVA_HOME="/path/to/jdk-21"
./gradlew :otp-field:jvmTest # 21 项交叉验证
./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.otpfield -m entry
hdc shell hilog | grep OtpField
八、遗留问题与改进方向
8.1 踩坑复盘
- 只改 ArkTS 页面不算 CMP 适配,必须让策略真正编成
ohosArm64/ohosX64动态库; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;- 拆源码集必须逐文件看 import 而不是看名字:
OtpFieldStyle差点跟着 UI 一起搬走,实际是纯枚举,留在commonMain供桥与 composeMain 共用;OtpFieldInternal.kt一半纯函数一半 Compose 类型,整文件迁 composeMain 保原貌、引擎在engine子包按语义重写; - 上游
commonTest引用KeyboardType/OtpFieldColors,这两个类型随 UI 迁走后旧测试直接卡死compileTestKotlinJvm——处置是按引擎 API 1:1 移植用例,不是删测试; - ArkTS struct 成员不能遮蔽
Object原型方法:页面里自定义的valueOf(tab)让每个 builder 调用点都报Argument of type 'Index' is not assignable to parameter of type 'Object'(一次 41 条连锁报错),改名即全消; - 同理,
@State tabIndex与 ArkUI 通用属性方法tabIndex(index)重名会报属性签名冲突,状态名要避开框架属性名(translate/opacity/enabled等同理); - Kotlin/Native 生成的
libohosotpfield_api.h在debugShared/目录里,不在源码树——链接后要拷进cpp/include/并入库,否则 CMake 编译期找不到OtpFieldBridge声明; - 根构建的
build/与oh_modules/忽略规则盖不住嵌套目录——DevEco 构建后生成的entry/oh_modules/、oh-package-lock.json5、.clang-tidy/.clangd要单独追加 gitignore,否则混进提交; - HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套(NAPI so / KN so / libc++)缺一不可; - KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free(); - 链接期 clang 对 CAdapter 生成代码(api.cpp)的警告可忽略,
.so正常链出——与 qrose / number-keyboard 记录一致; - 签名材料由 DevEco 自动写入本机配置,提交前确认
signingConfigs保持空数组,证书绝不进入 AtomGit。
8.2 已知问题
- 鸿蒙侧暂无 Compose UI:上游 Composable 留在
composeMain,等社区compose.uiohos 制品与 CMP 工具链对齐后接回; - 颜色不跨桥:
OtpFieldColors的九个颜色槽是 ComposeColor模型,Demo 用 ArkUI 常量色近似,业务侧按自身主题取色; shape参数不跨桥:上游允许对盒样式传任意Shape,桥只透出默认圆角(8 / 16 / 0 dp),自定义形状属纯 ArkUI 范畴;obscureCharShownDelay的回遮计时在 ArkTS 侧(Demo 固定 600ms;上游默认Duration.ZERO立即遮蔽),桥的revealedIndex契约天然支持任意延迟;- 焦点语义简化:Demo 用屏上数字键盘模拟输入,活动盒固定高亮;物理键盘 / IME 方案可在 ArkUI 层用
TextInput封装,桥不用改; - android / ios / desktop / wasm 目标剥离(源文件留在树里),发布插件一并剥离,本 fork 不发 Maven Central;
- HAP 安装必须使用与 bundle name
org.terminator.ohos.otpfield匹配的签名。
8.3 未来优化方向
- 社区 CMP 1.12 /
compose.uiohos 制品出现后,把composeMain接回commonMain,让OtpField在 Compose 容器里直接跑,OtpFieldEngine降级为内部实现; - 增加
TextInput+onContentChange封装,让系统输入法 / 物理键盘直接驱动桥,复用上游「单字段 + 自动前进」的焦点语义; - 评估
.har形态发布分段盒子组件层,让 ArkTS 工程直接依赖而不是各自拼装; - 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
- 补充真机(x86_64)验收矩阵和自动化截图脚本,覆盖三档样式与粘贴三态的全组合。
九、总结
9.1 核心难点回顾
上游 otp-field(一半 Compose UI + 一半纯 Kotlin 策略)
→ 按「是否依赖 androidx.compose」切源码集(composeMain 隔离,OtpFieldStyle 留守)
→ handleChange + OtpFieldInternal 1:1 提取为 OtpFieldEngine(commonMain,零依赖)
→ 工具链对齐 Kotlin 2.2.21-1.0.0(Gradle 8.14.1,cmp_qrose 钉法)
→ 根构建替换(:otp-field + :example:nativeApp,sample/skikoTest 留盘)
→ ohosArm64 / ohosX64 KLIB + jvm() 交叉验证
→ C ABI(@CName)+ 五 op 扁平 JSON
→ C++ N-API(DisposeString 纪律)
→ ArkTS JSON 解析 + @State
→ ArkUI 真机四 Tab 验收(粘贴三态 / 满位转换 / PIN 遮蔽 / 错误抖动)
9.2 封装层次
- 策略层:
OtpFieldEngine1:1 提取(字符过滤、粘贴三态、满位转换、样式规格、抖动关键帧),OtpFieldStyle枚举留守; - 隔离层:上游 Compose UI 五文件原样居
composeMain,不编不删,等工具链对齐; - Native 层:生成双 ABI 动态库并通过 1 个 C ABI 输出 JSON;
- N-API 层:完成参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:管理盒子状态、调桥分发、抖动回放、遮蔽计时与错误文本;
- DevEco 层:完成 CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- CMP UI 库的适配面要从「策略 / UI 占比」里找:上游策略占一半(且上游推荐 state-hoisted 用法、库本身无状态),桥就能做成与 Compose 调用方完全相同的形状——ArkTS 持 value、桥做判定,语义零损耗;策略占比越高的库,这条路的保真度越高;
- 拆源码集逐文件看 import,不看文件名:一个纯枚举的去留决定了桥能不能按名解析样式档位;一个混合文件的处置方式(整体迁走 + 子包重写)决定了上游原貌能不能保住;
- 交叉验证要连同元策略一起钉:过滤和粘贴三态只是地基,
revealedIndex与justCompleted才是最容易在重写时漂移的语义——21 项用例里必须有它们的显式断言,真机验收里必须有「满位替换不重复触发完成」这条用例。
9.4 适配成果
当前适配已完成:
- otp-field
0.1.0(0.1.0-ohos.1)输入策略的双 ABI Kotlin/Native 编译(OtpFieldEngine1:1 提取,上游 Compose UI 原样保留于composeMain); - 1 个 C ABI 符号和 C++ N-API 包装层(
llvm-nm验证全局 T 符号,双 ABI 一致); - DevEco Stage ArkUI 四 Tab 验收页面(三档样式完整输入闭环、粘贴三态、PIN 遮蔽、错误抖动、版本握手、策略面板);
- 21 项 JVM 交叉验证全绿(13 项从上游逐条移植)+ 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。
参考文档
更多推荐


所有评论(0)