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):临时存放运算符

从左到右扫描中缀表达式:

  1. 遇到数字 → 直接输出到 out
  2. 遇到运算符 → 和栈顶比较优先级
    • 栈顶优先级 ≥ 当前 → 弹出栈顶到 out
    • 否则 → 当前运算符入栈
  3. 遇到左括号 → 入栈
  4. 遇到右括号 → 弹栈到 out,直到遇到左括号
  5. 扫描结束 → 弹出栈中所有剩余运算符

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" 你不能把 12 当成两个数,必须拼成 "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 按等号时的处理

  1. 右括号 > 左括号 → 显示"错误"
  2. 空括号 () → 显示"错误"
  3. 末尾有多余运算符 → 去掉再算
  4. 左括号 > 右括号 → 自动补全右括号
// 去掉末尾运算符
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

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