HarmonyOSArkTS 实现计算器——调度场算法详解与避坑
HarmonyOS/ArkTS 实现计算器——调度场算法详解与避坑
前言
在 HarmonyOS 应用开发中实现计算器,第一反应可能是 eval() 或 new Function()。但 ArkTS 严格模式禁止动态代码执行,这两条路直接封死。你必须从零实现表达式解析。
本文基于 HarmonyOS NEXT(API 12+)、ArkTS 严格模式,完整讲解如何用**调度场算法(Shunting Yard Algorithm)**实现一个支持四则运算、括号、负号、科学计数法的计算器引擎。
一、为什么不能用 eval?
ArkTS 是 TypeScript 的严格子集,出于安全考虑禁止了以下特性:
| 写法 | ArkTS 是否支持 | 原因 |
|---|---|---|
eval("1+2") |
❌ | 动态代码执行,安全风险 |
new Function("return 1+2")() |
❌ | 同上 |
obj[dynamicKey] |
❌ | 动态属性访问 |
所以,你必须自己写解析器。
二、调度场算法原理
调度场算法(Shunting Yard Algorithm)由 Dijkstra 提出,核心思想是:把人写的中缀表达式转成计算机好算的后缀表达式(逆波兰表示法)。
2.1 中缀 vs 后缀
中缀表达式(人写的):1 + 2 × 3
后缀表达式(计算机算的):1 2 3 × +
中缀有优先级问题(先乘除后加减),后缀没有——从左到右算就行。
2.2 算法流程
需要两个数据结构:
- 输出队列(out):存放后缀表达式
- 运算符栈(ops):临时存放运算符
从左到右扫描中缀表达式:
- 遇到数字 → 直接输出到 out
- 遇到运算符 → 和栈顶比较优先级
- 栈顶优先级 ≥ 当前 → 弹出栈顶到 out
- 否则 → 当前运算符入栈
- 遇到左括号 → 入栈
- 遇到右括号 → 弹栈到 out,直到遇到左括号
- 扫描结束 → 弹出栈中所有剩余运算符
2.3 完整推演:1 + 2 × 3
| 步骤 | 读取 | out | ops | 说明 |
|---|---|---|---|---|
| 1 | 1 | [1] | [] | 数字直接输出 |
| 2 | + | [1] | [+] | 栈空,+入栈 |
| 3 | 2 | [1,2] | [+] | 数字直接输出 |
| 4 | × | [1,2] | [+,×] | ×优先级>栈顶+,入栈 |
| 5 | 3 | [1,2,3] | [+,×] | 数字直接输出 |
| 6 | 结束 | [1,2,3,×,+] | [] | 弹出剩余 |
最终后缀:1 2 3 × +
2.4 后缀表达式求值
从左到右扫描后缀表达式,用栈求值:
| 步骤 | 读取 | 数栈 | 说明 |
|---|---|---|---|
| 1 | 1 | [1] | 数字入栈 |
| 2 | 2 | [1,2] | 数字入栈 |
| 3 | 3 | [1,2,3] | 数字入栈 |
| 4 | × | [1,6] | 弹2,3,算2×3=6 |
| 5 | + | [7] | 弹1,6,算1+6=7 |
结果:7 ✅
三、三步实现
整个引擎分为三步流水线:
"1+2×3" → tokenize → ["1","+","2","×","3"] → toRPN → ["1","2","3","×","+"] → evalRPN → 7
3.1 第一步:切词(tokenize)
这是最容易出 bug 的一步。"12+34" 你不能把 1 和 2 当成两个数,必须拼成 "12"。
基本思路:逐字符扫描,数字连续拼成一个 token。
function tokenize(expr: string): string[] {
let tokens: string[] = []
let i = 0
while (i < expr.length) {
let c = expr[i]
if (isNumberToken(c)) {
let num = ''
while (i < expr.length && isNumberToken(expr[i])) {
num += expr[i]
i++
}
tokens.push(num)
} else {
// 运算符、括号单独处理
tokens.push(c)
i++
}
}
return tokens
}
但这远远不够。 还有三个大坑等着你。
坑1:负号 vs 减号
同一个 - 符号,有两种含义:
5 - 3 → 减号(二元运算符)
-3 → 负号(一元运算符)
(-3) → 负号
如果 2×(-3) 里的 - 被当成减号,调度场会算成 2×0-3=-3,正确答案应该是 2×(-3)=-6。
解决方案:用 'u' 标记一元负号,在 tokenize 阶段区分:
if (c === '-') {
let prev = tokens.length > 0 ? tokens[tokens.length - 1] : ''
// 只有在表达式开头或左括号后面才是负号
if (prev === '' || prev === '(') {
tokens.push('u') // 一元负号
} else {
tokens.push('-') // 减号
}
i++
}
判断逻辑:看 - 前面是什么——空或 ( 则是负号,否则是减号。
注意:有些计算器支持
2×-3这种写法(运算符后也是负号),但小米计算器要求负数必须带括号2×(-3)。所以本文的判断条件只有prev === '' || prev === '('。如果你需要支持2×-3,加上isOperator(prev)即可。
坑2:科学计数法
当计算结果是大数时,JavaScript 会自动转成科学计数法:
String(1e+19) // "10000000000000000000"
String(1e+20) // "1e+20"
问题来了:如果用户按=后结果是 1e+20,再按 +1=,expression 变成 1e+20+1。tokenize 按普通逻辑扫描,会把 e 截断,+ 当运算符,20 当数字——彻底乱套。
解决方案:在 tokenize 的数字读取阶段,检查是否跟着 e/E:
if (isNumberToken(c)) {
let num = ''
while (i < expr.length && isNumberToken(expr[i])) {
num += expr[i]
i++
}
// 支持科学计数法:遇到e/E后面跟+/-和数字
if (i < expr.length && (expr[i] === 'e' || expr[i] === 'E')) {
num += expr[i]
i++
if (i < expr.length && (expr[i] === '+' || expr[i] === '-')) {
num += expr[i]
i++
}
while (i < expr.length && isNumberToken(expr[i])) {
num += expr[i]
i++
}
}
tokens.push(num)
}
这样 1e+20 会被完整地读成一个 token,Number("1e+20") 也能正确解析。
坑3:空括号
3+() 是无效表达式,但如果不检测,toRPN 处理时 ( 和 ) 之间没有内容,evalRPN 会得到 NaN 或 undefined。
解决方案:在 preview 函数里,tokenize 之后遍历 tokens 检测相邻的 ( 和 ):
for (let i = 0; i < tokens.length - 1; i++) {
if (tokens[i] === '(' && tokens[i + 1] === ')') {
return '错误'
}
}
为什么不在字符串层面用
indexOf('()')检测?因为用户可能通过退格键删出空括号,或者有3+( )+2这种带空格的情况。token 级别检测更可靠。
3.2 第二步:中缀转后缀(toRPN)
这是调度场算法的核心。
function toRPN(tokens: string[]): string[] {
let out: string[] = []
let ops: string[] = []
for (let i = 0; i < tokens.length; i++) {
let t = tokens[i]
if (isNumberToken(t)) {
out.push(t)
} else if (t === 'u') {
ops.push(t)
} else if (t === '(') {
ops.push(t)
} else if (t === ')') {
while (ops.length > 0 && ops[ops.length - 1] !== '(') {
out.push(ops.pop()!)
}
ops.pop() // 弹掉左括号
} else {
// 运算符:弹出优先级≥自己的
while (ops.length > 0 && ops[ops.length - 1] !== '('
&& getPriority(ops[ops.length - 1]) >= getPriority(t)) {
out.push(ops.pop()!)
}
ops.push(t)
}
}
// 弹出剩余运算符,跳过残留的左括号
while (ops.length > 0) {
let top = ops.pop()!
if (top !== '(') out.push(top)
}
return out
}
优先级定义:
function getPriority(op: string): number {
if (op === 'u') return 3 // 一元负号优先级最高
if (op === 'x' || op === '/') return 2
return 1 // + -
}
为什么 'u' 优先级最高?看这个例子:
2 × (-3) → tokens: ["2", "x", "u", "3"]
toRPN 处理到 u 时直接入栈,处理到 × 时,× 优先级(2) < u 优先级(3),所以 u 不弹出。最后弹栈时先弹 u 再弹 ×,后缀变成 2 3 u ×,求值时先算 u(3)=-3,再算 2×(-3)=-6。
如果把 u 优先级设低,× 会先弹出,后缀变成 2 3 × u,变成先算 2×3=6 再取负 -6——碰巧结果一样,但遇到 -3+5 就会出问题。
关于残留左括号:如果用户输入 (3+5(没闭合),toRPN 结尾弹栈时 ( 会出现在输出中。加了 if(top !== '(') 就会跳过它,相当于自动忽略未闭合的左括号。UI 层也可以在按=时自动补全右括号。
3.3 第三步:后缀求值(evalRPN)
function evalRPN(rpn: string[]): number {
let stack: number[] = []
for (let i = 0; i < rpn.length; i++) {
let t = rpn[i]
if (isNumberToken(t)) {
stack.push(Number(t))
} else if (t === 'u') {
// 一元负号:弹一个数取反
let val: number = stack.pop()!
stack.push(-val)
} else {
// 二元运算符:弹两个数
let b = stack.pop()!
let a = stack.pop()!
if (t === '+') stack.push(a + b)
else if (t === '-') stack.push(a - b)
else if (t === 'x') stack.push(a * b)
else {
if (b === 0) throw new Error('除数不能为0')
stack.push(a / b)
}
}
}
return stack[0]
}
关键点:
'u'只弹一个数,这就是"一元"的含义- 二元运算符弹两个数,注意顺序:先弹出的
b是右操作数,后弹出的a是左操作数(减法和除法对顺序敏感) - 除以0抛异常,在 preview 函数里 catch 住
四、浮点误差——0.1 + 0.2 ≠ 0.3
这是 JavaScript 的经典问题:
0.1 + 0.2 // 0.30000000000000004
原因:IEEE 754 双精度浮点数无法精确表示 0.1 和 0.2,误差在运算中累积。
解决方案:用 toPrecision() 截断有效位数:
function formatNumber(v: number): string {
let str = String(Number(v.toPrecision(15)))
return str
}
toPrecision(15) 保留15位有效数字(接近双精度浮点数的精度上限16位),然后 Number() 再转回来去掉末尾多余的0。
0.1 + 0.2
→ 0.30000000000000004
→ toPrecision(15) → "0.300000000000000"
→ Number() → 0.3
→ String() → "0.3" ✅
为什么不用12位?因为 9999999999999(13个9)有13位有效数字,toPrecision(12) 会四舍五入成 10000000000000。15位能覆盖大部分日常计算场景。
五、大数处理
JavaScript 的 Number 最大安全整数是 9007199254740991(2^53 - 1),超过这个精度就无法精确表示:
9999999999999999 // 10000000000000000(丢失精度)
这是语言层面的限制,不是算法 bug。超出安全范围后,结果自动转为科学计数法显示(如 1e+16),这是合理的降级策略。
六、输入校验(UI 层配合)
算法引擎只是半壁江山,UI 层的输入校验同样重要。以下校验在 handleButtonPress 中实现:
6.1 连续运算符替换
按 + 再按 ×,不应变成 3++5,而应替换成 3×5:
if (isOperator(label) && isOperator(last)) {
this.expression = this.expression.slice(0, -1) + label
}
6.2 空表达式按运算符补0
空表达式按 +,自动变成 0+:
if (isOperator(label) && this.expression === '') {
this.expression = '0' + label
}
6.3 右括号配对检查
只允许在左括号数量 > 右括号数量,且上一个字符是数字或右括号时输入 ):
if (label === ')') {
let leftCount = this.expression.split('(').length - 1
let rightCount = this.expression.split(')').length - 1
let last = this.expression[this.expression.length - 1]
if (leftCount > rightCount && (last >= '0' && last <= '9' || last === ')')) {
this.expression += label
}
return
}
6.4 小数点去重
同一个数字段内只允许一个小数点。从末尾往回找到运算符或括号,检查当前数字段是否已有 .:
if (label === '.') {
let j = this.expression.length - 1
while (j >= 0 && !isOperator(this.expression[j])
&& this.expression[j] !== '(' && this.expression[j] !== ')') {
j--
}
let curNum = this.expression.substring(j + 1)
if (curNum.indexOf('.') >= 0) return // 已有小数点
this.expression += label
return
}
6.5 按等号时的处理
- 右括号 > 左括号 → 显示"错误"
- 空括号
()→ 显示"错误" - 末尾有多余运算符 → 去掉再算
- 左括号 > 右括号 → 自动补全右括号
// 去掉末尾运算符
while (expr.length > 0 && (isOperator(expr[expr.length - 1]) || expr[expr.length - 1] === '.')) {
expr = expr.slice(0, -1)
}
// 自动补全右括号
while (leftCount > rightCount) {
expr += ')'
leftCount--
}
七、实时预览的实现
小米计算器的体验:用户每按一个键,预览区就显示实时计算结果。用 @Watch 装饰器实现:
@State @Watch('onExpressionChange') expression: string = ''
onExpressionChange() {
if (this.expression === '') {
this.result = '0'
return
}
let last = this.expression[this.expression.length - 1]
// 表达式没输完(末尾是运算符、左括号、小数点),保持旧结果
if (isOperator(last) || last === '(' || last === '.') {
return
}
// 自动补全右括号后计算
let expr = this.expression
let left = this.expression.split('(').length - 1
let right = this.expression.split(')').length - 1
while (left > right) {
expr += ')'
left--
}
try {
this.result = preview(expr)
} catch (e) {
this.result = '错误'
}
}
关键点:自动补全右括号再预览。用户输入 (3+5 时,预览应该显示8,而不是等右括号闭合才出结果。
八、完整流程图
用户按键
│
▼
handleButtonPress()
│ 输入校验(运算符替换、括号配对、小数点去重等)
│
▼
expression 更新
│
▼
@Watch 触发 onExpressionChange()
│ 自动补全右括号
│
▼
preview(expression)
│ tokenize → toRPN → evalRPN → formatNumber
│
▼
result 更新 → UI 自动刷新
九、测试用例
| 输入 | 期望结果 | 测试点 |
|---|---|---|
1+2 |
3 | 基本加法 |
2+3×4 |
14 | 乘法优先级 |
(1+2)×3 |
9 | 括号 |
(-3)+5 |
2 | 一元负号 |
2×(-3) |
-6 | 负号优先级 |
0.1+0.2 |
0.3 | 浮点误差 |
5/0 |
不能除以0 | 除零 |
(3+5 |
8 | 自动补右括号 |
3+5) |
错误 | 多余右括号 |
3+() |
错误 | 空括号 |
3.5.5 |
无法输入 | 小数点去重 |
3+- |
3 | 末尾运算符截断 |
1e+20+1 |
1e+20 | 科学计数法续算 |
十、总结
| 问题 | 解决方案 |
|---|---|
| ArkTS 禁止 eval | 调度场算法:中缀→后缀→求值 |
| 负号 vs 减号 | tokenize 阶段用 'u' 标记一元负号,'u' 优先级最高 |
| 科学计数法 | tokenize 识别 e/E 后跟 +/- 和数字 |
| 浮点误差 | toPrecision(15) 截断 |
| 大数精度 | 语言限制,科学计数法降级显示 |
| 空括号 | token 级别检测相邻 ( ) |
| 未闭合左括号 | toRPN 结尾跳过残留 (;UI层自动补全右括号 |
调度场算法本身是 1961 年的经典表达式解析算法;市面上仅有少量 ArkTS 基础调度场 Demo,能同时兼容一元负号、科学计数法、并配套浮点误差消除、适配 ArkTS 强类型严格模式的完整实现十分稀缺,公开可复用代码案例寥寥无几。希望这篇文章能帮到正在做 HarmonyOS 计算器的开发者。
环境:HarmonyOS NEXT API 12+ / ArkTS 严格模式 / DevEco Studio 5.0
完整源码:GitHub - calculate
更多推荐

所有评论(0)