
1. 报名资料先过一遍规则在共享层校验嵌套表单把错误保留到对象字段和集合项路径。共享逻辑采用 Kotlin MultiplatformKMP源集Konform 的源码基线为固定上游源码快照。版本对应的源码与标签核对见 上游版本记录原始源码可在 固定上游快照 查阅。共享层使用 OHOS Kotlin/Native 2.2.21-1.1.0DevEco Studio 的实际版本为 6.1.1.300负责 N-API、ArkTS 和 HAP 构建构建 SDK 为 HarmonyOS API 24JDK 为 17。报名页看起来只有几项资料姓名、年龄、城市再勾几个兴趣。真正接到提交按钮时问题才开始变多。姓名只有一个字要不要放行年龄填成“十八”怎么办城市留空和整份地址没填是否属于同一类错误兴趣列表里面夹着一项空白又该提示谁这些情况都能写判断只是散在按钮回调里后面改一条业务规则就得沿着界面重新找一遍。这次做的小页面没有登录、联网或提交后台。它只负责把一份报名资料交给 Konform再把返回的字段问题呈现出来。范围收得小一点反而可以看清校验究竟在哪里执行错误路径从哪里产生以及错误之后还能不能继续提交正常资料。Konform 提供 Kotlin 的类型安全校验 DSL规则可以跟着数据类型组织。这里沿用它的共享实现增加鸿蒙 Native 构建和 ArkUI 示例。在 CPF-KMP-CMP 的鸿蒙工具链下把资料校验编译成原生库由界面通过 N-API 调用。画面上的错误条数和字段路径来自这次调用的返回值。2. 同一份资料为什么有两种结论示例放了基础规则和严格规则两个模式。基础规则要求年龄至少十八岁严格规则提高到二十一岁其余字段规则保持相同。这样切换模式时读者不用重新理解整个表单只要盯住年龄这一个变量就能知道规则是否真的换了。默认资料是“小明、二十二岁、杭州、摄影和徒步”方便第一次打开页面就观察完整结构。另一组预设把姓名改成单字年龄降到十七城市留空最后一个兴趣也留空。这组资料用于观察多处问题同时出现的情况。库会积累不同约束的错误因此同一个字段有时可能出现两条提示不能简单把错误总数理解成错误字段数。还有一份二十岁的资料。它在基础模式中满足年龄门槛在严格模式中就需要修正。页面保留输入点击模式按钮后重新执行同一个校验入口。业务如果要做“用户换了一种报名类型要求随之改变”的场景可以参考这种组织方式但具体年龄门槛仍要按自己的业务确定。兴趣在输入区用逗号分隔。这个选择只是为了缩短示例界面进入 Kotlin 后仍然是字符串列表。列表至少一项、最多四项每一项还要检查空白。只判断集合长度可能放过一个装着空字符串的列表逐项检查能把这种区别展示出来。姓名与城市也没有悄悄去掉输入中的空格所以原始资料和提示可以对应着检查。3. 运行效果看提示也看恢复过程运行环境为 API 24 的华为平板 MatePad edge 真机与 x86_64 模拟器。两端安装本次编译的签名应用完成页面操作和 15 项原库检查均返回 15/15。下面保留连续操作过程。这份示例内置十五项检查覆盖完整资料、年龄两端及相邻值、姓名边界、嵌套城市路径、集合逐项路径、严格规则、多错误保留以及非整数和错误后的恢复。检查调用原库 API结果由正在运行应用的设备返回运行画面与检查明细应放在一起看单独一个成功标签说明不了整个交互过程。界面采用同步调用资料很小适合观察功能。这里没有做大规模集合或高频输入的耗时基准因此也不把演示过程包装成性能结论。正式页面如果需要边输入边校验还应考虑触发频率和提示时机避免用户每敲一个字就被一串错误打断。4. 规则跟数据走错误跟路径走报名资料有一个嵌套地址对象。校验器先进入报名数据的 address 属性再检查 Address 的 city 属性兴趣则先进入集合随后为每个元素执行规则。这样做的好处是页面不用自己猜“这个错误属于哪里”只需要读取库返回的路径。下面是示例中的数据与规则。中文提示通过 hint 设置替换默认英文文案。这里保留短而明确的句子用户能够直接知道需要改哪一项提示文字也没有承担输入转换的职责。下面是harmony/src/Bridge.kt的完整内容。这里把报名对象、规则、输入解析、结果编码和十五项检查放在同一份完整文件里。registration 先把四个字段整理为对象evaluate 再选择年龄门槛并运行校验dataPath 原样带回页面。末尾的 quote、row、result 是返回协议的一部分不能只复制 validator 后就期待页面能收到完整结果。package harmony import io.konform.validation.* import io.konform.validation.constraints.* private data class Address(val city: String) private data class Registration(val name:String,val age:Int,val address:Address,val interests:ListString) private fun validator(minAge:Int) ValidationRegistration { Registration::name { notBlank() hint 姓名不能为空; minLength(2) hint 姓名至少两个字符; maxLength(16) hint 姓名最多十六个字符 } Registration::age { minimum(minAge) hint 年龄至少 $minAge 岁; maximum(120) hint 年龄不能超过 120 岁 } Registration::address { Address::city { notBlank() hint 城市不能为空 } } Registration::interests { minItems(1) hint 至少填写一个兴趣; maxItems(4) hint 最多填写四个兴趣; onEach { notBlank() hint 兴趣项不能为空 } } } private fun registration(input:String): Registration { require(input.length4096) { 输入过长 } val linesinput.lines() require(lines.size4) { 请依次输入姓名、年龄、城市和逗号分隔的兴趣 } val agelines[1].trim().toIntOrNull() ?: throw IllegalArgumentException(年龄必须是整数) return Registration(lines[0],age,Address(lines[2]),lines[3].split(,)) } fun evaluate(input:String,mode:Int):String try { require(mode in 0..1) { 未知模式 } val dataregistration(input); val validationvalidator(if(mode0)18 else 21)(data) val rowsmutableListOf(row(姓名,data.name),row(年龄门槛,if(mode0)18 岁 else 21 岁),row(城市,data.address.city),row(兴趣,data.interests.joinToString( · ))) validation.errors.forEach { rowsrow(it.path.dataPath,it.message) } result(if(validation.isValid) 校验通过可以提交 else 发现 ${validation.errors.size} 处错误,rows,validation.isValid) } catch(e:Throwable) { result(输入格式有问题,listOf(row(原因,e.message?:无法读取)),false) } private fun sample(name:String小明,age:Int18,city:String杭州,interests:ListString listOf(摄影)) Registration(name,age,Address(city),interests) fun runChecks():String checks(listOf( 完整资料通过 to { validator(18)(sample()).isValid }, 年龄下边界 to { validator(18)(sample(age18)).isValid }, 年龄下界前一位 to { !validator(18)(sample(age17)).isValid }, 年龄上边界 to { validator(18)(sample(age120)).isValid }, 年龄上界后一位 to { !validator(18)(sample(age121)).isValid }, 姓名长度下边界 to { validator(18)(sample(name小明)).isValid }, 单字符姓名 to { validator(18)(sample(name明)).errors.messagesAtPath(Registration::name).isNotEmpty() }, 空白姓名 to { !validator(18)(sample(name )).isValid }, 嵌套城市路径 to { validator(18)(sample(city)).errors.any { it.path.dataPath.address.city } }, 空集合被拒绝 to { !validator(18)(sample(interestsemptyList())).isValid }, 集合逐项路径 to { validator(18)(sample(interestslistOf(摄影,))).errors.any { it.path.dataPath.interests[1] } }, 严格模式门槛 to { !validator(21)(sample(age20)).isValid validator(21)(sample(age21)).isValid }, 多处错误同时保留 to { validator(18)(sample(name,age17,city)).errors.size3 }, 非整数不崩溃 to { evaluate(小明\n十八\n杭州\n摄影,0).contains(年龄必须是整数) }, 错误后正常恢复 to { evaluate(坏,0).contains(\ok\:false) evaluate(小明\n22\n杭州\n摄影,0).contains(\ok\:true) } )) private fun quote(s: String): String \ buildString { s.forEach { c - when(c) { - append(\\\); \\ - append(\\\\); \n - append(\\n); \r - append(\\r); \t - append(\\t); else - if(c.code 32) append(\\uc.code.toString(16).padStart(4,0)) else append(c) } } } \ private fun row(label: String, value: String, metric: Double 0.0): String {\label\:quote(label),\value\:quote(value),\metric\:(if(metric.isFinite()) metric else 0.0)} private fun result(summary: String, rows: ListString, ok: Boolean true): String {\ok\:$ok,\summary\:quote(summary),\rows\:rows.joinToString(,,[,])} private fun checks(cases: ListPairString, () - Boolean): String { var passed 0 val details cases.joinToString(\n) { (name,test) - try { if(test()) { passed; PASS $name } else FAIL $name } catch(e: Throwable) { FAIL $name: ${e.message} } } return {\passed\:$passed,\total\:${cases.size},\detail\:quote(details)} }错误路径这里有个容易拿错的接口。ValidationPath 的 toString 用于调试会带出路径段的内部表示面向界面应该读取 dataPath。嵌套城市可以表示成.address.city集合第二项可以表示成.interests[1]既能直接展示也方便后续映射到具体控件。字段名与中文标签之间仍然需要一层业务映射。示例为便于观察库的行为显示原始数据路径正式报名页可以把 name 映射成“姓名”把 address.city 映射成“所在城市”再把提示摆到输入框下面。做映射时要保留集合索引否则两项兴趣同时有问题用户会分不清提示指的是哪一项。联调时这两个路径检查曾经失败。重新核对发现应用工程仍带着旧桥接库返回的是调试路径界面的字段过滤自然匹配不到。处理时保留精确路径断言重新同步新动态库及配套头文件再构建应用。界面的成功提示也直接依据校验结果决定避免“没有筛出错误行”被误当成“资料已经通过”。平台库更新后应用使用的产物也要跟着更新这一步不能只靠编译日志推断。5. 非整数年龄应该停在哪一步“年龄必须是整数”属于输入解析问题。年龄尚未转换为 Int就没有完整的报名对象可交给校验器。示例用 toIntOrNull 识别这种情况返回一个格式错误结果页面保留输入并显示原因。它不会把“十八”自动改成十八也不会把转换失败默认设成零。年龄成功转换后十八岁或一百二十岁这样的上下边界才由 Konform 的约束判断。把解析失败与业务不满足分开处理能够避免出现一句模糊的“提交失败”也方便后续统计具体问题。数字格式有问题让用户改数字资料对象已经完整则把库返回的多个错误一起给出来。界面把姓名、年龄、所在城市与兴趣分成四个输入框左侧负责填写右侧像一张校验回执突出显示具体错误路径。点击检查后页面将四个字段组合成桥接接口的输入格式单个字段也有长度限制。异常被转换成可显示的错误结果不向 ArkTS 泄漏一个未捕获异常。随后点击完整资料预设会重新建立报名对象并运行规则。这个恢复动作很有必要错误提示出现之后下一次正常操作应该得到新的结果旧提示也应该随这次结果一起更新。字符串长度使用 Kotlin String 的长度语义。这适合说明普通姓名输入的上下界但不能直接当成任意语言里的可见字符数量。组合字符和补充平面字符有自己的编码特点真实姓名校验还需要结合业务定义决定怎么计数。示例没有处理身份证、实名验证或者远端重复报名检查因此“校验通过”只代表当前这些本地规则满足。6. 鸿蒙这一层接上的是哪几份代码环境搭建直接参考 CPF-KMP-CMP 官方快速开始。本次使用鸿蒙 Kotlin/Native 2.2.21-1.1.0 发行版原生构建把 ARM64 与 X64 分开输出应用的 SDK 与设备运行版本记录放在后面的实测部分。源码里新增的 harmony 目录负责平台配置、桥接和构建脚本。原来的 commonMain 包含约束、结果、路径及校验组合实现全部保持固定上游提交原样。原仓已有 nativeMain 的 callableEquals 实际实现鸿蒙编译也纳入这份代码用于可调用引用的等价比较。它已经适合 Native 的语义无需在页面里另做一份属性比较逻辑。独立的 build.gradle.kts 配置两个鸿蒙目标并把共享源目录与 Native 实际实现接入源集。另一个命令行脚本实际完成 KLIB 编译、动态库链接和 C 头文件生成。这里区分配置和执行记录原生编译的通过依据是执行日志及产物摘要不能因为写了 Gradle 文件就把它说成已经执行过整套 Gradle 发布流程。Bridge 的职责是把文本输入整理成报名对象选择规则并返回结果。N-API 接口检查参数把输入交给 Kotlin 导出的函数再复制返回的 JSON 字符串复制后释放 Kotlin/Native 分配的字符串。ArkTS 只解析结果并排版不再重复计算年龄门槛或兴趣错误条数。后续改规则时主要改 Kotlin 这一层界面的路径展示可以继续使用。把接口定义和应用配置放到一起看库到页面的调用次序才完整。下表中entry路径均位于example/harmony中生成的 C 头文件由原生构建脚本输出不需要手工猜测导出结构。文件在调用链中的职责harmony/src/Bridge.kt原库调用、返回编码与设备检查harmony/native-config.json共享源集、双 ABI 依赖和编译选项harmony/build-native.py编译 KLIB、链接动态库并生成匹配头文件entry/src/main/cpp/napi_init.cpp参数检查、原生调用与返回值生命周期entry/src/main/cpp/CMakeLists.txt按 ABI 选择动态库和头文件entry/src/main/cpp/types/libentry/index.d.tsArkTS 能看到的接口类型entry/src/main/ets/pages/Index.ets完整输入、运行反馈与独立界面6.1 源集、依赖和双架构链接配置共享源码位置是src/commonMain/kotlin。构建配置把 ARM64 与 X64 分开每个目标当前需要 0 份显式 KLIB 依赖。没有外部依赖不代表可以使用桌面编译器目标仍须是 OHOS。harmony/native-config.json完整内容{ code: konform, common: src/commonMain/kotlin, dependencies: { ohos_arm64: [], ohos_x64: [] }, nativeActual: src/nativeMain/kotlin }entry/src/main/cpp/CMakeLists.txt把生成头文件与同 ABI 动态库配对链接名称保持为entry对应页面的libentry.so导入cmake_minimum_required(VERSION 3.5.0) project(harmony_library_example) set(CMAKE_CXX_STANDARD 17) if(CMAKE_OHOS_ARCH_ABI STREQUAL x86_64 OR OHOS_ARCH STREQUAL x86_64) set(KMP_ABI x86_64) else() set(KMP_ABI arm64-v8a) endif() include_directories(${CMAKE_CURRENT_SOURCE_DIR}/include/${KMP_ABI}) add_library(kmp_library SHARED IMPORTED) set_target_properties(kmp_library PROPERTIES IMPORTED_LOCATION ${CMAKE_CURRENT_SOURCE_DIR}/../../../libs/${KMP_ABI}/libkonform.so) add_library(entry SHARED napi_init.cpp) target_link_libraries(entry PUBLIC kmp_library libace_napi.z.so)应用还需要启用外部 Native 构建并声明本地接口类型依赖。下面依次是完整的entry/build-profile.json5与entry/oh-package.json5不包含个人签名{ apiType: stageMode, buildOption: { resOptions: { copyCodeResource: { enable: false } }, externalNativeOptions: { path: ./src/main/cpp/CMakeLists.txt, abiFilters: [ arm64-v8a, x86_64 ] } }, buildOptionSet: [ { name: release, arkOptions: { obfuscation: { ruleOptions: { enable: false, files: [ ./obfuscation-rules.txt ] } } } } ], targets: [ { name: default }, { name: ohosTest } ] }{ name: entry, version: 1.0.0, description: Konform · 字段、嵌套对象与集合校验, main: , author: , license: , dependencies: { libentry.so: file:./src/main/cpp/types/libentry } }6.2 让字段校验回执穿过 N-APIN-API 先核对两个参数再按 UTF-8 字节数分配缓冲区。Kotlin 返回的字符串先复制到 ArkTS 值中随后调用 DisposeString 释放。下面给出整个文件包含模块注册只有 Evaluate 函数而没有 Init 和 Register页面就无法从 libentry.so 取得接口。完整文件example/harmony/entry/src/main/cpp/napi_init.cpp。#include napi/native_api.h #include konform_api.h #include string static napi_value Copy(napi_env env,const char* text) { napi_value value; napi_create_string_utf8(env,text?text:{},NAPI_AUTO_LENGTH,value); if(text) konform_symbols()-DisposeString(text); return value; } static napi_value Evaluate(napi_env env,napi_callback_info info) { size_t count2;napi_value args[2];napi_get_cb_info(env,info,count,args,nullptr,nullptr); size_t length0;int32_t mode0; if(count!2 || napi_get_value_string_utf8(env,args[0],nullptr,0,length)!napi_ok || napi_get_value_int32(env,args[1],mode)!napi_ok) { napi_throw_type_error(env,nullptr,evaluate requires text and a mode);return nullptr; } if(length16384){napi_throw_range_error(env,nullptr,Input exceeds demo limit);return nullptr;} std::string text(length1,\0);napi_get_value_string_utf8(env,args[0],text.data(),text.size(),length);text.resize(length); return Copy(env,konform_symbols()-kotlin.root.harmony.evaluate(text.c_str(),mode)); } static napi_value Check(napi_env env,napi_callback_info info){return Copy(env,konform_symbols()-kotlin.root.harmony.runChecks());} static napi_value Init(napi_env env,napi_value exports){ napi_property_descriptor properties[]{ {evaluate,nullptr,Evaluate,nullptr,nullptr,nullptr,napi_default,nullptr}, {runChecks,nullptr,Check,nullptr,nullptr,nullptr,napi_default,nullptr}}; napi_define_properties(env,exports,2,properties);return exports; } static napi_module module{1,0,nullptr,Init,entry,nullptr,{0}}; extern C __attribute__((constructor)) void Register(){napi_module_register(module);}6.3 从错误路径接到四个输入框页面的 validate 把姓名、年龄、城市与兴趣组合后交给原生入口。成功标记读取返回的 ok错误列表只是展示不用“列表恰好为空”反推成功。选择预设资料和修改模式都会再次运行规则下面保留完整的输入区、回执区和检查按钮。先给出entry/src/main/cpp/types/libentry/index.d.ts紧接着是完整的entry/src/main/ets/pages/Index.ets。类型声明与下面的实际调用保持一致。export const evaluate: (input: string, mode: number) string; export const runChecks: () string;import native from libentry.so; interface ResultRow { label: string; value: string; metric: number; } interface ValidationResult { ok: boolean; summary: string; rows: ResultRow[]; } interface CheckResult { passed: number; total: number; detail: string; } Entry Component struct Index { State personName: string 小明; State age: string 22; State city: string 杭州; State interests: string 摄影,徒步; State mode: number 0; State ok: boolean true; State summary: string ; State errors: ResultRow[] []; State checked: string 尚未执行设备检查; State details: string ; private samples: string[] [小明\n22\n杭州\n摄影,徒步, 明\n17\n\n摄影,, 小明\n20\n杭州\n摄影, 小明\n十八\n杭州\n摄影]; private sampleNames: string[] [完整资料, 多处错误, 年龄临界, 非整数]; aboutToAppear(): void { this.validate(); } private validate(): void { const text: string ${this.personName}\n${this.age}\n${this.city}\n${this.interests}; const response JSON.parse(native.evaluate(text, this.mode)) as ValidationResult; this.ok response.ok; this.summary response.summary; this.errors response.rows.filter((item: ResultRow) item.label.startsWith(.) || item.label 原因); } private sample(index: number): void { const parts: string[] this.samples[index].split(\n); this.personName parts[0]; this.age parts[1]; this.city parts[2]; this.interests parts[3]; this.validate(); } private runChecks(): void { const report JSON.parse(native.runChecks()) as CheckResult; this.checked 设备检查 ${report.passed}/${report.total}; this.details report.detail; } Builder FieldLabel(title: string, note: string) { Row() { Text(title).fontSize(17).fontWeight(FontWeight.Medium).fontColor(#263D38) Blank() Text(note).fontSize(12).fontColor(#7C8F87) }.width(100%) } build() { Scroll() { Column({ space: 22 }) { Row({ space: 18 }) { Text(报名).fontSize(22).fontWeight(FontWeight.Bold).fontColor(Color.White) .width(72).height(72).textAlign(TextAlign.Center).backgroundColor(#176C54).borderRadius(18) Column({ space: 5 }) { Text(提交前把资料补齐).fontSize(31).fontWeight(FontWeight.Bold).fontColor(#173E2F) Text(Konform / 嵌套字段 · 集合逐项 · 年龄规则).fontSize(14).fontColor(#698676) }.alignItems(HorizontalAlign.Start) Blank() Text(this.ok ? 可以提交 : 还有待修改项).fontSize(15).fontWeight(FontWeight.Medium) .fontColor(this.ok ? #176C54 : #B14C27).padding({ left: 18, right: 18, top: 12, bottom: 12 }) .backgroundColor(this.ok ? #DFEFE2 : #FFE9D8).borderRadius(24) }.width(100%) Row({ space: 8 }) { Text(填入一份资料).fontSize(14).fontColor(#688171).margin({ right: 10 }) ForEach(this.sampleNames, (title: string, index: number) { Button(title).id(sample-${index}).fontSize(13).buttonStyle(ButtonStyleMode.NORMAL) .backgroundColor(#FFFFFF).fontColor(#326C52).border({ width: 1, color: #CEDDD2 }) .onClick(() this.sample(index)) }) }.width(100%) Row({ space: 24 }) { Column({ space: 17 }) { Row() { Text(01).fontSize(26).fontWeight(FontWeight.Bold).fontColor(#AFC5AD) Text(填写资料).fontSize(21).fontWeight(FontWeight.Bold).fontColor(#263D38).margin({ left: 12 }) }.width(100%) this.FieldLabel(姓名, 2—16 个字符) TextInput({ text: this.personName, placeholder: 请输入姓名 }).id(source-input) .maxLength(64).height(48).fontSize(19).backgroundColor(#F6F8F2).borderRadius(7) .onChange((value: string) this.personName value) this.FieldLabel(年龄, this.mode 0 ? 基础门槛18 岁 : 严格门槛21 岁) TextInput({ text: this.age, placeholder: 年龄例如 22 }).id(age-input) .maxLength(16).height(48).fontSize(19).backgroundColor(#F6F8F2).borderRadius(7) .onChange((value: string) this.age value) this.FieldLabel(所在城市, address.city) TextInput({ text: this.city, placeholder: 请输入城市 }).id(city-input) .maxLength(128).height(48).fontSize(19).backgroundColor(#F6F8F2).borderRadius(7) .onChange((value: string) this.city value) this.FieldLabel(兴趣, 1—4 项用逗号分隔) TextInput({ text: this.interests, placeholder: 摄影,徒步 }).id(interests-input) .maxLength(256).height(48).fontSize(19).backgroundColor(#F6F8F2).borderRadius(7) .onChange((value: string) this.interests value) Row({ space: 10 }) { Button(基础规则).id(mode-0).layoutWeight(1).fontSize(14) .backgroundColor(this.mode 0 ? #176C54 : #EAF1E7) .fontColor(this.mode 0 ? Color.White : #37674B) .onClick(() { this.mode 0; this.validate(); }) Button(严格规则).id(mode-1).layoutWeight(1).fontSize(14) .backgroundColor(this.mode 1 ? #176C54 : #EAF1E7) .fontColor(this.mode 1 ? Color.White : #37674B) .onClick(() { this.mode 1; this.validate(); }) }.width(100%) Button(检查这份资料).id(evaluate).width(100%).height(52).fontSize(18) .backgroundColor(#173E2F).borderRadius(8).onClick(() this.validate()) }.padding(26).layoutWeight(1).backgroundColor(Color.White).borderRadius(12) Column({ space: 18 }) { Row() { Text(02).fontSize(26).fontWeight(FontWeight.Bold).fontColor(#B9C4B4) Text(校验回执).fontSize(21).fontWeight(FontWeight.Bold).fontColor(#263D38).margin({ left: 12 }) }.width(100%) Text(this.summary).id(result-summary).fontSize(24).fontWeight(FontWeight.Medium) .fontColor(this.ok ? #176C54 : #AE4724).width(100%).lineHeight(32) if (this.ok) { Text(✓).fontSize(80).fontColor(#408753).margin({ top: 30, bottom: 20 }) Text(这些本地规则已满足).fontSize(18).fontColor(#39614A) Text(你可以改一项资料或切换年龄规则重新检查。).fontSize(14) .fontColor(#6B8272).lineHeight(23).width(100%) } else if (this.errors.length 0) { ForEach(this.errors, (error: ResultRow) { Column({ space: 8 }) { Text(error.label).fontFamily(monospace).fontSize(15).fontColor(#9B4C22).width(100%) Text(error.value).fontSize(18).fontColor(#603C29).width(100%).lineHeight(25) }.width(100%).padding(17).backgroundColor(#FFF1DF).borderRadius(8) }) } else { Text(资料未通过返回值缺少可定位的字段错误。).fontSize(18).fontColor(#AE4724) .width(100%).lineHeight(26) } Blank().height(10) Text(错误携带字段路径同一字段可能有多条提示。).fontSize(13).fontColor(#778572).width(100%) }.padding(26).layoutWeight(1).backgroundColor(#F8FAF1).borderRadius(12) }.width(100%).alignItems(VerticalAlign.Top) Row({ space: 18 }) { Text(this.checked).fontSize(15).fontColor(#456351).layoutWeight(1) Button(核对 15 项原库结果).id(run-checks).fontSize(14).backgroundColor(#DDE7D7) .fontColor(#325C45).onClick(() this.runChecks()) }.width(100%) if (this.details.length 0) { Text(this.details).fontSize(13).fontFamily(monospace).fontColor(#33533F).lineHeight(22) .width(100%).padding(20).backgroundColor(#E6EDDE).borderRadius(8) } }.width(100%).constraintSize({ maxWidth: 1140 }).padding(30) }.width(100%).height(100%).backgroundColor(#EDF2E7) } }6.4 从源码生成与页面一致的应用按前文官方环境说明配置 JDK 17 与 OHOS Kotlin/Native 编译器把 JAVA_HOME、KOTLIN_NATIVE_HOME 指向对应安装目录。在当前库目录运行下面这份完整脚本它读取上面的配置分别生成两个 ABI 的库和头文件再复制进示例。脚本将公共源码和平台实现编译到明确的 OHOS 目标并保留每个阶段的构建日志。完整文件harmony/build-native.py。#!/usr/bin/env python3 Compile preserved common sources and the OHOS example bridge. from pathlib import Path import argparse,os,json,subprocess,shutil,hashlib ROOTPath(__file__).resolve().parent.parent CONFIGjson.loads((ROOT/harmony/native-config.json).read_text(encodingutf8)) pargparse.ArgumentParser();p.add_argument(--compiler,defaultos.environ.get(KOTLIN_NATIVE_HOME));p.add_argument(--sdk-native,defaultos.environ.get(OHOS_NATIVE_HOME));p.add_argument(--install);p.add_argument(--check-inputs,actionstore_true);argsp.parse_args() commonsorted((ROOT/CONFIG[common]).rglob(*.kt));actualsorted((ROOT/harmony/src).rglob(*.kt)) if CONFIG.get(nativeActual):actualsorted((ROOT/CONFIG[nativeActual]).rglob(*.kt)) extrasorted((ROOT/harmony/dependencies/src).rglob(*.kt)) if CONFIG.get(sourceDependency) else [] assert common and actual if args.check_inputs: proofjson.loads((ROOT/harmony/verification.json).read_text(encodingutf8)) for path,digest in proof[native][coreSourceHashes].items():assert hashlib.sha256((ROOT/path).read_bytes()).hexdigest()digest for target,abi in [(ohos_arm64,arm64-v8a),(ohos_x64,x86_64)]: for name in CONFIG[dependencies][target]:assert (ROOT/harmony/dependencies/name).is_file(),name assert hashlib.sha256((ROOT/example/harmony/entry/libs/abi/(libCONFIG[code].so)).read_bytes()).hexdigest()proof[native][outputs][target] print(Shared source, dependencies and both compiled libraries match verification records);raise SystemExit(0) if not args.compiler:p.error(Set KOTLIN_NATIVE_HOME or pass --compiler) compilerPath(args.compiler).resolve();envdict(os.environ) def run(target,phase,argv): destROOT/harmony/build/target;dest.mkdir(parentsTrue,exist_okTrue) paramdest/(phase.args);param.write_text(\n.join(x.replace(\\,/) for x in argv),encodingutf8) with (dest/(phase.log)).open(w,encodingutf8) as log:qsubprocess.run([cmd.exe,/d,/c,str(compiler/bin/konanc.bat),str(param)],envenv,stdoutlog,stderrsubprocess.STDOUT) if q.returncode:raise SystemExit(f{target} {phase} failed; inspect {phase}.log) print(target,phase,passed,flushTrue) outputs{} for target,abi in [(ohos_arm64,arm64-v8a),(ohos_x64,x86_64)]: destROOT/harmony/build/target;dest.mkdir(parentsTrue,exist_okTrue);nameCONFIG[code];libs[] for file in CONFIG[dependencies][target]:libs[-library,str(ROOT/harmony/dependencies/file)] if CONFIG.get(utf8proc): if not args.sdk_native:p.error(Unicode backend requires --sdk-native or OHOS_NATIVE_HOME) from build_utf8proc import prepare libs[-library,str(prepare(ROOT,compiler,env,target,Path(args.sdk_native).resolve()))] opts[-target,target,-produce,library,-Xmulti-platform,-Xexpect-actual-classes,-Xcommon-sources,.join(map(str,commonextra)),-opt-inkotlin.ExperimentalUnsignedTypes]CONFIG.get(compilerOptions,[])libs plugin[-Xpluginstr(ROOT/harmony/dependencies/serialization-plugin.jar)] if CONFIG.get(serialization) else [] if CONFIG.get(splitCore): core[-library,str(dest/(name_core.klib))] run(target,compile-core,list(map(str,commonextra[a for a in actual if a.name!Bridge.kt]))optsplugin[-o,str(dest/(name_core))]) run(target,compile-bridge,[str(ROOT/harmony/src/Bridge.kt),-target,target,-produce,library,-o,str(dest/name)]plugincorelibs) libscore else:run(target,compile,list(map(str,commonextraactual))optsplugin[-o,str(dest/name)]) run(target,link,[-target,target,-produce,dynamic,-o,str(dest/name),-Xincludestr(dest/(name.klib))]libs) outputs[target]hashlib.sha256((dest/(libname.so)).read_bytes()).hexdigest() if args.install: projectPath(args.install).resolve();assert (project/entry/build-profile.json5).is_file() for source,to in [(dest/(libname.so),project/entry/libs/abi/(libname.so)),(dest/(name_api.h),project/entry/src/main/cpp/include/abi/(name_api.h))]: to.parent.mkdir(parentsTrue,exist_okTrue);shutil.copy2(source,to) (ROOT/harmony/build/manifest.json).write_text(json.dumps({sourceFileCount:len(common),outputs:outputs},indent2),encodingutf8)python harmony/build-native.py --install example/harmony完成原生构建后用 DevEco Studio 打开example/harmony同步 entry 的类型依赖为当前应用包名配置自己的开发者签名再构建和安装 HAP。仅修改 ArkTS 可以重建应用改过 Kotlin 或平台 actual 时先重编两个 ABI 的动态库及匹配头文件。本文的运行记录对应 API 24、OHOS Kotlin/Native 2.2.21-1.1.0覆盖前面列出的输入与检查。API 26 和其他目标平台没有在这次记录中实测。更多通用三方库资料见 oh-tpc 社区。7. 总结这份校验器可以留给页面复用最后得到的是一份能够被鸿蒙页面调用的共享校验器。规则围绕数据类型组织嵌套对象和集合错误带着自己的路径输入格式问题在建立对象之前处理。页面负责显示和再次提交因此完整资料、规则变化和错误恢复可以沿着同一条调用链检查。源码中的 harmony 说明鸿蒙目标和桥接改动example/harmony 提供完整应用示例。新增规则时建议先确定数据结构、提示文案和错误路径再安排界面展示位置。这样即使换了页面布局校验逻辑仍能继续复用而错误也不会只剩下一条无法定位的提示。项目源码鸿蒙跨平台库示例总仓库 · Konform 适配目录。约束用法可查 Konform 上游文档平台工具与示例组织可参考 CPF-KMP-CMP 和 CPF-ApplicationTPC。7.1 FAQ多个字段都错了为什么只显示一条多个字段都错了为什么只显示一条检查是否只读取第一条错误。应按校验结果遍历 dataPath 与信息把集合下标也带回页面规则执行和界面展示是两步不能在展示时丢掉其余路径。为什么 Native 构建提示 callableEquals 缺少 actual公共源集的 expect 声明需要匹配实现。当前构建纳入上游 nativeMain 的 callableEquals actual复制源文件时须保留这个源集配置不能只编 commonMain。年龄填成小数会按整数校验吗示例先检查年龄输入格式再构建资料模型。格式不合法时给出输入错误不能悄悄截断小数后当成有效年龄。严格模式改变的是门槛不改变整数输入约定。安装或 ABI 报错时先核对当前包名对应的签名、arm64-v8a / x86_64 库与匹配头文件Kotlin 修改后重新生成原生库再构建安装 HAP。完整排查步骤见 本库 FAQ平台说明见 KMP 鸿蒙使用说明。