1. 先把输入和答案记下来
做一个计算器,按钮和结果框很快就能搭出来。麻烦通常藏在结果的末尾:长整数最后一位有没有变,小数结果是不是只在显示时截掉了尾巴,除不尽的数又按什么规则收尾?这一篇从三组容易核对的输入出发,把这些问题放进一个能在鸿蒙设备上操作的小页面。
计算部分使用 kotlin-multiplatform-bignum。它提供 BigInteger、BigDecimal 等实现,共享 Kotlin 源码可以参与 Native 编译。页面里的大整数加法、乘法、商余数和小数运算,均由这份原库完成。
这次先记下三个按钮对应的实验结果。读者也可以在示例中修改两项输入,再切换运算方式。
实验 | 输入 | 设备实际返回 |
超过安全整数 | 9007199254740993 + 1 | 9007199254740994 |
十进制小数 | 0.1 + 0.2 | 0.3 |
除不尽的小数 | 1 ÷ 3 | 0.33333333333333333333 |
下面是 MatePad Edge 真机上的连续操作。切换预设后,输入框、运算方式和结果一起变化,最后运行页面底部的边界检查。
第三行的答案值得暂时留一个问号:为什么到这里停止?前两行核对数值,第三行还要核对计算约定。先沿着输入的去向看代码,再回来解释这个约定。
2. 第一笔记录:三十位整数怎么传进去
页面首次打开时,默认计算三十位整数加一。这个长度足够让最后一位变化清楚地显示出来,也便于检查传参途中有没有丢位。两项输入始终作为字符串保存,进入 Native 时仍是 UTF-8 字符串;Kotlin 收到后再调用 parseString。结果也以字符串返回,整条链路保留输入的数字信息。
这个顺序不能随便改。如果先把输入转成 ArkTS number,超出安全整数范围的数值可能已经发生变化,再用字符串送给 BigInteger,库算得再准确也无法补回原来的数字。因此示例的三个预设都直接给输入状态赋字符串。
下面是实际编入动态库的计算入口。运算模式对应页面上的五个选项,整数除法一次返回商和余数。入口把解析或运算异常转换成错误状态,页面据此显示“输入有问题”。
package harmony import com.ionspin.kotlin.bignum.integer.BigInteger import com.ionspin.kotlin.bignum.decimal.BigDecimal import com.ionspin.kotlin.bignum.decimal.DecimalMode import com.ionspin.kotlin.bignum.decimal.RoundingMode private fun integer(s:String)=BigInteger.parseString(s.trim(),10) private fun decimal(s:String)=BigDecimal.parseString(s.trim()) fun calculate(mode: Int, first: String, second: String): String { try { require(first.length in 1..512 && second.length in 1..512) { "请输入1到512位数字" } val value = when(mode) { 0 -> (integer(first)+integer(second)).toString() 1 -> (integer(first)*integer(second)).toString() 2 -> { val (q,r)=integer(first).divideAndRemainder(integer(second)) "商:$q\n余数:$r" } 3 -> (decimal(first)+decimal(second)).toStringExpanded() 4 -> decimal(first).divide(decimal(second),DecimalMode(20,RoundingMode.ROUND_HALF_TO_EVEN)).toStringExpanded() else -> error("未知运算") } val note=if(mode==4) "20位有效数字,ROUND_HALF_TO_EVEN" else "Kotlin/Native · bignum 原库计算" return "{\"ok\":true,\"value\":"+quoted(value)+",\"note\":"+quoted(note)+"}" } catch(e: Throwable) { return "{\"ok\":false,\"value\":"+quoted(e.message ?: "无法计算")+",\"note\":\"输入未通过校验\"}" } } fun runChecks(): String=checks(listOf( "30位整数加法" to { (integer("123456789012345678901234567890")+BigInteger.ONE).toString()=="123456789012345678901234567891" }, "大整数乘法" to { (integer("999999999999999999")*integer("999999999999999999")).toString()=="999999999999999998000000000000000001" }, "负数相消" to { integer("-9007199254740993")+integer("9007199254740993")==BigInteger.ZERO }, "商余数重建" to { val a=integer("123456789012345678901234567890"); val b=integer("97"); val(q,r)=a.divideAndRemainder(b); q*b+r==a && r>=BigInteger.ZERO && r<b }, "十六进制解析" to { BigInteger.parseString("ffffffffffffffff",16).toString()=="18446744073709551615" }, "0.1 + 0.2 精确结果" to { (decimal("0.1")+decimal("0.2")).toStringExpanded()=="0.3" }, "三分之一精度" to { decimal("1").divide(decimal("3"),DecimalMode(10,RoundingMode.ROUND_HALF_TO_EVEN)).toStringExpanded()=="0.3333333333" }, "除零返回错误" to { calculate(2,"1","0").contains("\"ok\":false") }, "非法数字返回错误" to { calculate(0,"hello","1").contains("\"ok\":false") }, "桥接长度上限" to { calculate(0,"9".repeat(513),"1").contains("\"ok\":false") } ))代码里有两层长度限制:Kotlin 入口限制输入字符串为一到五百一十二个字符,N-API 桥限制传入的 UTF-8 字节长度。两者服务于这个演示的输入规模和调用边界,不能据此推断原库只能计算五百一十二位。页面预设的长整数与十六进制解析检查也覆盖了不同的解析路径。
检查结果需要返回多行文字,计算结果也可能包含商和余数两行,所以 JSON 包装要处理引号、反斜杠、换行和制表符。下面的辅助代码与计算入口一起编译,页面读取同一份返回值。
package harmony internal fun quoted(s: String): String = "\"" + buildString { for (c in s) when(c) { '\\' -> append("\\\\") '"' -> append("\\\"") '\n' -> append("\\n") '\r' -> append("\\r") '\t' -> append("\\t") else -> if(c.code < 32) append(" ") else append(c) } } + "\"" internal fun checks(tests: List<Pair<String, () -> Boolean>>): String { val lines=tests.map { (name, test) -> val ok=try { test() } catch(e: Throwable) { false } name to ok } return "{\"passed\":"+lines.count{it.second}+",\"total\":"+lines.size+ ",\"detail\":"+quoted(lines.joinToString("\n") { (n,ok) -> (if(ok) "PASS " else "FAIL ")+n })+"}" }N-API 层取出模式和两段字符串,调用 Kotlin 导出的函数,再创建页面可以读取的字符串。Kotlin/Native 返回值的释放也在这里完成:先复制到 N-API 的值中,再调用生成接口中的 DisposeString。重复点按钮时,这一步同样会执行。
#include "napi/native_api.h" #include "bignum_api.h" #include <string> // The strings returned by Kotlin/Native have to be released by DisposeString. static napi_value ReturnString(napi_env env, const char* text) { napi_value value; napi_create_string_utf8(env,text?text:"",NAPI_AUTO_LENGTH,&value); if(text) bignum_symbols()->DisposeString(text); return value; } static std::string ReadString(napi_env env, napi_value value) { size_t length=0; if(napi_get_value_string_utf8(env,value,nullptr,0,&length)!=napi_ok || length>4096) { napi_throw_type_error(env,nullptr,"Expected a string up to 4096 UTF-8 bytes"); return ""; } std::string result(length+1,'\0'); napi_get_value_string_utf8(env,value,result.data(),result.size(),&length); result.resize(length); return result; } static napi_value Run(napi_env env, napi_callback_info info) { size_t argc=3; napi_value args[3]; napi_get_cb_info(env,info,&argc,args,nullptr,nullptr); if(argc!=3) { napi_throw_type_error(env,nullptr,"Wrong number of arguments"); return nullptr; } int32_t mode=0; if(napi_get_value_int32(env,args[0],&mode)!=napi_ok){napi_throw_type_error(env,nullptr,"Expected numeric mode");return nullptr;} auto a=ReadString(env,args[1]); auto b=ReadString(env,args[2]); bool pending=false;napi_is_exception_pending(env,&pending);if(pending)return nullptr; return ReturnString(env,bignum_symbols()->kotlin.root.harmony.calculate(mode,a.c_str(),b.c_str())); } static napi_value Check(napi_env env, napi_callback_info info) { return ReturnString(env,bignum_symbols()->kotlin.root.harmony.runChecks()); } static napi_value Init(napi_env env, napi_value exports) { napi_property_descriptor methods[]={ {"calculate",nullptr,Run,nullptr,nullptr,nullptr,napi_default,nullptr}, {"runChecks",nullptr,Check,nullptr,nullptr,nullptr,napi_default,nullptr} }; napi_define_properties(env,exports,2,methods); return exports; } static napi_module module={1,0,nullptr,Init,"entry",nullptr,{0}}; extern "C" __attribute__((constructor)) void RegisterEntry(){ napi_module_register(&module); }整数除法的核对用了一个很实用的关系:商乘除数,再加余数,应当恢复被除数;正数用例还检查余数的范围。它能同时约束几个返回值,便于发现“某个示例答案碰巧正确”的情况。三十位加法、大整数乘法和负数相消也由当前设备上的 Kotlin 检查函数执行。
3. 第二笔记录:0.3 算出来之后,除法怎么收尾
小数加法的两项输入是十进制文本“0.1”和“0.2”。BigDecimal 从文本解析它们,加法结果通过 toStringExpanded 返回“0.3”。这里可以直接观察解析和运算得到的数值,不必靠页面截取最后几位来让结果看起来整齐。
一除以三需要另一种处理。循环小数无法用有限长度的十进制字符串完整写完,示例调用 divide 时指定 DecimalMode:二十位有效数字,ROUND_HALF_TO_EVEN 舍入。计算结果旁边也显示这条约定,避免只给一个很长的小数,让读者猜它为什么停在那里。
注意“有效数字”的含义。它约束整个数值的精度,并不等同于始终保留二十位小数。当前三分之一的整数部分为零,两种说法看起来容易混淆;换成更大的数量级,区别就会显现。用于金额时,还应按照具体业务的尺度和舍入规定选模式,再补上舍入临界值用例。
页面把输入、模式、结果和计算说明放在一起,便于来回比较。下面保留完整页面代码,三个预设也可以看出字符串是在什么地方进入计算入口的。
import native from 'libentry.so'; interface Calculation { ok: boolean; value: string; note: string; } interface Checks { passed: number; total: number; detail: string; } @Entry @Component struct Index { @State first: string = '123456789012345678901234567890'; @State second: string = '1'; @State mode: number = 0; @State result: string = ''; @State note: string = ''; @State ok: boolean = true; @State checked: string = '点击运行检查,结果来自当前设备'; @State details: string = ''; private modes: string[] = ['整数加法', '整数乘法', '商与余数', '小数加法', '小数除法']; aboutToAppear(): void { this.calculate(); } private calculate(): void { const response = JSON.parse(native.calculate(this.mode, this.first, this.second)) as Calculation; this.ok = response.ok; this.result = response.value; this.note = response.note; } private preset(a: string, b: string, mode: number): void { this.first = a; this.second = b; this.mode = mode; this.calculate(); } build() { Scroll() { Column({ space: 18 }) { Column({ space: 8 }) { Text('小数别算歪,大整数别丢位').fontSize(30).fontWeight(FontWeight.Bold).fontColor('#14243C') Text('bignum · Kotlin Multiplatform / HarmonyOS').fontSize(14).fontColor('#65758B') }.alignItems(HorizontalAlign.Start).width('100%') Row({ space: 10 }) { Button('超过安全整数').onClick(() => this.preset('9007199254740993', '1', 0)) Button('0.1 + 0.2').onClick(() => this.preset('0.1', '0.2', 3)) Button('1 ÷ 3').onClick(() => this.preset('1', '3', 4)) }.width('100%') Column({ space: 14 }) { Text('输入两项数字').fontSize(20).fontWeight(FontWeight.Medium).fontColor('#20314A').width('100%') TextInput({ text: this.first, placeholder: '第一个数,最多512位' }) .fontSize(18).fontColor('#20314A').placeholderColor('#68798E').onChange((value: string) => this.first = value).maxLength(512) TextInput({ text: this.second, placeholder: '第二个数' }) .fontSize(18).fontColor('#20314A').placeholderColor('#68798E').onChange((value: string) => this.second = value).maxLength(512) Row({ space: 8 }) { ForEach(this.modes, (title: string, index: number) => { Button(title).fontSize(13).backgroundColor(this.mode === index ? '#2563EB' : '#E8EEF8') .fontColor(this.mode === index ? Color.White : '#24364E') .onClick(() => { this.mode = index; this.calculate(); }) }) }.width('100%') Button('用 Kotlin 原库计算').width('100%').height(46).backgroundColor('#2563EB') .onClick(() => this.calculate()) }.padding(22).backgroundColor(Color.White).borderRadius(18).width('100%') Column({ space: 12 }) { Text(this.ok ? '计算结果' : '输入有问题').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#20314A').width('100%') Text(this.result).fontSize(24).fontColor(this.ok ? '#0C735B' : '#BE3333').width('100%') .copyOption(CopyOptions.LocalDevice) Text(this.note).fontSize(13).fontColor('#68798E').width('100%') }.padding(22).backgroundColor(this.ok ? '#EAF8F1' : '#FFF0EF').borderRadius(18).width('100%') Row() { Text(this.checked).fontSize(15).fontColor('#24364E').layoutWeight(1) Button('运行边界检查').fontSize(14).onClick(() => { const checks = JSON.parse(native.runChecks()) as Checks; this.checked = `检查通过 ${checks.passed}/${checks.total}`; this.details = checks.detail; }) }.width('100%') if (this.details.length > 0) { Text(this.details).fontSize(14).fontColor('#24364E').fontFamily('monospace').lineHeight(22).width('100%') .padding(18).backgroundColor('#E7EDF6').borderRadius(12) } Text('精度由十进制字符串保留。不会先转成 ArkTS number。') .fontSize(13).fontColor('#68798E').width('100%') }.padding(28).width('100%').constraintSize({ maxWidth: 1000 }) }.width('100%').height('100%').backgroundColor('#F3F6FA') } }边界检查中的三分之一采用十位有效数字,以便核对一个较短的确定答案;页面按钮采用二十位有效数字。十位精度用于核对固定预期值,二十位精度用于页面上的除法演示。检查函数还包含除零、非法字符及超长输入,返回失败状态时结果区会使用错误样式。
4. 编译记录:旧注解和大小写 API
计算接口确定下来之后,源代码还要通过鸿蒙工具链。环境安装直接参考 CPF-KMP-CMP 官方快速开始。本次实际使用鸿蒙 Kotlin/Native 2.2.21-1.1.0 编译 arm64 与 x64 产物,DevEco 应用和两台设备的验证环境为 API 24。
编译时遇到的第一处旧 API 在 integer/Configuration.kt。SharedImmutable 被当前编译器报告为废弃错误,于是移除了该注解和导入,底层仍使用原来的 BigInteger63Arithmetic。这项改动让旧源码能够通过新版编译器检查,大整数计算实现保持原样。
另外三处是字符串大小写方法的更新:integer/base63/BigInteger63LinkedListArithmetic.kt、integer/base63/array/BigInteger63Arithmetic.kt 和 integer/util/VariousUtil.kt 中的 toLowerCase、toUpperCase 改为 lowercase、uppercase。它们出现在数字解析和辅助处理代码中,改动范围可以直接按这三个文件核对。
新增的 harmony/build.gradle.kts 描述鸿蒙目标及源集关系:commonMain 接入原库共享源码,ohosMain 同时接入示例入口和原来的 nativeMain。原仓中的 example/harmony 提供完整的鸿蒙调用工程,配置、桥接和页面各有自己的位置。
plugins { kotlin("multiplatform") version "2.2.21-1.1.0" } kotlin { val ohosTargets=listOf(ohosArm64(),ohosX64()) ohosTargets.forEach { target -> target.binaries.sharedLib { baseName="bignum" } } sourceSets { commonMain { kotlin.srcDir("../bignum/src/commonMain/kotlin") } val ohosMain by creating { dependsOn(commonMain.get()) kotlin.srcDir("src") kotlin.srcDir("../bignum/src/nativeMain/kotlin") } ohosArm64Main { dependsOn(ohosMain) } ohosX64Main { dependsOn(ohosMain) } commonTest { kotlin.srcDir("../bignum/src/commonTest/kotlin") dependencies { implementation(kotlin("test")) } } } }这份配置说明平台目标的组织方式。本次已经验证的是命令行编译器生成的 KLIB、动态库及后续 HAP 构建;Gradle 发布流程尚未运行验证。两个 ABI 的产物分别供真机和模拟器使用,示例也随库放在原仓的 example 下,便于顺着源码找到实际调用。
5. 复核这本小账本
同一份最终构建也安装到了 x86_64 模拟器。前面的三组预设得到相同结果,三十位默认输入加一的末尾变化一致;模拟器与 MatePad Edge arm64 真机的原生检查均返回十项通过。
这十项包括三十位整数加法、大整数乘法、负数相消、商余数重建、十六进制解析、小数加法、指定精度除法,以及三项错误输入处理。它们覆盖当前计算器使用的路径,原库可选的 serialization 扩展没有包含在这次验证中。这里也没有进行长输入下的性能测试,后续测量时可以分别记录算法运算、字符串转换与页面刷新的时间。
回看三组实验,最容易忽略的是输入还没开始计算就已经失真,以及结果没有写清舍入规则。这个示例把字符串传递、原库运算和精度说明连在一起,既能检查长整数的最后一位,也能说明循环小数的最后一位从何而来。
继续扩展计算器时,可以先从舍入临界值和不同数量级的小数补起。BigDecimal 的模式与运算说明可查 bignum 上游文档,鸿蒙平台目标与工具链资料见 CPF-KMP-CMP。
项目源码:harmonyos-crossplatform-libs。本文示例位于 libraries/bignum,构建步骤见 适配说明。