本文记录 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),不提取就过不了桥。
类型绑定 ComposecharValidatorFor 吃 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 抛出的上游异常文案原样跨桥变成 JSON error,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 / valueprocess 是否接受本次编辑;接受时返回过滤、截断后的新值。
revealedIndexPIN 模式下刚输入位的下标(-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 MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(Eclipse Temurin,org.gradle.java.home 钉入 gradle.properties)
OpenHarmony 目标ohosArm64 + ohosX64(jvm() 保留作交叉验证)
Gradle8.14.1(2.2.21 线的配套版本,wrapper 随根构建替换)
第三方依赖无(引擎只用 Kotlin stdlib;不拉 kotlinx-serialization 进 KN)
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.otpfield
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点

上游 commonMain 只有五个文件,按「是否依赖 androidx.compose」逐个判定:

文件依赖形态处置
OtpField.ktComposable、BasicTextField、Animatable、FocusRequester迁入 composeMain(原样保留)
OtpFieldColors.ktandroidx.compose.ui.graphics.Color 九槽色板迁入 composeMain
OtpFieldDefaults.ktMaterialTheme、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 方法覆盖内核
OtpFieldBridgeotpFieldBridge(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.kt21 项交叉验证(13 项从上游逐条移植)
example/nativeApp/build.gradle.kts双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填
nativeApp/.../Bridge.kt1 个 C ABI、五 op 分发、MiniJson、错误通道
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/pages/Index.etsArkUI 四 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)
NativeOtpFieldBridgeC ABI JSON 导出
N-APIotpFieldBridge(req)向 ArkTS 暴露能力
ArkTSdispatchProcess(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 项用例全部通过:

用例组用例数钉住什么
长度校验3length<=0 异常文案、构造期校验、合法长度通过
字符过滤2Digits 只收数字;Text 收非空白可打印、拒空白与控制符
粘贴三态4合法填满、超长截断、非法整段拒绝、满位后继续输入拒绝
单字符编辑4合法追加、非法拒绝、删除缩短、无变化返回 null
元策略5reveal 指向刚输位、删除/粘贴无 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 功能用例

  1. 启动后 Policy Tab 出现版本握手 engine 0.1.0-ohos.1 (upstream 0.1.0)(来自真实 Native 调用,非静态文字);
  2. Boxed Tab 逐位输入 1–6,状态行每次显示 accepted=true revealed=<刚输位>;
  3. 点 Paste valid 一次填满 123456,状态行出现 justCompleted=true——onComplete 时刻被正确传递(见第七节效果图);
  4. 点 Paste invalid(含字母的 6 位串),状态行显示拒绝、六个盒子保持原值——整段拒绝不半填;
  5. 填满后再按数字键被拒;替换满位字段中的一个字符被接受但 justCompleted=false——完成回调不重复触发;
  6. 打开 Obscure,已输位显示 •,刚输位明文 600ms 后回遮;
  7. 点 Trigger error,六个盒子变红并按关键帧左右摆动后回到原位,错误文案出现;
  8. 切换长度 4 / 6 / 8,值与完成态清空重新开始;
  9. Policy Tab 点 validate(0),状态行显示上游异常文案 OtpField length must be greater than 0; was 0.,HAP 不崩;
  10. 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 踩坑复盘

  1. 只改 ArkTS 页面不算 CMP 适配,必须让策略真正编成 ohosArm64 / ohosX64 动态库;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. 拆源码集必须逐文件看 import 而不是看名字:OtpFieldStyle 差点跟着 UI 一起搬走,实际是纯枚举,留在 commonMain 供桥与 composeMain 共用;OtpFieldInternal.kt 一半纯函数一半 Compose 类型,整文件迁 composeMain 保原貌、引擎在 engine 子包按语义重写;
  4. 上游 commonTest 引用 KeyboardType / OtpFieldColors,这两个类型随 UI 迁走后旧测试直接卡死 compileTestKotlinJvm——处置是按引擎 API 1:1 移植用例,不是删测试;
  5. ArkTS struct 成员不能遮蔽 Object 原型方法:页面里自定义的 valueOf(tab) 让每个 builder 调用点都报 Argument of type 'Index' is not assignable to parameter of type 'Object'(一次 41 条连锁报错),改名即全消;
  6. 同理,@State tabIndex 与 ArkUI 通用属性方法 tabIndex(index) 重名会报属性签名冲突,状态名要避开框架属性名(translate / opacity / enabled 等同理);
  7. Kotlin/Native 生成的 libohosotpfield_api.h 在 debugShared/ 目录里,不在源码树——链接后要拷进 cpp/include/ 并入库,否则 CMake 编译期找不到 OtpFieldBridge 声明;
  8. 根构建的 build/ 与 oh_modules/ 忽略规则盖不住嵌套目录——DevEco 构建后生成的 entry/oh_modules/、oh-package-lock.json5、.clang-tidy / .clangd 要单独追加 gitignore,否则混进提交;
  9. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套(NAPI so / KN so / libc++)缺一不可;
  10. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  11. 链接期 clang 对 CAdapter 生成代码(api.cpp)的警告可忽略,.so 正常链出——与 qrose / number-keyboard 记录一致;
  12. 签名材料由 DevEco 自动写入本机配置,提交前确认 signingConfigs 保持空数组,证书绝不进入 AtomGit。

8.2 已知问题

  • 鸿蒙侧暂无 Compose UI:上游 Composable 留在 composeMain,等社区 compose.ui ohos 制品与 CMP 工具链对齐后接回;
  • 颜色不跨桥:OtpFieldColors 的九个颜色槽是 Compose Color 模型,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.ui ohos 制品出现后,把 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 封装层次

  • 策略层:OtpFieldEngine 1:1 提取(字符过滤、粘贴三态、满位转换、样式规格、抖动关键帧),OtpFieldStyle 枚举留守;
  • 隔离层:上游 Compose UI 五文件原样居 composeMain,不编不删,等工具链对齐;
  • Native 层:生成双 ABI 动态库并通过 1 个 C ABI 输出 JSON;
  • N-API 层:完成参数检查、字符串转换和 Native 内存释放;
  • ArkTS 层:管理盒子状态、调桥分发、抖动回放、遮蔽计时与错误文本;
  • DevEco 层:完成 CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. CMP UI 库的适配面要从「策略 / UI 占比」里找:上游策略占一半(且上游推荐 state-hoisted 用法、库本身无状态),桥就能做成与 Compose 调用方完全相同的形状——ArkTS 持 value、桥做判定,语义零损耗;策略占比越高的库,这条路的保真度越高;
  2. 拆源码集逐文件看 import,不看文件名:一个纯枚举的去留决定了桥能不能按名解析样式档位;一个混合文件的处置方式(整体迁走 + 子包重写)决定了上游原貌能不能保住;
  3. 交叉验证要连同元策略一起钉:过滤和粘贴三态只是地基,revealedIndex 与 justCompleted 才是最容易在重写时漂移的语义——21 项用例里必须有它们的显式断言,真机验收里必须有「满位替换不重复触发完成」这条用例。

9.4 适配成果

当前适配已完成:

  • otp-field 0.1.0(0.1.0-ohos.1)输入策略的双 ABI Kotlin/Native 编译(OtpFieldEngine 1: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。

参考文档

Logo

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

更多推荐