1. 需要查询语句的时候

商品列表最开始往往只有价格和库存两个开关,界面也很清爽。后来又要照顾会员商品:价格超过一百,且有库存;没有库存也没关系,会员专属商品可以展示。把这句话拆成开关当然能做,但再添一个例外,页面上的控件和条件分支就要一起增加。

这次换个入口,直接输入一小段查询条件。它不是数据库语句,也不执行脚本,只认识三个字段、几个比较运算符,以及与、或和括号。目标很具体:让一行文本变成结构明确的筛选规则,再用规则检查三条固定商品数据。页面既显示命中了谁,也把解析结构展示出来,方便对照自己写下的意思。

解析这一步交给 Parsus。它是 Kotlin 的解析组合库,语法规则可以在代码里组合起来,生成的解析结果也由开发者自己定义。本例把它的公共源码编译到鸿蒙原生目标,页面负责收集输入,筛选表达式的词法和组合规则留在 Kotlin 层。

2. 运行时把错误也走一遍

运行环境为 API 24 的华为电脑真机(电脑模式、ARM64)与 x86_64 模拟器。两端安装本次编译的签名应用,完成页面操作和 16 项原库检查,均返回 16/16。下面保留连续操作过程。

演示顺序从库存筛选开始,接着切换到全部对照,再输入带括号的组合条件。检查这几步时,重点看的是命中数量以及耳机、键盘、支架的位置变化,单看“解析成功”四个字不足以说明筛选语义正确。

随后从左侧选择缺少数字的错误输入。这个输入仍有字段和符号,正好能检验解析器是否严格要求比较规则完整。商品列表会换成浅橙色错误区域,显示字符偏移和库给出的解析信息。最后点恢复查询,再从结果区看到正常商品条目,确认失败没有破坏后续状态。自己修改输入时,点击语句右边的执行按钮再查询。

设备内检查覆盖十六项,包括比较边界、小数、负数、空白忽略、与或优先级、括号、未知字段、空查询、尾随文本,以及失败后的恢复。其中既有应该命中的条件,也有应该被拒绝的输入,不能把所有检查都写成能成功的同一种路径。检查按钮运行的是 Kotlin 桥接中的这些断言,页面只展示统计和逐项反馈。

3. 先写明这门小语言允许什么

本例的数据只有耳机、键盘、支架三条。每条商品都有价格、库存和会员标记;会员标记用零和一表示,方便全部比较规则共用一种数值类型。这个选择是演示约定,不代表业务系统中的所有布尔字段都应该转成数字。

第一条默认查询要求价格不低于一百,并且库存大于零。耳机符合,键盘虽然贵但没有库存,支架有库存却不到价格门槛。把后半段改为“有库存或者是会员商品”,再用括号包起来,键盘就会进入结果。两条查询只相差几个字符,结果变化却能说明括号和运算顺序是否真的被解析了。

这里的与符号比或符号结合得更紧。没有括号时,一段“会员商品,或者价格符合且有库存”的规则,会先计算后面的两个条件;如果改成“会员商品或者价格符合,并且都有库存”,就需要括号把前两段合为一组。这个约定必须提前说清,否则语法树再漂亮,也可能和输入者想表达的条件不一致。

字段名也需要边界。语法层允许英文名称,语义层只接收价格、库存、会员三个现有字段。比如输入一个看起来正常的成本字段,字符和运算符都能解析成功,但这不等于可以执行。示例会检查整棵树用到的字段,发现不支持的名字就返回错误,而不是把不存在的数据当成零,悄悄给出一份错误结果。

还有一个容易混在一起的细节:字符串写成价格不等于某个值,和字段不存在不是同一件事。前者是一种已支持的比较规则,后者是执行规则所需的数据缺失。示例在解析完成后遍历节点收集字段名,再统一做校验。即便某个或条件在第一项就成立,另一项里的未知字段也会被发现,避免逻辑短路恰好掩盖一个拼写问题。

