1. 价格公式放到页面上

一件商品三十元,买三件,打九折,结果应该是八十一元。这当然不值得专门引入一个库。问题在于,页面上的计算方式可能会变:有的场景按件打折,有的还要加运费,有的需要满足会员条件后再选择价格分支。如果每种公式都写一个按钮对应的函数,输入框看起来开放,实际能执行的规则却早已写死。

这次做一个带固定变量的表达式页面,接入 Kotlin multiplatform expressions evaluator。输入是公式文本,变量由应用提供,库负责分词、生成语法树和求值。页面既能计算数值,也能判断条件,并加了一个两参数的运费函数,用来检查自定义函数是否真的进入计算流程。

示例刻意保持变量少而明确:价格三十,数量三,折扣比例零点一,会员状态为真。右侧变量面板把这四个固定值单独列出,输入区下面用大字号显示当前结果,再展示另一份环境的对照结果。下方的四道预设题分别用于折扣、运费、布尔条件和参数错误。初次看到结果可以心算核对,随后观察同一棵语法树给出的不同值。

2. 用同一份输入做两次计算

先输入价格乘数量,再乘一减折扣比例。第一页数值结果是八十一;下面另一行使用相同语法树,只把数量改成五,结果应当是 一百三十五。这一对结果能说明,式子里的数量确实作为变量读取,而不是第一次解析时就永久替换成了三。

另一个样例把价格和数量的乘积交给运费函数,再加六元运费,得到九十六;数量换成五之后则得到一百五十六。这个函数没有特别的数学技巧,正因为简单,能清楚观察参数表达式是否先被求值,以及自定义函数是否接收到了两个正确的数。

条件模式使用数量不少于三且是会员这一判断,结果为真。复用语法树时将会员改为假,结果变为假。数值和布尔值没有挤在一个不明确的字符串接口里:桥接根据模式调用库的数值求值或布尔求值入口,选错模式也会得到错误反馈,而不是把真强行转成一。

所谓复用在这里有一个清楚的范围:每次点击先解析输入一次,然后在这次调用里给两份变量环境各求值一次。页面没有实现跨多次点击的长期语法树缓存。要做可复用的公式服务,还需要规定缓存键、公式变更和失效方式,不能把本例的一次调用说成已经完成全局缓存优化。

界面里的第二份环境只改变一个变量,其他值仍然沿用原环境。这也是对照实验的一部分:折扣公式只改变数量,条件表达式只改变会员状态。若同时改变价格、折扣和会员,虽然也能得到新的结果,但很难从一次演示确认哪项输入影响了哪个节点。固定其余变量,会让语法树复用的行为更容易复核。

3. 自定义函数在哪里接进去

库的函数声明包含名称和参数数量,示例的运费函数注册为两个参数。函数内部还要检查类型:数量正确不代表类型正确,给它一个布尔值仍应失败。这里接受数字类型并转成双精度值,然后做加法,避免假设所有调用者只会传一种具体数字类型。

下面是 harmony/src/Bridge.kt 的完整内容。先声明两参数 Fee 函数,再构造求值器和固定变量环境。evaluate 会解析一次输入,在本次调用中用两份环境求值,并检查结果类型和有限性。完整文件还包含 JSON 返回辅助方法及十六项检查,因此数字、条件与失败都走同一条可追踪的返回链路。

package harmony
import com.github.murzagalin.evaluator.*
import kotlin.math.abs

