最近在折腾一套HarmonyOS应用实例正好做到第44个主题是“万以内的加减法竖式计算器”。这名字听起来像教育类Demo实际上手之后才发现它真正难的不是加减法本身而是怎么把“进位”“借位”这种隐性过程用有限的ArkUI组件清晰表达出来同时还得让孩子愿意按。做完这个项目我对状态管理、自定义组件拆解、以及竖式对位的视觉细节都有了新的理解这里把完整思路、代码和踩坑记一次聊透。如果你正准备练手鸿蒙开发或者想给孩子做个学习工具这个项目很适合参考它不需要复杂后台纯前端交互一个人就能完成。核心难点集中在三件事——竖式数据怎么建模、进位借位标记怎么算、用户逐位输入怎么判对错。下面从需求拆解讲起边讲原理边给代码。1. 从课本到屏幕竖式计算器的需求拆解1.1 竖式背后的数学逻辑竖式计算本质上是把多位数运算拆成“按位相加/相减”并从低位向高位逐位推进。加法里最经典的“满十进一”比如 345 678个位 5813所以要写 3向十位进 1十位 47112写 2 向百位进 1百位 36110写 0 向千位进 1最终是 1023。减法里则是“不够减向高位借一当十”比如 602 - 347个位 2 不够减 7向十位借 1变成 12-75但十位原本的 0 被借走后就变成了 9 再参与后续计算。这些规则对大人来说已经是条件反射但对小朋友来说最大的障碍不是记不住而是步骤太多容易漏。传统纸笔练习里孩子经常出现两种情况一是进位“1”写在哪里不统一二是借位之后高一位数字忘了减 1。竖式计算器这个应用要做的就是把每一步的“临时状态”显性展示出来——进位写在横线上方借位在对应位做标记孩子只需要按位填入结果数字应用实时判断是否填对。1.2 孩子练习竖式的常见痛点我先是拿身边两个小学生做了摸底发现了几个特别典型的场景第一个痛点是对位问题。数字位数参差不齐时孩子容易把个位和十位搓开导致“345 78”被算成 34 加 78。竖式布局天然解决这个问题因为每一位都有固定的格子个位对个位、十位对十位视觉上有强约束。第二个痛点是进位和借位的“隐形”。很多孩子在纸上写的竖式是干净的但心里清楚进位过程却不愿意写出来结果一旦算错很难自查。如果应用能自动标出“这里有进位”“这里需要借位”孩子就不得不面对这个步骤实际上是在强制建立计算习惯。第三个痛点是即时反馈缺失。作业本上做一整页竖式错了要等家长批改反馈链路太长。我在交互设计中把反馈缩短到“每填一位立即判断”填对了给绿色高亮填错了给红色抖动或重新输入这样孩子能马上知道哪一步有问题不会把错误动作重复很多遍。1.3 功能范围与页面规划基于上面的观察这个竖式计算器需要具备这样几个能力支持加法、减法运算范围控制在 1 到 9999 之间也就是“万以内”。题目随机生成但减法必须保证被减数大于等于减数避免出现负结果这是小学常规题型。竖式界面需要明确展示被加数/被减数、加数/减数、横线、结果区以及进位标记和借位标记。用户不是一次性填写整个结果而是按位逐格填写模拟在纸上写竖式时的从左到右实际上是从个位向高位思考顺序。每填一位立即校验全对后给出完成反馈。我最后把应用规划为两个页面首页是设置页选择运算类型和题目数量答题页是核心页面展示竖式并处理交互。为了让家长也能用还加了一个简易统计记录本次练习的正确率和耗时但这部分不是核心可以放后面慢慢做。2. ArkUI下的数据模型与界面骨架2.1 用类模型描述一道竖式题在把竖式画到屏幕上之前得先把一道题在内存里建模。我的做法是定义两个类Exercise描述一道完整的题DigitCell描述竖式中单独一个格子。class DigitCell { value: number 0; // 正确答案的数值 input: string ; // 用户输入的内容 isCorrect: boolean false; // 当前是否已判定正确 needCarry: boolean false; // 该位是否存在进位/借位标识 carryValue: number 0; // 进位值或借位标记加法用1减法用-1来表示借位 } class Exercise { isAdd: boolean true; // 加法还是减法 top: number[] []; // 上方数字按位拆分个位在前 bottom: number[] []; // 下方数字 result: number[] []; // 正确结果 cells: DigitCell[] []; // 结果区的每个格子 carries: number[] []; // 每位进位/借位状态用于显示标记 }这里有个关键设计所有数组都采用“个位在前”的顺序也就是 index 0 对应个位index 1 对应十位以此类推。这样从低位向高位逐位计算时循环就是从 0 走到数组末尾非常自然。展示的时候再倒序渲染正好把高位放在左边。选择用数组而不是直接用整数是因为竖式运算必须访问每一位整数的取模运算虽然能做但涉及进位时反复修改位值很繁琐数组更直观。后续生成竖式布局时直接遍历数组就行。2.2 页面状态三个数组控制整道题在答题页里我使用了State装饰器管理一个Exercise对象。ArkUI 的页面状态更新遵循“状态改变驱动 UI 刷新”的原则只要修改的是State装饰的顶层对象组件就能重新渲染。不过这里有一个很容易踩的坑Exercise对象内部嵌套了cells数组数组里又是DigitCell对象。如果直接修改this.exercise.cells[0].input 5ArkUI 的状态监听可能不会触发刷新因为被修改的是深层嵌套属性。后面我会专门讲这个问题这里先说明我采用的简化方案——顶层用State exercise: Exercise new Exercise()每次更新时整体替换exercise对象或者对cells数组做浅拷贝后重新赋值。页面的整体状态大致如下exercise当前整道题的数据。currentIndex当前正在输入的是第几位用于输入框聚焦和自动跳位。questionCount本次练习的题目总数。currentNumber当前做到第几题。correctCount答对的题目数。UI 结构上我用一个Column作为根容器里面依次放标题栏、题目展示区、竖式卡片、数字键盘区。竖式卡片是核心我用Row嵌套实现每一行保证每一列对齐。2.3 竖式布局的排列技巧竖式在纸面上的经典排布是上方数字与下方数字右对齐然后画一条横线结果写在横线下面。在 ArkUI 里做成“每一列宽度固定”是最省事的做法。我先把竖式可能出现的最大位数定为 4 位千位到个位实际显示时根据题目最大位数动态决定渲染多少列。每一列我用一个固定宽度的Column子组件比如宽度设为 64vp高度也是 64vp里面放数字、进位标记、输入框。整行由多个这样的列横向排列自然就实现了对齐。这里有个视觉细节要特别注意数字在列中的位置必须居中但进位标记要稍微偏上。我实现了这样一个自定义组件DigitColumn输入参数是这一位对应的数字、进位标记、输入框状态等Component struct DigitColumn { Prop digit: number -1; Prop marker: string ; Prop isInput: boolean false; Prop value: string ; Prop correct: boolean false; State inputText: string ; build() { Column({ space: 4 }) { Text(this.marker) .fontSize(14) .height(20) .fontColor(this.marker . || this.marker 1 ? #E84026 : #FFFFFF) Text(this.digit 0 ? this.digit.toString() : ) .fontSize(24) .fontWeight(FontWeight.Bold) .height(32) if (this.isInput) { TextInput({ text: this.value }) .type(InputType.Number) .textAlign(TextAlign.Center) .fontSize(22) .width(52) .height(40) .backgroundColor(this.correct ? #E8F5E9 : #FFFFFF) .border({ width: 1, color: this.correct ? #4CAF50 : #CCCCCC, radius: 6 }) } else { Text() .width(52) .height(40) } } .width(64) .justifyContent(FlexAlign.Start) } }单独抽组件的好处是后续在答题页ForEach渲染时代码很干净。而且进位标记和数字的上下间距我只在组件内部调整不用动外层布局。3. 运算逻辑与交互实现3.1 加法进位逻辑的实现加法逻辑是整个应用最核心的数学部分。我要先生成正确的竖式结果同时还要记录每一位是否有进位因为进位标记是要显示在界面上的。具体做法是先把两个加数按位拆成数组比如top [5, 4, 3]表示 345bottom [8, 7, 6]表示 678。然后从个位开始循环function generateAddExercise(a: number, b: number): Exercise { let ex new Exercise(); ex.isAdd true; ex.top splitDigits(a); ex.bottom splitDigits(b); let maxLen Math.max(ex.top.length, ex.bottom.length); let result: number[] []; let carries: number[] []; let carry 0; for (let i 0; i maxLen; i) { let sum (ex.top[i] || 0) (ex.bottom[i] || 0) carry; if (sum 10) { carries.push(1); // 有进位 result.push(sum - 10); carry 1; } else { carries.push(0); result.push(sum); carry 0; } } if (carry 0) { carries.push(1); result.push(carry); } ex.result result; ex.carries carries; // 后续生成 cells return ex; }这个循环的关键点是carry会参与下一位计算而且最高位计算完后如果还有进位需要额外再push一位。比如 999 1 1000原本最大位数是 3最终结果是 4 位这一步必须补上。我第一次做的时候没想到直接导致结果少了最高位的 1。进位标记的显示位置也很讲究在纸笔竖式里进位“1”通常写在横线上方、下一列靠近中间的位置。我在DigitColumn里单独留了一个marker区域这一位有进位时显示1没有时为空。3.2 减法借位逻辑的实现减法逻辑比加法麻烦一点因为借位涉及“高位被借走之后要减 1”而这个变化会连锁影响后续的位。我的实现是维护一个borrow变量表示当前位是否被借位过同时维护一个borrowed数组记录每一位是否发生借位。function generateSubExercise(a: number, b: number): Exercise { // 调用前保证 a b let ex new Exercise(); ex.isAdd false; ex.top splitDigits(a); ex.bottom splitDigits(b); let maxLen Math.max(ex.top.length, ex.bottom.length); let result: number[] []; let borrowed: number[] []; let borrow 0; for (let i 0; i maxLen; i) { let topDigit ex.top[i] || 0; let bottomDigit ex.bottom[i] || 0; topDigit - borrow; // 上一位借走的影响 if (topDigit bottomDigit) { topDigit 10; borrowed.push(1); borrow 1; } else { borrowed.push(0); borrow 0; } result.push(topDigit - bottomDigit); } // 去除结果最高位的多余 0 while (result.length 1 result[result.length - 1] 0) { result.pop(); borrowed.pop(); } ex.result result; ex.carries borrowed; return ex; }这里最容易出错的是borrow的传递。比如 602 - 347个位 2 7要从十位借十位原本是 0它自己不够借只能再向百位借。上面的循环在十位的判断里topDigit先被减去上一位的借位变成 -1然后-1 4所以它又要加 10同时继续向百位借。这样逻辑上是可以正确算出结果的但界面上的借位标记需要额外处理十位既被别人借走自己又往下借标记应该显示在十位和个位之间。我在实际实现里简化了一下只在与个位相邻的减数上方显示一个圆点代表“这一位发生借位”高位的借位隐含在连续圆点中。这个视觉简化对低年级孩子已经足够。3.3 随机出题的边界控制随机出题看似简单其实坑不少。第一个坑是减法不能出现负数第二个坑是要把“万以内”这个范围精确落到数字生成上。我的生成逻辑是加法随机生成两个 1~9999 之间的数不限制谁大谁小因为加法结果最大是 19998已经超过“万以内”了但这其实不太符合练习范围。我后来将两个加数的位数限制在“结果不超过 9999”或“每个加数不超过 9999”题目更合理。最终做法是限定两个加数都在 100~9999 之间而且它们的和的位数不超过 4 位。减法先随机生成被减数a100~9999再生成减数b范围是 1~a这样天然保证a b且结果大于等于 0。这还不够题目需要有一定的层次感。如果能控制结果的位数就能区分难度。我在设置页加了一个“难度”选项简单模式下两个数位数相同且都为 1~2 位数困难模式放宽到 4 位数。这样同一套应用可以适应不同年级的孩子。3.4 输入框联动与实时反馈竖式输入交互是我觉得最有价值的部分。传统做法是让孩子在一个 TextInput 里输入最终结果但那失去了“逐位计算”的意义。我的方案是结果区的每一位都是一个独立的 TextInput初始为空点击输入一个数字后应用立即判断这一位是否等于正确结果然后自动跳到下一位。这里涉及两个细节第一个是“按什么顺序聚焦”。竖式计算的顺序是从个位开始所以结果区最右边一个格子应该首先成为焦点。我在页面加载完成后调用focusControl.requestFocus指定的参数是最后一个格子的 key。用户输完一位手动把焦点移到左边一位。这样模拟了从低位算到高位的自然过程。第二个是“判定时机”。我监听了每个格子的onChange事件当输入长度变为 1 时立即和result[i]比较。如果正确把cells[i].isCorrect设为 true格子变绿同时自动跳到下一位如果错误格子变红并清空输入等用户重新输入。这个即时反馈非常关键孩子能在错误发生时立刻知道而不是等整道题做完才看到“这题错了”。值得注意的是TextInput的onChange事件在输入拼音或复制粘贴时也可能触发但因为我们限定InputType.Number基本上就只剩数字键了。我还加了一层校验只保留单个数字 0~9多余字符一律过滤。4. 实战踩坑与优化记录4.1 数组嵌套更新不刷新这个坑我非说不可。ArkUI 的响应式系统对State修饰的普通对象是“浅监听”也就是说this.exercise.cells[0].input 5这种直接修改深层属性的写法经常不会触发页面更新。我用的是最简单的兜底方案每次修改完内部状态后把exercise重新赋值为一个新对象。// 更新某个 cell 的输入值 let cells this.exercise.cells.slice(); let target new DigitCell(); Object.assign(target, { value: cells[index].value, input: newVal, isCorrect: true, needCarry: cells[index].needCarry, carryValue: cells[index].carryValue, }); cells[index] target; this.exercise.cells cells; // 整体替换强制刷新这样做的性能开销极小因为一道题最多 4~5 个格子但换来的稳定性很值。如果你要处理更复杂的嵌套模型建议用Observed和ObjectLink把DigitCell声明为可观察类让组件自行监听内部属性变化不过那套写法理解成本更高。4.2 TextInput在竖式场景的失控最初我直接用系统键盘的TextInput很快发现两个问题一是每个格子获得焦点时都会弹出系统输入法在模拟器上经常遮住下半屏二是 TextInput 的游标闪烁和竖式格子的简洁风格极不搭。后来我干脆做了一个自定义数字键盘放在页面底部。键盘区域我用一个Grid或Row排列 0~9 和退格键。点击数字时往当前聚焦的格子写入数字点击退格清空当前格。这样不仅把系统输入法完全规避了还能统一控制交互节奏。自定义键盘的代码不复杂Builder NumberPad(onKey: (key: string) void) { Column({ space: 8 }) { Row({ space: 8 }) { this.PadButton(1, onKey) this.PadButton(2, onKey) this.PadButton(3, onKey) } // 类似的 Row 排列 4-6、7-9 Row({ space: 8 }) { this.PadButton(0, onKey) this.PadButton(退格, onKey) this.PadButton(确定, onKey) } } }自定义键盘还有一个额外好处可以加入“确定”按钮作为整道题做完后的显式提交。虽然我能做到每一位都对才放行但孩子还是会习惯点一下“确定”来获得完成反馈。4.3 对位与视觉反馈的小细节竖式对齐是所有截图里最容易被挑刺的地方。我一开始固定每列宽度 60vp但数字只有 1 位数时看起来很空有进位标记时4 位数字加上标记又显得拥挤。后来我把列宽统一成 72vp行高也相应增加让竖式卡片在横屏状态下充满屏幕效果立刻好很多。颜色反馈我也做了分级正确输入给绿色背景错误输入给红色边框已经完成对的格子保持绿色。对于进位和借位标记我坚持用红色大号字体因为这是在提醒“这里不要忘记”。视觉上红色标记位于横线上方绿色结果位于横线下方形成了两个层次的视觉流。还有一个小细节结果区的横线我没有用Divider组件而是直接用Rect画了一条宽度可动态计算的直线。因为Divider默认宽度是跨满父容器但如果数字只有 3 位整条横线从屏幕左边画到右边会显得很蠢。动态计算横线宽度为最大位数 * 72就能做到恰到好处。4.4 真实判题时的隐藏陷阱最后分享一个在判题逻辑上容易漏掉的点。减法中如果结果的高位是 0比如 800 - 750 50竖式显示时结果最高位是 0但在纸上孩子通常不会在百位写 0。我的界面设计是只显示必要的位数所以result数组里实际上已经把高位的 0 去掉了。但用户输入格子的数量必须和result数组长度一致否则孩子会困惑“为什么百位没有格子”。处理方法是生成题目时计算完结果后把最高位的连续 0 全部弹出同时把对应的进位/借位标记也弹出。然后根据result数组的实际长度生成格子。这样竖式里显示几个格子孩子就只需要填几个格子和纸笔习惯一致。另外一个容易踩坑的是加法结果刚好进位到 10 的倍数比如 123 877 1000这时候result数组长度为 4但top和bottom都是 3 位。竖式顶部数字只有 3 列结果区却有 4 列横线必须按结果位数对齐。所以我的竖式列数统一以Math.max(top.len, bottom.len, result.len)为准空位不显示数字但保留列宽这样永远都不会错位。说实话这个竖式计算器做完之后最大的感喟是它没有用任何高级特效所有亮点全在“把一件简单的事讲清楚”。HarmonyOS 应用开发里复杂动画和网络请求固然炫目但对初学者来说能把状态模型设计得干净、把嵌套更新问题解决、把对位布局做舒服才是基本功。如果你也要做类似的练习工具建议先把加减法的边界情况和进位借位标记在纸上推演一遍再动手写代码能省掉好几个晚上的排查时间。后面我还会在这个基础上加入乘法竖式和除法试商思路会更多一层难度到时候再来分享。