筛选本身不会改变原来的三条商品,也不把命中结果写回源数据。全部对照模式可以一直显示耳机、键盘和支架的固定价格与库存,这使两次查询之间的变化只来自规则。若把演示换成真实列表,还需要明确查询执行期间数据是否允许更新,不然界面里看到的条件和实际参与筛选的数据可能不是同一个时刻。

输入长度限定在五百一十二个字符以内,左括号总数也有上限。这个限制是页面示例的保护措施,避免一次同步操作占用太久,并没有测试库可以承受多少万层嵌套。本例也不认识字符串、日期、排序和集合运算,遇到这些需求应当扩展规则,而不是继续往一条正则里叠条件。

4. 查询规则怎样组合成一棵树

一条比较规则有三个部分:字段、比较符号、数字。把这三个词元依次读出来后,构造一个比较节点。两个比较节点之间有与符号,就构造与节点;两组条件之间有或符号,就构造或节点。节点只保存含义,不保存界面颜色,也不会在解析时访问商品列表。

下面是实际示例里的核心语法,省去的是外围页面和返回格式,没有省去规则本身。空白词元设为忽略,因此换行和制表符不会改变条件含义;字段和数字使用正则词元,括号及逻辑运算符则使用固定文本词元。

下面是 harmony/src/Bridge.kt 的完整内容。这份 Bridge.kt 同时定义 Query 节点、查询文法、字段校验、商品数据和节点求值。文法只负责生成结构,matches 才决定某条商品是否命中。把两者放在完整文件中阅读,可以顺着输入找到每一个比较符号的来源,也能核对十六项检查究竟在验证哪条规则。

package harmony
import me.alllex.parsus.parser.*
import me.alllex.parsus.token.*

private sealed class Query {
    data class Compare(val field:String,val op:String,val number:Double):Query()
    data class Both(val left:Query,val right:Query):Query()
    data class Either(val left:Query,val right:Query):Query()
}
private object QueryGrammar:Grammar<Query>() {
    init { regexToken("\\s+",ignored=true) }
    val field by regexToken("[a-zA-Z][a-zA-Z0-9_]*")
    val op by regexToken(">=|<=|==|!=|>|<")
    val number by regexToken("-?\\d+(?:\\.\\d+)?")
    val left by literalToken("(")
    val right by literalToken(")")
    val and by literalToken("&")
    val orToken by literalToken("|")
    val comparison:Parser<Query> by parser { Query.Compare(field().text,op().text,number().text.toDouble()) }
    val nested by -left * ref(::root) * -right
    val term:Parser<Query> by comparison or nested
    val andChain by leftAssociative(term,and) { a,b -> Query.Both(a,b) }
    override val root:Parser<Query> by leftAssociative(andChain,orToken) { a,b -> Query.Either(a,b) }
}
private data class Product(val name:String,val data:Map<String,Double>)
private val products=listOf(
    Product("耳机",mapOf("price" to 129.0,"stock" to 8.0,"vip" to 0.0)),
    Product("键盘",mapOf("price" to 299.0,"stock" to 0.0,"vip" to 1.0)),
    Product("支架",mapOf("price" to 59.0,"stock" to 15.0,"vip" to 0.0)))