private object Fee:Function("fee",2) {
    override fun invoke(vararg args:Any):Any {
        require(args.size==2 && args.all{it is Number}) {"fee 需要两个数字参数"}
        return (args[0] as Number).toDouble()+(args[1] as Number).toDouble()
    }
}
private val calculator=Evaluator(functions=DefaultFunctions.ALL+Fee)
private val values:Map<String,Any> = mapOf("price" to 30.0,"qty" to 3.0,"discount" to 0.1,"vip" to true)
private fun format(v:Double)=if(v==v.toLong().toDouble()) v.toLong().toString() else v.toString()
fun evaluate(input:String,mode:Int):String=try {
    require(input.isNotBlank() && input.length<=512) {"请输入 1 到 512 个字符的表达式"}
    val ast=calculator.preprocessExpression(input)
    val rows=mutableListOf(row("变量环境","price=30 · qty=3 · discount=0.1 · vip=true"),row("自定义函数","fee(金额, 运费) = 两数相加"))
    if(mode==0) {
        val v=calculator.evaluateDouble(ast,values)
        require(v.isFinite()) {"结果不是有限数,请检查除零或函数定义域"}
        rows.add(0,row("计算结果",format(v),v))
        rows+=row("复用语法树 · qty=5",format(calculator.evaluateDouble(ast,values+mapOf("qty" to 5.0))))
        result("数值计算成功",rows)
    } else {
        val v=calculator.evaluateBoolean(ast,values)
        rows.add(0,row("判断结果",v.toString(),if(v)1.0 else 0.0))
        rows+=row("复用语法树 · vip=false",calculator.evaluateBoolean(ast,values+mapOf("vip" to false)).toString())
        result("条件判断成功",rows)
    }
} catch(e:Exception) {result("表达式不可计算",listOf(row("原因",e.message?:e.toString()),row("继续操作","修正表达式后可再次计算")),false)}
private fun close(a:Double,b:Double)=abs(a-b)<1e-9
private fun rejects(s:String)=try{calculator.evaluateDouble(s,values);false}catch(_:Exception){true}
fun runChecks():String=checks(listOf(
    "乘法优先" to {close(calculator.evaluateDouble("2+3*4"),14.0)},
    "括号优先" to {close(calculator.evaluateDouble("(2+3)*4"),20.0)},
    "负数和小数" to {close(calculator.evaluateDouble("-2.5+1.25"),-1.25)},
    "变量折扣" to {close(calculator.evaluateDouble("price*qty*(1-discount)",values),81.0)},
    "自定义函数" to {close(calculator.evaluateDouble("fee(price*qty,6)",values),96.0)},
    "内置函数" to {close(calculator.evaluateDouble("sum(1,2,3)+max(4,5)"),11.0)},
    "内置常量" to {close(calculator.evaluateDouble("cos(pi)"),-1.0)},
    "幂运算" to {close(calculator.evaluateDouble("2^3"),8.0)},
    "布尔条件" to {calculator.evaluateBoolean("qty>=3 && vip",values)},
    "三元分支" to {close(calculator.evaluateDouble("qty>=3 ? 10 : 20",values),10.0)},
    "语法树复用" to {val ast=calculator.preprocessExpression("price*qty");close(calculator.evaluateDouble(ast,values),90.0) && close(calculator.evaluateDouble(ast,values+mapOf("qty" to 5)),150.0)},
    "拒绝缺变量" to {rejects("missing+1")},
    "拒绝函数参数不足" to {rejects("fee(1)")},
    "拒绝错误类型" to {rejects("fee(true,2)")},
    "拒绝缺右括号" to {rejects("(1+2")},
    "错误后恢复" to {rejects("fee(1)") && close(calculator.evaluateDouble("fee(1,2)"),3.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)+"}"
}

注册时把默认函数保留下来,再追加本例的函数。这样求和、最大值和三角函数仍可使用,加入一项业务函数不会无意中把默认功能全部替换掉。函数名也属于表达式语言的一部分,重命名时需要同时考虑已有公式,否则旧公式可能从函数调用变成无法解释的输入。

预处理返回的是语法树,变量读取发生在后面的求值步骤。把结构和环境分开,是这个页面最值得观察的部分:公式没变,数量变了,结果会变;公式没变,会员条件变了,判断也会变。反过来,如果公式本身改动,就必须重新预处理,不能拿旧树去解释新的输入文本。

数值结果还要检查是否有限。浮点除零有时不会抛异常,而是返回无限大;部分函数在定义域外会返回非数。示例把这些结果转成明确错误,不把它们当正常金额展示。不过这个保护不代表库适合财务结算:本例用双精度浮点数,严肃金额计算仍应选择合适的十进制表示和舍入规则。

4. 错误不是另一个计算结果

下方的错误参数预设只传一个参数给两参数函数,用来检验参数数量约束。大结果区会改成横线,并给出不可计算的提示和原因,输入区及计算按钮保持可用。点回折扣题即可重新得到正常结果;自己修改公式时,使用输入区右下角的计算按钮。恢复过程可以检查解析失败是否影响了下一次调用。

名称缺失、函数参数类型不对、括号没有闭合是另外几种失败。它们在库里可能产生不同异常,桥接统一转换成可显示的失败结果,但保留原因,不把所有问题都简化为“格式不对”。这种信息对调试比较实用:缺变量应去补环境,缺括号应改公式,两者处理方向完全不同。

输入最多五百一十二个字符。这个页面采用同步桥接,适合短公式演示;如果计算规则复杂、需要批量计算或者涉及很深的递归,应该进一步评估执行线程和资源边界。这里没有测量吞吐量,也没有宣称语法树复用会带来某个百分比的性能提升。

5. 鸿蒙侧的目录和依赖很短

环境按 CPF-KMP-CMP 官方快速开始 设置。使用 OHOS Kotlin/Native 2.2.21-1.1.0,分别构建 ARM64 和 X64 原生库;页面通过应用中的原生桥接访问同一个计算入口。

这个版本的表达式库公共源码只依赖 Kotlin 标准库,没有需要为鸿蒙补写的文件系统、网络或时钟接口。原有目录保持不动,新增平台目录保存构建配置、可移植构建脚本和示例桥接。示例目录里增加鸿蒙应用,页面展示的数值来自桥接对原库的调用。

这里的适配工作是让原库进入新的原生目标,再接通应用边界,并非修改每个运算符。分词器、解析器、数值求值器和布尔求值器都来自公共源码。构建后核对公共文件摘要,保证没有为了编译方便把上游函数删掉,也没有用几个固定公式替代完整的解析过程。

桥接接收公式文本和计算模式,返回成功状态、概述和结果行。字符串在原生侧生成,复制给页面后释放原生内存。页面只解释结构化返回,不需要把 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 公式便笺与结果面板

右侧是固定的变量环境,预设题只替换公式文本或模式。点击计算后先更新原生结果,再显示主结果和对照环境的结果;失败时保留公式并显示原因。完整页面没有省略变量面板、示例题和检查状态,便于复现录屏里的每一步。

先给出 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 * qty * (1 - discount)';
  @State mode: number = 0;
  @State view: Result = { ok: true, summary: '', rows: [] };
  @State checkStatus: string = '16 项计算检查,尚未运行';
  @State details: string = '';
  private samples: string[] = ['price * qty * (1 - discount)', 'fee(price * qty, 6)', 'qty >= 3 && vip', 'fee(1)'];
  private labels: 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 value(i: number): string { return this.view.rows.length > i ? this.view.rows[i].value : ''; }
  private last(): string { return this.view.rows.length > 0 ? this.view.rows[this.view.rows.length - 1].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
  variable(name: string, number: string, caption: string) {
    Row({ space: 12 }) {
      Column({ space: 4 }) {
        Text(name).fontSize(14).fontFamily('monospace').fontColor('#886D41').width('100%')
        Text(caption).fontSize(11).fontColor('#B4A78D').width('100%')
      }.layoutWeight(1)
      Text(number).fontSize(23).fontWeight(FontWeight.Medium).fontColor('#58432B')
    }.width('100%').padding({ top: 15, bottom: 15 }).border({ width: { bottom: 1 }, color: '#EDE2CB' })
  }

  @Builder
  preset(i: number) {
    Column({ space: 8 }) {
      Text(this.labels[i]).fontSize(14).fontWeight(FontWeight.Medium).fontColor('#6E5634').width('100%')
      Text(this.samples[i]).fontSize(12).fontFamily('monospace').fontColor('#AA977C').width('100%').maxLines(2)
    }.id(`sample-${i}`).padding(16).backgroundColor(this.input === this.samples[i] ? '#F9E2AC' : '#FDF8ED')
      .borderRadius(10).layoutWeight(1).height(92).onClick(() => { this.input = this.samples[i]; this.refresh(); })
  }

  build() {
    Scroll() {
      Column({ space: 24 }) {
        Row({ space: 16 }) {
          Text('ƒ').fontSize(48).fontColor('#C08626').fontWeight(FontWeight.Medium)
          Column({ space: 4 }) {
            Text('公式计算簿').fontSize(32).fontWeight(FontWeight.Bold).fontColor('#5D482F').width('100%')
            Text('Expressions Evaluator · 公式写一次,变量换一组').fontSize(14).fontColor('#B09A7B').width('100%')
          }.layoutWeight(1)
          Text('数值 / 条件').fontSize(12).fontColor('#B58F55').padding(12).backgroundColor('#FFF9ED').borderRadius(10)
        }.width('100%')

        Row({ space: 24 }) {
          Column({ space: 20 }) {
            Column({ space: 12 }) {
              Row({ space: 8 }) {
                Text('输入一条公式').fontSize(14).fontColor('#AC946D').layoutWeight(1)
                Button('数值计算').id('mode-0').fontSize(12).fontColor(this.mode === 0 ? '#FFF9E9' : '#A68D68')
                  .backgroundColor(this.mode === 0 ? '#A67C35' : '#F6EAD2').height(32).borderRadius(7)
                  .onClick(() => { this.mode = 0; this.refresh(); })
                Button('条件判断').id('mode-1').fontSize(12).fontColor(this.mode === 1 ? '#FFF9E9' : '#A68D68')
                  .backgroundColor(this.mode === 1 ? '#A67C35' : '#F6EAD2').height(32).borderRadius(7)
                  .onClick(() => { this.mode = 1; this.refresh(); })
              }.width('100%')
              TextArea({ text: this.input, placeholder: 'price * qty * (1 - discount)' }).id('source-input').maxLength(4096)
                .height(110).fontFamily('monospace').fontSize(24).fontColor('#604A2D').backgroundColor('#FFFDF6')
                .onChange((v: string) => { this.input = v; })
              Row({ space: 12 }) {
                Text('数值函数、变量、比较条件都由原库计算').fontSize(12).fontColor('#B9A58A').layoutWeight(1)
                Button('计算  =').id('evaluate').fontSize(19).fontWeight(FontWeight.Bold).height(48).width(135)
                  .fontColor('#FFFFFF').backgroundColor('#C48A2A').borderRadius(10).onClick(() => { this.refresh(); })
              }.width('100%')
            }.padding(24).backgroundColor('#FFFDF7').borderRadius(16).width('100%').border({ width: 1, color: '#E9DEC8' })

            Column({ space: 12 }) {
              Row() {
                Text(this.mode === 0 ? '本次计算结果' : '本次判断结果').fontSize(14).fontColor('#AC8B51').layoutWeight(1)
                Text(this.view.ok ? '已求值' : '需要修正').fontSize(12).fontColor(this.view.ok ? '#AA823B' : '#AE664A')
              }.width('100%')
              Text(this.view.ok ? this.value(0) : '—').id('result-0').fontSize(64).fontWeight(FontWeight.Bold).fontColor(this.view.ok ? '#755019' : '#BB8E6F').width('100%')
              Text(this.view.summary).id('summary').fontSize(14).fontColor(this.view.ok ? '#9B7A44' : '#B16445').width('100%')
              if (this.view.ok) {
                Row({ space: 14 }) {
                  Column({ space: 5 }) {
                    Text(this.mode === 0 ? '将 qty 换成 5' : '将 vip 换成 false').fontSize(12).fontColor('#AB915E').width('100%')
                    Text('复用这次解析得到的语法树').fontSize(11).fontColor('#B8A77D').width('100%')
                  }.layoutWeight(1)
                  Text(this.last()).id('result-3').fontSize(31).fontWeight(FontWeight.Medium).fontColor('#8B6930')
                }.width('100%').padding(16).backgroundColor('#FFF0C7').borderRadius(10)
              } else {
                ForEach(this.view.rows, (r: ResultRow, i: number) => {
                  Text(`${r.label}:${r.value}`).id(`error-${i}`).fontSize(14).fontColor('#AA7756').width('100%').lineHeight(22)
                })
              }
            }.width('100%').padding(24).backgroundColor('#FFF5DC').borderRadius(16)
          }.layoutWeight(1)

          Column({ space: 18 }) {
            Column({ space: 4 }) {
              Text('当前变量').fontSize(18).fontWeight(FontWeight.Medium).fontColor('#6F5836').width('100%')
              Text('固定环境,用于逐项对照结果').fontSize(11).fontColor('#B6A58A').width('100%')
              this.variable('price', '30', '商品价格')
              this.variable('qty', '3', '购买数量')
              this.variable('discount', '0.1', '折扣比例')
              this.variable('vip', 'true', '会员状态')
            }.padding(24).backgroundColor('#F5ECD8').borderRadius(16).width('100%')
            Column({ space: 9 }) {
              Text('自定义函数').fontSize(12).fontColor('#AD9470').width('100%')
              Text('fee(金额, 运费)').fontSize(18).fontFamily('monospace').fontColor('#8B6937').width('100%')
              Text('两个参数相加;少传一个参数时会明确报错。').fontSize(13).fontColor('#AD9777').lineHeight(23).width('100%')
            }.width('100%').padding(20).backgroundColor('#FAF0DE').borderRadius(12)
          }.width(288)
        }.width('100%').alignItems(VerticalAlign.Top)

        Column({ space: 12 }) {
          Text('换一道题试试').fontSize(15).fontColor('#9D815B').width('100%')
          Row({ space: 12 }) { this.preset(0); this.preset(1); }.width('100%')
          Row({ space: 12 }) { this.preset(2); this.preset(3); }.width('100%')
        }.width('100%')

        Row({ space: 12 }) {
          Text(this.checkStatus).id('check-status').fontSize(13).fontColor('#A8987C').layoutWeight(1)
          Button('检查计算边界').id('run-checks').fontSize(13).backgroundColor('#E9DBBB').fontColor('#8A7147').borderRadius(8).onClick(() => { this.check(); })
        }.width('100%').padding({ top: 4, bottom: 6 })
        if (this.details.length > 0) { Text(this.details).id('check-details').fontFamily('monospace').fontSize(12).fontColor('#9C8B6D').lineHeight(22).width('100%') }
      }.width('100%').padding(30).constraintSize({ maxWidth: 1240 })
    }.width('100%').height('100%').backgroundColor('#EEE5D1')
  }
}

5.2 公式结果经过哪一层转换

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 "expressionsevaluator_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) expressionsevaluator_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,expressionsevaluator_symbols()->kotlin.root.harmony.evaluate(text.c_str(),mode));
}
static napi_value Check(napi_env env,napi_callback_info info){return Copy(env,expressionsevaluator_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.3 源集、依赖和双架构链接配置

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

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

{
  "common": "src/commonMain/kotlin",
  "code": "expressionsevaluator",
  "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}/libexpressionsevaluator.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": "Expressions Evaluator · 从公式到可复用语法树",
  "main": "",
  "author": "",
  "license": "",
  "dependencies": {
    "libentry.so": "file:./src/main/cpp/types/libentry"
  }
}

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. 实际运行,连同恢复流程一起看

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

演示先计算折扣,再调用运费函数,然后切换到条件模式读取会员状态。之后故意触发参数不足错误,再回到折扣计算。观察顺序里既有数字变化,也有模式变化和失败恢复,只有把它们连续走完,才能检查页面与原生调用之间的状态是否一致。

设备内检查共十六项,覆盖乘法与括号优先级、负数、小数、变量、自定义函数、内置函数和常量、幂、布尔条件、三元表达式、语法树复用、缺变量、参数不足、错误类型、缺右括号和恢复。浮点值使用小误差容限比较,避免把十进制显示相同误当成底层浮点表示完全一致。

7. 收个尾:开放的是公式,边界仍由应用决定

接入这个库后,页面可以把一段公式交给共同的解析和求值入口,应用仍然负责变量来源、函数白名单、长度限制和错误反馈。输入能力开放不等于执行边界没有约束,业务函数越多,越应该把每个参数和返回含义写清楚。

当前示例只做短公式和固定变量,适合观察表达式求值在鸿蒙上的接入方式。它没有读取真实订单,也没有把双精度金额包装成严肃财务能力。之后如果做配置驱动页面,可以在这个基础上补变量编辑、公式校验和保存机制,但先保证错误可定位、失败可恢复,比先加一排函数按钮更有用。

源码与示例见 Expressions Evaluator 鸿蒙目录,总项目见 harmonyos-crossplatform-libs。相关框架资料见 CPF-KMP-CMP 和 CPF-ApplicationTPC。

Logo

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

更多推荐