做到第71个练手项目,我特别想聊这个四则运算演示器。HarmonyOS的应用实例不难搜,很多都套路化,但这个题目的价值在于它并不只是写一个计算器,而是把小学数学里最容易搞混的“先乘除后加减,有括号先算括号”规则,在ArkTS里做成了可视化逐步演示。它解决了一个实际痛点:光告诉结果还不够,必须把每一步运算顺序、每一步优先级怎么变化都展示出来。如果你已经会写基础ArkUI页面,想找一个既有界面又有算法逻辑的HarmonyOS项目来练手,这个题目很对口味。
1. 项目定位:它和普通计算器到底有什么不一样
1.1 从“计算器”到“运算顺序演示器”的思维转变
如果只是做一个能算四则运算的应用,HarmonyOS里有现成的组件和逻辑可以直接套,输入表达式、点击、出结果,三分钟就搞定了。但“运算顺序演示器”这个定位不一样,它要求的不只是结果,而是要把中间过程完整呈现出来。
举个例子,表达式2 + 3 × 4 - 8 ÷ 2,普通计算器直接给出10就完事了,但演示器需要说明:
- 第一步先算
3 × 4,得到12 - 第二步再算
8 ÷ 2,得到4 - 第三步计算
2 + 12 - 第四步计算
14 - 4 - 最终结果是
10
这个差异决定了整个项目的设计核心:计算引擎必须能够把一次完整运算拆解成多步,并且每一步都要记录“我算的是哪个子表达式”、“结果是什么”、“当前整个表达式长什么样”。这是一个典型的“过程展示型”应用,计算能力反而是次要的,重要的是顺序逻辑。
1.2 适合什么人、练什么能力
这个实例适合两类人:
- 刚学完HarmonyOS基础组件、想深入理解状态管理细节的开发者。项目里会频繁用到
@State、@Prop、ForEach,以及数组替换不刷新视图这类经典坑。 - 想给孩子做数学辅助工具、顺带练练手感的家长开发者。把运算规则做成演示器,比单纯刷题直观得多,孩子可以看到每一步高亮、每一步结果。
从技术覆盖面上看,这个项目几乎不依赖第三方库,核心代码就是“表达式解析 + 步骤生成 + UI绑定”。难点在于把解析算法讲清楚,然后配合ArkTS的响应式状态让步骤逐条刷新出来。这比做几十个静态列表页面要扎实得多。
1.3 功能拆解
我在开发前把功能拆成了四块:
- 表达式输入区:支持数字、加减乘除、括号输入,允许修改和清空。
- 校验与容错:表达式不完整、括号不配对、连续运算符要能给出友好提示。
- 逐步运算引擎:把表达式转换成一系列带步骤说明的计算过程。
- 步骤展示区:用列表展示每一步,同时高亮正在计算的局部表达式。
这四块听起来简单,但每一步都有隐藏问题。比如“高亮当前计算片段”,就涉及原始表达式字符串和当前步骤之间的位置映射,处理不好就高亮错地方。
2. 运算顺序规则与技术选型思路
2.1 优先级、结合性和括号的完整处理
做这个项目之前,必须先明确四则运算的完整规则,不是只记一句话就够的:
- 括号优先,从最内层括号开始计算
- 乘号和除号优先级相同,同级从左到右依次计算
- 加号和减号优先级相同,同级从左到右依次计算
- 减号可以当成负号处理,但这个项目我先不做负号,避免解析复杂化
这里最容易出问题的就是“同级从左到右”。很多人实现计算器时只处理了优先级,没有处理结合性,结果8 ÷ 4 ÷ 2会算出错误答案。正确结果是1,因为要先把8 ÷ 4算成2,再用2 ÷ 2得到1;如果从右往左算就会得到4。
我在设计引擎时,把运算符定义为一个枚举,每个运算符带两个属性:优先级和方向。优先级用数字表示,数字越大越先算;方向表示同级时从左还是从右处理。加减乘除都是左结合,不需要特殊处理。
2.2 为什么选用“中缀转后缀”而不是递归解析
表达式3 + 4 × 2这种写法叫中缀表达式,人类看着舒服,但程序处理顺序很麻烦。常见方案有两种:
- 递归下降解析:直接处理中缀表达式,通过递归函数识别优先级,代码直观但分支多。
- 调度场算法:先把中缀表达式转换成后缀表达式,后缀表达式也叫逆波兰式,然后依次压栈计算,顺序天然和运算法则一致。
我最终选择了调度场算法,原因很实际:它完美契合“逐步演示”的需求。调度场算法的输出序列本身就体现了运算顺序,比如3 + 4 × 2转成后缀是3 4 2 × +,一眼就能看出要先乘法后加法。后续步骤展示就是把后缀表达式的求值过程翻译成文字。
有人可能会问,递归解析是不是更简单?对于嵌套括号很深的表达式,递归代码确实不难写,但它不好记录“每一步发生了什么”。而调度场算法天然带着栈操作过程,每一步都有迹可循。要把运算过程展示给用户,选这种结构化算法更省劲。
2.3 步骤数据模型设计
要让步骤展示有条理,数据模型必须先定清楚。我在项目里定义了一个StepRecord类:
class StepRecord { // 当前步骤的说明文字 description: string; // 参与这一步运算的两个操作数的值 leftValue: number; rightValue: number; // 运算符 op: string; // 这一步的结果 result: number; // 在当前表达式文本中的起始位置 startIndex: number; // 在当前表达式文本中的结束位置 endIndex: number; // 计算完这一步之后,整个表达式的文本快照 snapshot: string; }这个模型里最关键的是startIndex和endIndex。它们用于在原始表达式里高亮当前正在计算的片段。比如原始表达式2 + 3 × 4,在算3 × 4这一步时,高亮范围应该从字符串第4位到第8位。没有这两个字段,高亮功能就只能靠猜。
snapshot字段则用于展示“当前表达式变为什么样了”。每算完一步,我都把新的表达式字符串存下来,这样步骤列表里可以直接显示中间化简结果,不需要用户自己脑补。
3. HarmonyOS界面拆解与交互布局
3.1 页面整体结构设计
在ArkUI里做这个页面,我用的是Column嵌套滚动布局,整体分三层:
- 顶部:表达式展示区,用大号字体显示当前输入的表达式,和一个“演示”按钮。
- 中部:步骤结果区,用List或Scroll组件展示计算步骤。
- 底部:数字和运算符按钮区。
之所以把输入区放在顶部、按钮放在底部,是为了符合手机单手握持习惯。孩子用的时候拇指最容易够到的是底部按键,展示区在整个页面中段,视线也更集中。
我用滚动列表Scroll而不是List来做步骤区,因为步骤数量通常不超过几十条,滚动需求简单,Scroll加ForEach就够用,性能开销也小。
3.2 按键布局与输入限制
按键区我用了四行:
7 8 9 ÷ 4 5 6 × 1 2 3 - 0 . C +还有一个单独的全宽按钮放“括号”和“演示”。这里要注意,÷和×在字符串里直接保留原符号,但在解析时要做映射,把÷映射成除法运算符,把×映射成乘法运算符,不要真的用文本里的unicode参与计算。
我还加了一层输入限制逻辑:运算符后面不能直接跟另一个运算符,除非是负号场景;小数点不能重复输入;左括号必须和右括号配对。这些限制用onClick或onChange事件拦截都行,我用的是在按钮点击处理函数里统一判断,这样所有入口都走同一套规则。
3.3 高亮和步骤联动的实现思路
步骤高亮是这个演示器的灵魂。我的实现方式是:
- 解析步骤数据时,记录每个步骤在原始表达式中的位置区间
[startIndex, endIndex]。 - 在表达式文本显示组件中,把普通文本和需要高亮的文本拆成多个
Span子组件。 - 根据当前选中的步骤索引,动态计算哪些
Span需要用不同颜色显示。
在ArkTS里,可以使用Text组件的Span子组件实现单段混排颜色,例如:
Text() { ForEach(this.textSegments, (segment: TextSegment) => { Span(segment.text) .fontSize(24) .fontColor(segment.isHighlighted ? Color.Red : Color.Black) .fontWeight(segment.isHighlighted ? FontWeight.Bold : FontWeight.Normal) }) }这里有个地方要特别注意:原始表达式里每个字符都要放进对应的TextSegment数组里,哪怕一个连续的数字串也要按“是否落入高亮区间”拆成多个片段,不能直接按空格切分,否则高亮位置会对不上。
4. 核心代码实现详解
4.1 表达式合法性校验
计算前先校验,这一步不能省。我写的校验函数做了三件事:
- 括号配对:遇到左括号入栈,右括号出栈,最后栈必须是空的。
- 运算符位置:表达式开头不能是运算符(负号场景先跳过),结尾不能是运算符。
- 连续运算符:出现
× +这类连续运算符要报错。
以括号校验为例,思路很简单:
function checkBrackets(expr: string): boolean { let stack: string[] = []; for (let ch of expr) { if (ch === '(') { stack.push(ch); } else if (ch === ')') { if (stack.length === 0) { return false; } stack.pop(); } } return stack.length === 0; }这里我把“括号配对”和“表达式位置”分开写,因为错误提示需要区分是“括号不配对”还是“输入不完整”,合并写虽然省代码,但用户看到的提示会很不友好。
4.2 表达式拆分:数字、运算符、括号
字符串表达式不能直接逐字符丢给后续算法,得先拆成Token列表。常见的坑是数字有多位,比如123必须被识别成整体;小数点也要跟数字一起归入同一个Token。
下面这段是我项目里的拆分逻辑核心:
function tokenize(expr: string): Token[] { let tokens: Token[] = []; let i = 0; while (i < expr.length) { let ch = expr[i]; if (isDigit(ch) || ch === '.') { let start = i; while (i < expr.length && (isDigit(expr[i]) || expr[i] === '.')) { i++; } let numStr = expr.substring(start, i); tokens.push({ type: TokenType.NUMBER, value: parseFloat(numStr), rawText: numStr, startIndex: start, endIndex: i - 1 }); continue; } if (ch === '+') { tokens.push({ type: TokenType.PLUS, value: 0, rawText: ch, startIndex: i, endIndex: i }); i++; continue; } // 减号、乘号、除号、左括号、右括号同理 } return tokens; }这里每个Token都保存了startIndex和endIndex,这部分数据是后面高亮功能能精确工作的基础。如果只在Token里存数字和运算符类型,后续就追查不到原始位置了。我就是因为第一版没记录位置,后来不得不重写了一次。
4.3 调度场算法:中缀转后缀
调度场算法的核心是维护一个运算符栈。遍历Token时,数字直接进入输出队列;运算符则要跟栈顶运算符比较优先级,如果栈顶优先级更高或相同,就先弹出栈顶进输出队列,再把当前运算符压栈;遇到左括号直接压栈,遇到右括号则一直弹栈,直到碰到左括号。
用2 + 3 × 4举例:
- 读到
2,直接输出 - 读到
+,栈空,压栈 - 读到
3,直接输出 - 读到
×,比较:栈顶是+,×优先级更高,所以×压栈 - 读到
4,直接输出 - 结束后弹出栈中所有运算符
这样就得到后缀表达式2 3 4 × +。
ArkTS代码里我就是按这个逻辑写的,注意数组的pop()和push()方法可以直接复现栈操作。不需要自己实现栈类,直接用Array<string>就行。
4.4 后缀表达式求值并生成演示步骤
得到后缀表达式之后,求值阶段就是专门为“演示”服务的。每次从后缀表达式里取出一个数字时压入数字栈;取出运算符时,从数字栈弹出两个数字,按顺序计算,然后生成一条StepRecord。
这里要处理一个最关键的细节:表达式的原始展示文本和计算顺序之间的对应关系。后缀表达式里数字和运算符都已经丢失了原始顺序,所以我需要额外维护一个映射关系,在解析每种Token时就把它们指向的原始位置保存下来。
我的处理方式很简单:把每个Token都拷贝一份到“求值队列”里,这个队列元素除了类型和数值,还带着它在原始表达式里的开始和结束位置。每次执行一个运算符时,读到的就是这个运算符Token以及它关联的操作数Token的位置信息,这样生成的步骤说明可以精确指向原始表达式中的某一段。
function evaluateToSteps(postfix: Token[]): StepRecord[] { let numStack: number[] = []; let records: StepRecord[] = []; for (let token of postfix) { if (token.type === TokenType.NUMBER) { numStack.push(token.value); } else { let right = numStack.pop()!; let left = numStack.pop()!; let result = applyOp(left, right, token.type); // 记录这一步对应的原始位置 records.push(buildStepRecord(left, right, token, result)); numStack.push(result); } } return records; }注意applyOp里除法要判断除数为零的情况,我在项目里专门弹了个提示:“除数不能为0”。虽然在教育场景里这是个好教学点,但应用不能崩溃。
4.5 演示过程的节奏控制
步骤列表一次性生成之后,怎么播放也有讲究。我提供了两种模式:
- “一键出步骤”:所有步骤同时生成,点击“演示”后全部显示。
- “逐步播放”:按时间间隔逐条显示,模拟老师在黑板上一步步写。
逐步播放我在实现时用了setInterval,每500毫秒往步骤数组里追加一条记录。但这里有个ArkTS状态管理的大坑:数组元素的添加不一定触发视图刷新。我踩了好几次,后面专门用了一个@State currentStepCount: number来驱动显示:
@State displayedStepCount: number = 0; startPlay() { let timer = setInterval(() => { if (this.displayedStepCount >= this.steps.length) { clearInterval(timer); return; } this.displayedStepCount++; }, 500); }UI里渲染步骤时只取前displayedStepCount条,这样状态改变一定会触发重新渲染,方式笨但绝对稳。如果你只把this.steps.push(record)写在定时器里而不改变任何其他@State变量,界面很可能一动不动。
5. 实操踩坑记录:状态、动画与输入法的那些事
5.1 ArkTS数组更新不刷新视图
这个问题可以说是HarmonyOS应用开发里最高频的坑之一。很多人写了this.dataSource.push(item)之后发现列表不更新,原因是@State监听的是引用变化,而不是原地修改。虽然某些场景下框架会深度观察,但依赖这种边缘行为太危险了。
我的解决方案很简单:每次修改数组后,重新赋值一个新数组引用。例如:
let newList: StepRecord[] = this.steps.slice(); newList.push(record); this.steps = newList;这样视图刷新响应非常稳定。如果你用装饰器@Observed和@ObjectLink去跟踪复杂的类对象,也能解决,但对这种简单数据,我更推荐直接替换整个数组,代码更直观,行为也更好预测。
5.2 高亮位置偏移与文本拆分
这个坑非常隐蔽。原始表达式经过Token拆分后,我记录了每个Token的起止位置,但用户输入的表达式里可能掺杂了空格。我在输入层限制了用户不能输入空格,不过从别处复制粘贴就可能带进来。空格会导致字符位置和下标的计算差一位到两位,高亮就会偏。
处理办法是入库前先做一次“净化”,把所有空格、不可见字符统一剔除:
expression = expression.replace(/\s/g, '');如果你不想剔除空格,就要在计算高亮区间时做偏移映射。我权衡之后选择了剔除,因为作为教学演示器,空格本来也不影响语义。
5.3 软键盘弹出遮挡按键区
页面底部是按钮区,在真机上点击输入框编辑表达式时,软键盘弹出会遮挡底座。原因很简单:页面高度没有随着键盘调整。
HarmonyOS里比较直接的处理是用expandSafeArea或者设置页面的键盘避让模式。我在项目里用了window.setWindowInputMode调整默认输入模式,设置为adjustResize,让页面能自动收缩高度,按键区就能跟着上移。
不过还要注意,不要给底部按钮列设置固定高度height,尽量用layoutWeight或安全区适配,否则键盘弹出来时按钮区会和键盘叠在一起。
5.4 逐步播放时高亮闪烁
逐步播放时,每一条新步骤出现,用户希望当前步骤的表达式高亮能跟着变化。我的实现是在步骤列表当前展示的索引变化时,重新计算textSegments,并把新数组状态赋值回去。
高亮闪烁的bug出在我没设置过渡动画,导致每次重新生成文本段数组时,整个表达式都被强制刷新一次,看起来就像闪了一下。解决方式很直接:要么不加动画,要么用animateTo做颜色变化的平滑过渡。我最终选择不加动画,只改变字体颜色和粗细,这样在教育场景里更利索,不会因为动画干扰视线。
另外,如果用户点击任意一条历史步骤,我希望高亮能跳转到对应位置。这个我用了一个@State只存当前选中步骤的索引,点击列表项时动态更新,因为它本身就驱动了文本段数组重建,所以联动是自动化完成的,不需要额外刷一次UI。
6. 表达式校验的几个边界情况
6.1 括号嵌套与非法输入
我实际测试时发现,用户最容易输入的非法表达式其实是())重复括号和2 + (3 × 4这种漏右括号的情况。前者我在括号配对检查里直接拦截,后者在表达式结尾也会被判为非法。
但有个边界容易被漏掉:2 + (3 × 4)),这种表达式括号总数是配对的,但右括号数量在中间某个位置超过了左括号的累计数量。如果只是简单数左右括号数量,会被当成合法。所以括号校验必须用“遇到左括号计数加一,遇到右括号计数减一,中途出现负数就非法”的方式,不能只看最终计数。
6.2 除数为零和重复小数点
除数为零在演示步骤里如何表现,我做了个特殊设计:不直接报错,而是在步骤里用文字展示“这里不能除以0,所以这个表达式不成立”,并且终止后续计算。这个处理的逻辑是:教育应用应该解释为什么非法,而不是只给一个NaN结果。
重复小数点1.2.3在Token解析阶段就会出问题,我会在解析时判断一个数字字符串内的小数点数量是否大于1,如果大于1就返回一个错误码,提示用户格式不正确。
6.3 形如2 × ÷ 3的连续运算符
连续运算符会在解析过程中被识别。我在输入限制之外,还给解析器加了一层容错:如果刚解析了一个运算符,紧接着又出现另一个运算符且中间没有数字,就判断当前Token类型不合法。这样即使在极少数绕过输入限制的情况下,引擎也不会崩溃,而是输出友好的错误提示。
这个双保险很重要。输入限制是针对人手点击按钮设计的,如果用户用输入法从剪贴板粘贴表达式,就能绕过按钮点击逻辑,所以解析器本身必须可靠。
7. 后续扩展方向与个人经验
7.1 可以扩展成“分步讲解器”
做完这个演示器之后,我其实产生了更多想法。最直接的一个是把它扩展成“步骤详解器”,不仅仅是演示运算顺序,还可以在每一步下面追加讲解文字,比如“为什么这里要先算乘法?因为乘法优先级比加法高,括号以外的乘除要先于加减计算”。
这个扩展需要增加一个“讲解词生成器”,在生成步骤时根据运算符类型附上对应的规则说明。代码层面改动不大,只是数据模型里加一个explanation字段。
如果你想让这个应用更适合儿童教育,我建议把颜色做得更大胆一些,比如用高亮色块圈住正在计算的部分,点击步骤条可以高亮到具体数字。孩子看到“原来先算的是这一段”比大人看到结果更有意义。
7.2 从项目里得到的实际体会
开发这个应用时,最大的收获不是掌握了调度场算法,而是理解了“状态管理 + 计算过程”之间如何配合。一个计算器的功能可以靠一次性计算解决,但一旦涉及过程展示,就必须把日志记录、变量状态、UI刷新全部编排好。
如果你准备动手复刻这个实例,我建议从“不带动画的单机版本”开始,先把计算引擎和步骤生成跑通,再美化界面。直接一开始就追求动画和视觉效果,卡壳之后很难定位是解析问题还是渲染问题。
还有一点经验可以分享:程序里的步骤记录不要只为了人看而写,要给每一个步骤配上“机器可读”的字段(比如原始位置区间、参与运算的左右值),后续做高亮、做撤销、做跳转都有用。只输出一句人话,后面想重新计算就抓瞎了。
这个项目现在还在我的练习列表里,下一步我准备把它移植成一个HarmonyOS的卡片服务,让用户不用打开应用就能看到上一条表达式运算过程。做卡片时数据绑定又会有新的坑,到时候再跟你们聊聊。