private fun Query.matches(values:Map<String,Double>):Boolean=when(this) {
    is Query.Compare -> { val v=values[field]?:throw IllegalArgumentException("未知字段:$field;允许 price、stock、vip")
        when(op) {">="->v>=number;"<="->v<=number;"=="->v==number;"!="->v!=number;">"->v>number;else->v<number} }
    is Query.Both -> left.matches(values) && right.matches(values)
    is Query.Either -> left.matches(values) || right.matches(values)
}
private fun Query.fields():Set<String> = when(this) { is Query.Compare->setOf(field);is Query.Both->left.fields()+right.fields();is Query.Either->left.fields()+right.fields() }
private fun Query.structure():String=when(this) {is Query.Compare->"$field $op $number";is Query.Both->"AND(${left.structure()}, ${right.structure()})";is Query.Either->"OR(${left.structure()}, ${right.structure()})"}
private fun query(input:String):Query {
    require(input.length<=512) {"查询不能超过 512 个字符"}
    require(input.count{it=='('}<=24) {"括号层级过多"}
    val q=QueryGrammar.parseOrThrow(input)
    require(q.fields().all{it in setOf("price","stock","vip")}) {"未知字段:${q.fields().filter{it !in setOf("price","stock","vip")}.joinToString()}"}
    return q
}
fun evaluate(input:String,mode:Int):String=try {
    val q=query(input);val matched=products.filter{q.matches(it.data)}
    val rows=mutableListOf(row("解析结构",q.structure()),row("命中商品",matched.joinToString("、"){it.name}.ifEmpty{"无"}))
    (if(mode==0) matched else products).forEach { p->rows+=row(p.name, "price=${p.data["price"]} · stock=${p.data["stock"]} · ${if(q.matches(p.data)) "命中" else "未命中"}",if(q.matches(p.data)) 1.0 else 0.0) }
    result("查询成功 · ${matched.size} / ${products.size} 条命中",rows)
} catch(e:ParseException) {result("查询语法错误",listOf(row("错误位置","字符偏移 ${e.error.offset}"),row("解析反馈",e.error.describe())),false)}
catch(e:Exception) {result("查询不可执行",listOf(row("原因",e.message?:"无效查询")),false)}
private fun matches(s:String,p:Int=0)=query(s).matches(products[p].data)
private fun rejects(s:String):Boolean=try{query(s);false}catch(_:Exception){true}
fun runChecks():String=checks(listOf(
    "大于比较" to {matches("price > 100")},
    "等于比较" to {matches("stock == 8")},
    "小于等于" to {matches("price <= 59",2)},
    "不等于" to {matches("vip != 1")},
    "组合条件" to {matches("price >= 100 & stock > 0") && !matches("price >= 100 & stock > 0",1)},
    "与优先于或" to {matches("vip == 1 | price > 100 & stock > 0",1)},
    "括号改变结合" to {!matches("(vip == 1 | price > 100) & stock > 0",1)},
    "空白忽略" to {matches("  price\n >\t100 ")},
    "小数边界" to {matches("price > 128.5")},
    "负数" to {matches("stock > -1")},
    "拒绝缺右操作数" to {rejects("price >")},
    "拒绝缺右括号" to {rejects("(price > 100")},
    "拒绝尾随文本" to {rejects("price > 100 xyz")},
    "拒绝未知字段" to {rejects("cost > 10")},
    "拒绝空查询" to {rejects("")},
    "失败后恢复" to {rejects("price >") && matches("stock > 0")}
))

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)+"}"
}

注意比较符号的声明顺序。大于等于放在大于前面,小于等于放在小于前面,这样较长的符号不会先被拆成一个短符号,再留下一个无法解释的等号。负号属于数字词元,因而负数阈值不需要另外增加一个算术表达式解析器。

括号规则通过引用访问根规则,而不是在初始化时直接读取尚未建好的属性。进入左括号后,再次解析完整条件,随后要求右括号结束这一组。与条件和或条件分开组成两层,这两层的顺序就是优先级。后面如果加入逻辑非,也应该找准它属于哪一层,而不是只在输入字符串前面删除一个叹号。

解析入口使用整段解析。尾部多出一段名称,不会被视为“前半段已经成功,所以结果照用”。从文本输入得到可执行规则时,完整消费输入比得到一个看似合理的前缀结果更稳妥,否则用户修改了结尾,页面可能仍旧执行上一次含义相近的条件。

5. 鸿蒙接入改动集中在哪里

环境配置直接按 CPF-KMP-CMP 官方快速开始 进行。本例使用 OHOS Kotlin/Native 2.2.21-1.1.0,输出 ARM64 和 X64 两套原生库。Parsus 本身使用 Kotlin 标准库的协程机制,公共源码没有额外引入协程依赖包,不能因为简介里有协程一词就先添一组不需要的依赖。

