☰
提交资料前,先让 Konform 把问题指出来:鸿蒙 KMP表单校验实践
2026/10/26 10:17:12 网站建设 项目流程

1. 报名资料先过一遍规则

在共享层校验嵌套表单,把错误保留到对象字段和集合项路径。共享逻辑采用 Kotlin Multiplatform(KMP)源集,Konform 的源码基线为固定上游源码快照。

版本对应的源码与标签核对见 上游版本记录,原始源码可在 固定上游快照 查阅。共享层使用 OHOS Kotlin/Native 2.2.21-1.1.0;DevEco Studio 的实际版本为 6.1.1.300,负责 N-API、ArkTS 和 HAP 构建,构建 SDK 为 HarmonyOS API 24,JDK 为 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:List<String>) private fun validator(minAge:Int) = Validation<Registration> { 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.length<=4096) { "输入过长" } val lines=input.lines() require(lines.size>=4) { "请依次输入姓名、年龄、城市和逗号分隔的兴趣" } val age=lines[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 data=registration(input); val validation=validator(if(mode==0)18 else 21)(data) val rows=mutableListOf(row("姓名",data.name),row("年龄门槛",if(mode==0)"18 岁" else "21 岁"),row("城市",data.address.city),row("兴趣",data.interests.joinToString(" · "))) validation.errors.forEach { rows+=row(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:Int=18,city:String="杭州",interests:List<String> = listOf("摄影")) = Registration(name,age,Address(city),interests) fun runChecks():String = checks(listOf( "完整资料通过" to { validator(18)(sample()).isValid }, "年龄下边界" to { validator(18)(sample(age=18)).isValid }, "年龄下界前一位" to { !validator(18)(sample(age=17)).isValid }, "年龄上边界" to { validator(18)(sample(age=120)).isValid }, "年龄上界后一位" to { !validator(18)(sample(age=121)).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(interests=emptyList())).isValid }, "集合逐项路径" to { validator(18)(sample(interests=listOf("摄影",""))).errors.any { it.path.dataPath==".interests[1]" } }, "严格模式门槛" to { !validator(21)(sample(age=20)).isValid && validator(21)(sample(age=21)).isValid }, "多处错误同时保留" to { validator(18)(sample(name="",age=17,city="")).errors.size>=3 }, "非整数不崩溃" 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("\\u"+c.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: List<String>, ok: Boolean = true): String = "{\"ok\":$ok,\"summary\":"+quote(summary)+",\"rows\":"+rows.joinToString(",","[","]")+"}" private fun checks(cases: List<Pair<String, () -> 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.ts

ArkTS 能看到的接口类型

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-API

N-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 count=2;napi_value args[2];napi_get_cb_info(env,info,&count,args,nullptr,nullptr); size_t length=0;int32_t mode=0; 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(length>16384){napi_throw_range_error(env,nullptr,"Input exceeds demo limit");return nullptr;} std::string text(length+1,'\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 ROOT=Path(__file__).resolve().parent.parent CONFIG=json.loads((ROOT/'harmony/native-config.json').read_text(encoding='utf8')) p=argparse.ArgumentParser();p.add_argument('--compiler',default=os.environ.get('KOTLIN_NATIVE_HOME'));p.add_argument('--sdk-native',default=os.environ.get('OHOS_NATIVE_HOME'));p.add_argument('--install');p.add_argument('--check-inputs',action='store_true');args=p.parse_args() common=sorted((ROOT/CONFIG['common']).rglob('*.kt'));actual=sorted((ROOT/'harmony/src').rglob('*.kt')) if CONFIG.get('nativeActual'):actual+=sorted((ROOT/CONFIG['nativeActual']).rglob('*.kt')) extra=sorted((ROOT/'harmony/dependencies/src').rglob('*.kt')) if CONFIG.get('sourceDependency') else [] assert common and actual if args.check_inputs: proof=json.loads((ROOT/'harmony/verification.json').read_text(encoding='utf8')) 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/('lib'+CONFIG['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') compiler=Path(args.compiler).resolve();env=dict(os.environ) def run(target,phase,argv): dest=ROOT/'harmony/build'/target;dest.mkdir(parents=True,exist_ok=True) param=dest/(phase+'.args');param.write_text('\n'.join('"'+x.replace('\\','/')+'"' for x in argv),encoding='utf8') with (dest/(phase+'.log')).open('w',encoding='utf8') as log:q=subprocess.run(['cmd.exe','/d','/c',str(compiler/'bin/konanc.bat'),'@'+str(param)],env=env,stdout=log,stderr=subprocess.STDOUT) if q.returncode:raise SystemExit(f'{target} {phase} failed; inspect {phase}.log') print(target,phase,'passed',flush=True) outputs={} for target,abi in [('ohos_arm64','arm64-v8a'),('ohos_x64','x86_64')]: dest=ROOT/'harmony/build'/target;dest.mkdir(parents=True,exist_ok=True);name=CONFIG['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,common+extra)),'-opt-in=kotlin.ExperimentalUnsignedTypes']+CONFIG.get('compilerOptions',[])+libs plugin=['-Xplugin='+str(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,common+extra+[a for a in actual if a.name!='Bridge.kt']))+opts+plugin+['-o',str(dest/(name+'_core'))]) run(target,'compile-bridge',[str(ROOT/'harmony/src/Bridge.kt'),'-target',target,'-produce','library','-o',str(dest/name)]+plugin+core+libs) libs+=core else:run(target,'compile',list(map(str,common+extra+actual))+opts+plugin+['-o',str(dest/name)]) run(target,'link',['-target',target,'-produce','dynamic','-o',str(dest/name),'-Xinclude='+str(dest/(name+'.klib'))]+libs) outputs[target]=hashlib.sha256((dest/('lib'+name+'.so')).read_bytes()).hexdigest() if args.install: project=Path(args.install).resolve();assert (project/'entry/build-profile.json5').is_file() for source,to in [(dest/('lib'+name+'.so'),project/'entry/libs'/abi/('lib'+name+'.so')),(dest/(name+'_api.h'),project/'entry/src/main/cpp/include'/abi/(name+'_api.h'))]: to.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(source,to) (ROOT/'harmony/build/manifest.json').write_text(json.dumps({'sourceFileCount':len(common),'outputs':outputs},indent=2),encoding='utf8')
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 鸿蒙使用说明。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询