提交资料前,先让 Konform 把问题指出来:鸿蒙表单校验实践
1. 报名资料先过一遍规则
报名页看起来只有几项资料:姓名、年龄、城市,再勾几个兴趣。真正接到提交按钮时,问题才开始变多。姓名只有一个字要不要放行,年龄填成“十八”怎么办,城市留空和整份地址没填是否属于同一类错误,兴趣列表里面夹着一项空白又该提示谁?这些情况都能写判断,只是散在按钮回调里,后面改一条业务规则,就得沿着界面重新找一遍。
这次做的小页面没有登录、联网或提交后台。它只负责把一份报名资料交给 Konform,再把返回的字段问题呈现出来。范围收得小一点,反而可以看清校验究竟在哪里执行,错误路径从哪里产生,以及错误之后还能不能继续提交正常资料。
Konform 提供 Kotlin 的类型安全校验 DSL,规则可以跟着数据类型组织。这里沿用它的共享实现,增加鸿蒙 Native 构建和 ArkUI 示例。在 CPF-KMP-CMP 的鸿蒙工具链下,把资料校验编译成原生库,由界面通过 N-API 调用。画面上的错误条数和字段路径来自这次调用的返回值。
2. 同一份资料,为什么有两种结论
示例放了基础规则和严格规则两个模式。基础规则要求年龄至少十八岁,严格规则提高到二十一岁,其余字段规则保持相同。这样切换模式时,读者不用重新理解整个表单,只要盯住年龄这一个变量,就能知道规则是否真的换了。
默认资料是“小明、二十二岁、杭州、摄影和徒步”,方便第一次打开页面就观察完整结构。另一组预设把姓名改成单字,年龄降到十七,城市留空,最后一个兴趣也留空。这组资料用于观察多处问题同时出现的情况。库会积累不同约束的错误,因此同一个字段有时可能出现两条提示,不能简单把错误总数理解成错误字段数。
还有一份二十岁的资料。它在基础模式中满足年龄门槛,在严格模式中就需要修正。页面保留输入,点击模式按钮后重新执行同一个校验入口。业务如果要做“用户换了一种报名类型,要求随之改变”的场景,可以参考这种组织方式,但具体年龄门槛仍要按自己的业务确定。
兴趣在输入区用逗号分隔。这个选择只是为了缩短示例界面,进入 Kotlin 后仍然是字符串列表。列表至少一项、最多四项,每一项还要检查空白。只判断集合长度,可能放过一个装着空字符串的列表;逐项检查能把这种区别展示出来。姓名与城市也没有悄悄去掉输入中的空格,所以原始资料和提示可以对应着检查。
3. 运行效果:看提示,也看恢复过程
运行环境为 API 24 的华为电脑真机(电脑模式、ARM64)与 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 头文件由原生构建脚本输出,不需要手工猜测导出结构。
| 文件 | 在调用链中的职责 |
|
| 原库调用、返回编码与设备检查 |
|
| 共享源集、双 ABI 依赖和编译选项 |
|
| 编译 KLIB、链接动态库并生成匹配头文件 |
|
| 参数检查、原生调用与返回值生命周期 |
|
| 按 ABI 选择动态库和头文件 |
|
| ArkTS 能看到的接口类型 |
|
| 完整输入、运行反馈与独立界面 |
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。
更多推荐



所有评论(0)