源码仍保留在上游原有公共源集,新增内容放在独立的鸿蒙目录。构建配置指定公共源集和两个目标;构建脚本先生成库,再链接共享对象;桥接文件定义本例的查询语言、固定数据、错误返回和检查入口。运行页面放在示例目录的鸿蒙子目录,打开后可以直接输入条件并查看反馈。

这次没有修改解析组合器,也没有给正则匹配写一个页面侧替身。公共目录里的二十八个 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

完整输入、运行反馈与独立界面

5.1 把查询结果送回商品工作台

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 "parsus_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) parsus_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,parsus_symbols()->kotlin.root.harmony.evaluate(text.c_str(),mode));
}
static napi_value Check(napi_env env,napi_callback_info info){return Copy(env,parsus_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);}

5.2 源集、依赖和双架构链接配置

共享源码位置是 src/commonMain/kotlin。构建配置把 ARM64 与 X64 分开;每个目标当前需要 0 份显式 KLIB 依赖。没有外部依赖不代表可以使用桌面编译器,目标仍须是 OHOS。

harmony/native-config.json 完整内容:

{
  "common": "src/commonMain/kotlin",
  "code": "parsus",
  "dependencies": {
    "ohos_arm64": [],
    "ohos_x64": []
  }
}

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}/libparsus.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": "Parsus · 括号组合与商品筛选",
  "main": "",
  "author": "",
  "license": "",
  "dependencies": {
    "libentry.so": "file:./src/main/cpp/types/libentry"
  }
}

5.3 查询工具栏怎样驱动商品列表

页面默认保留一条查询,左侧预设会把新语句送到同一个执行入口。商品是否命中和解析结构都来自原生结果,界面只选择显示命中项还是全部对照。下面的完整页面可以顺着 execute 的调用读到查询框、模式按钮、商品条目和错误区域。

先给出 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 Result { ok: boolean; summary: string; rows: ResultRow[]; }
interface Checks { passed: number; total: number; detail: string; }

@Entry
@Component
struct Index {
  @State input: string = 'price >= 100 & stock > 0';
  @State mode: number = 0;
  @State view: Result = { ok: true, summary: '', rows: [] };
  @State checkStatus: string = '16 项查询规则,等待核对';
  @State details: string = '';
  private samples: string[] = ['price >= 100 & stock > 0', 'price >= 100 & (stock > 0 | vip == 1)', 'price >', 'stock > 0'];
  private sampleLabels: string[] = ['库存筛选', '嵌套条件', '错误输入', '恢复查询'];
  private sampleNotes: string[] = ['价格达标,而且有货', '有货或会员专属', '观察缺少数字的反馈', '有库存就能入选'];

  aboutToAppear(): void { this.refresh(); }
  private refresh(): void {
    try { this.view = JSON.parse(native.evaluate(this.input, this.mode)) as Result; }
    catch (e) { this.view = { ok: false, summary: '查询调用失败', rows: [{ label: '反馈', value: '请修正输入后再次查询', metric: 0 }] }; }
  }
  private fieldValue(index: number): string { return this.view.rows.length > index ? this.view.rows[index].value : ''; }
  private check(): void {
    try {
      const r = JSON.parse(native.runChecks()) as Checks;
      this.checkStatus = `设备检查 ${r.passed}/${r.total}`;
      this.details = r.detail;
    } catch (e) { this.checkStatus = '检查暂未完成,可再次运行'; }
  }

  @Builder
  field(label: string, note: string) {
    Row({ space: 10 }) {
      Text(label).fontFamily('monospace').fontSize(14).fontColor('#1D6456').width(58)
      Text(note).fontSize(12).fontColor('#748D84')
    }.width('100%').padding({ top: 8, bottom: 8 })
  }

  build() {
    Column() {
      Row({ space: 14 }) {
        Text('Q').fontSize(25).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
          .width(46).height(46).textAlign(TextAlign.Center).backgroundColor('#137763').borderRadius(14)
        Column({ space: 3 }) {
          Text('商品查询工作台').fontSize(27).fontWeight(FontWeight.Bold).fontColor('#173D31').width('100%')
          Text('Parsus · 写下条件,看看哪些商品会留下').fontSize(14).fontColor('#708A7F').width('100%')
        }.layoutWeight(1)
        Text('3 条固定商品').fontSize(13).fontColor('#3A7966').padding({ left: 14, right: 14, top: 10, bottom: 10 })
          .backgroundColor('#E3F1E9').borderRadius(18)
      }.width('100%').padding({ left: 28, right: 28, top: 22, bottom: 22 }).backgroundColor('#FFFFFF')

      Row() {
        Scroll() {
          Column({ space: 12 }) {
            Text('常用查询').fontSize(12).fontWeight(FontWeight.Bold).fontColor('#91A599').width('100%')
            ForEach(this.samples, (sample: string, i: number) => {
              Column({ space: 7 }) {
                Text(this.sampleLabels[i]).fontSize(16).fontWeight(FontWeight.Medium).fontColor(this.input === sample ? '#125E4D' : '#3B5146').width('100%')
                Text(this.sampleNotes[i]).fontSize(12).fontColor('#87988E').width('100%')
              }.id(`sample-${i}`).width('100%').padding(16).borderRadius(12)
                .backgroundColor(this.input === sample ? '#DDF0E6' : '#FFFFFF')
                .border({ width: 1, color: this.input === sample ? '#A7D5BF' : '#EDF2EE' })
                .onClick(() => { this.input = sample; this.refresh(); })
            })
            Text('允许的字段').fontSize(12).fontWeight(FontWeight.Bold).fontColor('#91A599').width('100%').margin({ top: 14 })
            Column() {
              this.field('price', '价格')
              this.field('stock', '库存数量')
              this.field('vip', '会员标记 0 / 1')
            }.padding(14).width('100%').backgroundColor('#FFFFFF').borderRadius(12)
            Text('用 & 表示同时满足,用 | 表示任一满足;括号先组合。').fontSize(12).fontColor('#8A9C90').lineHeight(21).width('100%')
          }.padding(20).width('100%')
        }.width(240).height('100%').backgroundColor('#F0F5F0')

        Scroll() {
          Column({ space: 20 }) {
            Column({ space: 12 }) {
              Text('筛选语句').fontSize(14).fontWeight(FontWeight.Medium).fontColor('#62786D').width('100%')
              Row({ space: 12 }) {
                TextInput({ text: this.input, placeholder: '例如 price > 100 & stock > 0' }).id('source-input')
                  .fontFamily('monospace').fontSize(17).fontColor('#254C3E').height(56).maxLength(4096)
                  .backgroundColor('#F6F9F6').borderRadius(10).layoutWeight(1).onChange((v: string) => { this.input = v; })
                Button('执行查询').id('evaluate').height(56).fontSize(15).backgroundColor('#187B64').fontColor('#FFFFFF')
                  .borderRadius(10).onClick(() => { this.refresh(); })
              }.width('100%')
              if (this.view.ok) {
                Text(this.fieldValue(0)).id('query-structure').fontSize(12).fontFamily('monospace').fontColor('#80948A').width('100%')
              }
            }.width('100%').padding(20).backgroundColor('#FFFFFF').borderRadius(14)

            Row({ space: 10 }) {
              Column({ space: 5 }) {
                Text(this.view.summary).id('summary').fontSize(21).fontWeight(FontWeight.Bold).fontColor(this.view.ok ? '#224E3D' : '#B9453F').width('100%')
                if (this.view.ok) { Text(`命中商品:${this.fieldValue(1)}`).fontSize(13).fontColor('#7B9083').width('100%') }
              }.layoutWeight(1)
              Button('命中记录').id('mode-0').fontSize(13).backgroundColor(this.mode === 0 ? '#1D6F5A' : '#E6EEE7')
                .fontColor(this.mode === 0 ? '#FFFFFF' : '#698171').onClick(() => { this.mode = 0; this.refresh(); })
              Button('全部对照').id('mode-1').fontSize(13).backgroundColor(this.mode === 1 ? '#1D6F5A' : '#E6EEE7')
                .fontColor(this.mode === 1 ? '#FFFFFF' : '#698171').onClick(() => { this.mode = 1; this.refresh(); })
            }.width('100%')

            if (this.view.ok) {
              ForEach(this.view.rows.slice(2), (r: ResultRow, i: number) => {
                Row({ space: 18 }) {
                  Text(r.label.slice(0, 1)).fontSize(26).fontColor('#547D67').width(60).height(60).textAlign(TextAlign.Center)
                    .backgroundColor('#EDF4EA').borderRadius(16)
                  Column({ space: 8 }) {
                    Text(r.label).fontSize(20).fontWeight(FontWeight.Medium).fontColor('#22452F').width('100%')
                    Text(r.value).id(`result-${i + 2}`).fontSize(14).fontFamily('monospace').fontColor('#8A9D8F').width('100%')
                  }.layoutWeight(1)
                  Text(r.metric === 1 ? '符合条件' : '未命中').fontSize(13).fontColor(r.metric === 1 ? '#287556' : '#8B918C')
                    .padding({ left: 12, right: 12, top: 8, bottom: 8 }).backgroundColor(r.metric === 1 ? '#E7F4EA' : '#EFF1EF').borderRadius(8)
                }.width('100%').padding(20).backgroundColor('#FFFFFF').borderRadius(14)
              })
              if (this.view.rows.length === 2) { Text('没有商品符合这条查询,试试调整价格或库存条件。').fontSize(15).fontColor('#8A9A90').padding(28).width('100%') }
            } else {
              Column({ space: 14 }) {
                Text('检查这条查询').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#A94D3E').width('100%')
                ForEach(this.view.rows, (r: ResultRow, i: number) => {
                  Text(`${r.label}:${r.value}`).id(`result-${i}`).fontSize(14).fontFamily('monospace').fontColor('#9B6656').lineHeight(23).width('100%')
                })
              }.width('100%').padding(22).backgroundColor('#FFF2E9').borderRadius(14)
            }

            Row({ space: 14 }) {
              Text(this.checkStatus).id('check-status').fontSize(13).fontColor('#819184').layoutWeight(1)
              Button('核对查询规则').id('run-checks').fontSize(13).fontColor('#3C6F57').backgroundColor('#E2EDE3').onClick(() => { this.check(); })
            }.width('100%').padding({ top: 8, bottom: 6 })
            if (this.details.length > 0) { Text(this.details).id('check-details').fontFamily('monospace').fontSize(12).lineHeight(21).fontColor('#657A67').width('100%') }
          }.padding(24).width('100%').constraintSize({ maxWidth: 1050 })
        }.layoutWeight(1).height('100%').backgroundColor('#F8FAF6')
      }.width('100%').layoutWeight(1).alignItems(VerticalAlign.Top)
    }.width('100%').height('100%').backgroundColor('#F8FAF6')
  }
}

5.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 社区。

6. 小结:语法和业务规则各管一段

这个小页面最终形成了三段职责。Parsus 把文本解析为查询树;示例的字段校验和节点求值决定商品是否符合;鸿蒙页面负责输入与展示。拆开后,新增字段可以先改业务数据和校验,新增操作符则要同时补语法、语义和检查,改动位置比较容易找。

它适合做规则编辑器的起点,还不是通用查询引擎。若要接大列表、后台数据或多人配置,仍要补充异步执行、错误提示的本地化和规则版本管理。当前例子只保留三条固定数据,恰好能把括号、优先级和错误恢复这些容易被忽略的地方观察清楚。

源码和可运行示例见 Parsus 鸿蒙目录,合集见 harmonyos-crossplatform-libs。平台资料可查阅 CPF-KMP-CMP 与 CPF-ApplicationTPC。

Logo

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

更多推荐