ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

HarmonyOS应用实例:用ArkTS实现100以内加法竖式计算达人

HarmonyOS应用实例:用ArkTS实现100以内加法竖式计算达人

这次想聊的是 HarmonyOS 应用实例 22:100以内加法『竖式计算达人』。这个名字看着像个给孩子玩的小工具,但拆开以后你会发现,它把随机出题、竖式排版、进位逻辑、答案校验、分步讲解全塞进了一个单页面应用里,非常典型的“小而全”练手项目。作为 HarmonyOS 应用开发实例,它的价值不只是“会做一道加法题”,而是能让你把 ArkTS 的声明式 UI、状态管理、事件响应和纯业务函数串起来跑一遍。

如果你正在学 HarmonyOS 应用开发,又不想一上来就碰复杂的网络请求和数据库,这类计算类小工具是最好的起点。它不需要后端、不需要复杂权限,界面逻辑直观,坑也集中在前端排版和状态刷新上。哪怕你完全没有做过鸿蒙应用,跟着这个思路走下来,也能搞出一个能正常出题、判题、讲步骤的完整应用。这篇就把我的设计过程、核心代码和踩坑点完整写出来。

1. 需求拆解与整体设计思路

1.1 先想清楚这个应用到底要做什么

很多人在拿到“100以内加法”这个需求时,第一反应是“不就是随机两个数然后判断结果吗”。如果真是这么做,做出来的东西和一张口算纸没什么区别。竖式计算达人的核心差异在于“竖式”两个字。孩子平时在作业本上写竖式,讲究的是数位对齐、进位标记、从个位算起,这些细节才是练习的价值。

所以我定的功能清单是这样的:

  • 随机生成一道 100 以内加法题,两个加数可以是两位数,和不超过 100。
  • 题目以竖式形式展示,十位、个位严格对齐,有进位时显示进位标记。
  • 孩子在下方的输入框填写计算结果,点击按钮检查。
  • 答对计分,答错允许重试,但同一题只能计一次分。
  • 支持查看分步计算过程,明确展示“个位先加、满十进一、十位再加”的步骤。
  • 提供下一题按钮,连续出题形成练习闭环。

从这组需求能看出,应用的重点其实不在加法本身,而在怎么把“竖式计算的思路”转译成 UI 和逻辑。加法谁都会,但要让一个刚接触竖式的孩子看得懂、练得下去,就需要在排版和反馈上下足功夫。

1.2 技术选型:为什么用 ArkTS + ArkUI

HarmonyOS 应用开发现在的主流方案是 Stage 模型搭配 ArkTS 语言和 ArkUI 声明式框架。针对这个项目,不需要引入任何第三方库,单页面就能搞定。

选 ArkTS 而不是旧式 Java XML 或类 Web 开发,主要有几个原因:

  • 声明式 UI 和状态绑定非常契合这种交互场景。界面上的题目、得分、反馈信息都是状态,状态一变 UI 自动刷新,比手动操作 DOM 或 findView 舒服太多。
  • 内置组件足够用。Column、Row、Text、TextInput、Button、Divider 这几个组件就能拼出整个竖式界面。
  • 业务逻辑集中在随机数生成和字符串拼接上,ArkTS 的类型约束反而能帮你提前发现数据结构设计问题。

如果动用 Canvas 自绘竖式,当然也能做,而且排版更自由,但开发量会明显变大。这个项目的定位是计算练习工具,不是绘画渲染 Demo,所以用基础组件拼版是最务实的方案。后续想追求更精美的竖式展示,再把某一块替换成 Canvas 不迟。

1.3 页面结构与状态设计

整个应用只有一个页面,但页面内部有清晰的区块划分。从上到下依次是:

  • 顶部信息栏:显示当前是第几题、当前得分。
  • 竖式题目卡片:这是核心展示区,包含进位标记、两个加数、横线、答案输入位。
  • 操作区:检查答案、查看过程、下一题这三个按钮。
  • 反馈区:显示对错提示、分步计算步骤、鼓励文案。

状态变量也不是随手一放,而是和用户操作一一对应。我用的核心状态如下:

状态变量作用变化时机
question当前题目的完整数据生成新题时整体替换
inputValue用户输入的答案字符串输入框每次变化
feedback对错提示文案点击检查时
hasAnswered当前题是否已计分首次检查后置为 true,下一题重置
showProcess是否展示分步过程点击查看过程或答错后
score / total得分和总题数每次判题后更新

这里要注意一个常见的坑:@State 修饰的变量,如果你只修改对象内部的某个字段,在某些情况下 UI 不一定刷新。最稳妥的做法是每次出题时直接生成一个全新对象整体赋值给 question,而不是在旧对象上改字段。这种“整体替换”的思路在后面写代码时会反复出现。

2. 出题算法与竖式数据结构

2.1 数据结构:用对象描述一道竖式题

竖式题不能只存两个加数和结果,因为界面展示和分步讲解都需要按位拆分。我定义了一个接口:

interface Question { addend1: number; // 第一个加数 addend2: number; // 第二个加数 sum: number; // 两数和 tens1: number; // 第一个加数十位 ones1: number; // 第一个加数个位 tens2: number; // 第二个加数十位 ones2: number; // 第二个加数个位 carry: number; // 个位相加是否进位,0 或 1 resultOnes: number; // 个位结果 resultTens: number; // 十位结果 resultHundreds: number; // 百位结果,0 或 1 }

为什么要把“原数字”和“各数位”都存下来?因为不同场景需要不同视角。输入框校验用 addend1 + addend2;竖式排版用 tens1、ones1、tens2、ones2;分步讲解用 carry、resultOnes、resultTens。如果只有两个加数的整体数字,每次展示前都要再做一次除法和取余,逻辑散落各处,很容易出 bug。把所有派生数据在生成题目时就算好,后续所有模块只读取不重复计算,整个代码会清爽很多。

这里也体现了数据建模的重要性:界面长什么样、讲解流程怎么走,会反过来决定数据结构怎么设计。

2.2 随机出题:按“数位”生成而不是按“和”生成

很多人的第一版出题函数是“直接随机两个数,判断和是否小于等于100”。这样做也能用,但会出现两个问题:一是算出来的题可能是个位和十位都完全不需要进位的简单题,竖式练习价值低;二是很难在出题阶段控制难度。

我更推荐按位生成:

function generateQuestion(): Question { for (let i = 0; i < 100; i++) { const tens1 = Math.floor(Math.random() * 10); const tens2 = Math.floor(Math.random() * 10); const ones1 = Math.floor(Math.random() * 10); const ones2 = Math.floor(Math.random() * 10); const addend1 = tens1 * 10 + ones1; const addend2 = tens2 * 10 + ones2; const sum = addend1 + addend2; // 和不能超过 100 if (sum > 100) continue; // 排除 0+0 这种没有练习意义的题 if (addend1 === 0 && addend2 === 0) continue; // 至少一个加数是两位数,否则竖式只有一行,和“横式”没区别 if (tens1 === 0 && tens2 === 0) continue; const onesSum = ones1 + ones2; const carry = onesSum >= 10 ? 1 : 0; const resultOnes = onesSum % 10; const tensSum = tens1 + tens2 + carry; const resultHundreds = tensSum >= 10 ? 1 : 0; const resultTens = tensSum % 10; return { addend1, addend2, sum, tens1, ones1, tens2, ones2, carry, resultOnes, resultTens, resultHundreds }; } // 极端情况兜底,几乎不会走到 return createDefaultQuestion(); }

按位生成的另一个好处是,后续想加难度分级非常容易。比如想出一道人教版小学一年级常见的“个位相加进一位”的题,可以先生成 ones1 和 ones2,让它们满足 ones1 + ones2 >= 10,再生成十位。想出一道路程更长的连续进位题,就让十位相加再加进位后也变成 10 或更多。先用随机 + 重试把基础版本跑起来,后续优化难度时再改成“先定进位规则,再生成数位”的定向逻辑。

2.3 防止题目重复与难度失衡

纯随机出题有一个副作用:连续几题可能是完全一样的。孩子练着练着就会发现“这题刚才做过”,练习效果打折扣。我处理的办法很简单,维护一个最近题目缓冲:

const history: string[] = []; function isDuplicate(q: Question): boolean { const key = `${q.addend1}+${q.addend2}`; return history.includes(key); } function recordHistory(q: Question): void { const key = `${q.addend1}+${q.addend2}`; history.push(key); if (history.length > 5) { history.shift(); } }

生成题目后先查 history,重复就重新生成,新题生成后把 key 放入 history。这样能避免连续 5 题内出现重复,又不会因为历史记录无限增长导致内存问题。

难度失衡方面,完全随机容易产生大量 30+20、40+10 这类“十位相加不进位、个位相加也不进位”的简单题目,孩子熟练以后会觉得无聊。常见做法是引入难度参数,比如小学低年级可以分成三个等级:

  • 入门:两个加数都是一位数或整十数,练习数位对齐。
  • 进阶:个位相加需要进位,但十位相加不进位。
  • 挑战:个位、十位都有可能进位,甚至出现 99+1=100 这样的边界题。

基础版可以先不做等级选择,但出题时要避免两个加数全是 0 开头,保证竖式至少有两行数字。实测下来,加了这个过滤后,题目整体观感会好很多。

3. 界面布局与竖式展示实现

3.1 竖式排版的核心思路:固定列宽

竖式在纸上是靠数位上下对齐,但在手机上直接写一串文本很难做到对齐。我的做法是固定三列:百位列、十位列、个位列,每个数字占一个固定宽度容器,空格也用同样宽度的 Text 填充。

ArkUI 里没有像 HTML 那样简单的 table 布局,但用 Row 加固定宽度 Text 完全可以模拟。下面这个自定义构建方法是我实际在用的:

@Builder numberRow(value: number, showPlus: boolean) { Row() { Text(showPlus ? '+' : ' ') .width(40) .fontSize(32) .textAlign(TextAlign.Center); Text(value >= 100 ? String(Math.floor(value / 100)) : ' ') .width(40) .fontSize(32) .textAlign(TextAlign.Center); Text(value >= 10 ? String(Math.floor(value / 10) % 10) : ' ') .width(40) .fontSize(32) .textAlign(TextAlign.Center); Text(String(value % 10)) .width(40) .fontSize(32) .textAlign(TextAlign.Center); } }

你需要理解这里的关键:不是把“35”当成一个整体 Text 放进去,而是拆成“3”和“5”两个独立 Text,分别塞在十位和个位列。这样无论上面的数是 35、7 还是 100,个位永远在同一列。如果不拆开,一旦遇到不同位数的数,下方数字就会对不齐,这是竖式应用最容易出现的问题。

进位标记行也一样,固定三列:

@Builder carryRow() { Row() { Text(' ') .width(40) .fontSize(24) .textAlign(TextAlign.Center); Text(' ') .width(40) .fontSize(24) .textAlign(TextAlign.Center); Text(this.question.carry === 1 ? '1' : ' ') .width(40) .fontSize(24) .fontColor('#E84026') .textAlign(TextAlign.Center); } }

因为题目和不超过 100,百位最多也只可能是 1,所以三列足够。整个竖式区域用 Column 纵向排列:进位行、第一个加数、第二个加数、横线、答案输入区。

3.2 横线与答案输入区

竖式下方需要一个横线,ArkUI 里可以用 Divider,也可以用一个高度很小的 Column 自己画。我习惯用 Divider:

Divider() .strokeWidth(2) .color('#666666') .width(180)

答案区我没有直接显示结果,而是放了一个 TextInput,让用户把计算结果填进去:

TextInput({ placeholder: '输入答案', text: this.inputValue }) .type(InputType.Number) .maxLength(3) .width(160) .fontSize(28) .textAlign(TextAlign.Center) .onChange((value: string) => { this.inputValue = value; })

设置.type(InputType.Number)后,手机会弹出数字键盘,对孩子更友好。不过要注意,数字键盘并不代表输入内容一定是纯数字,所以校验逻辑里仍然要做兜底判断。

3.3 小屏适配与可读性细节

竖式练习的主要用户是孩子,所以字体和按钮都不能太小。我实测下来,数字字号 32fp、按钮高度 44vp 以上比较合适。数字列宽设成 40vp,整个竖式卡片宽度控制在 200vp 左右,在大多数手机竖屏下都能完整显示。

还有一个容易忽略的点:进位标记的颜色可以用醒目的红色,但不要太刺眼。我用的是 #E84026,和主文本的 #182431 区分开,既醒目又不花哨。反馈文案也要语气鼓励式,答对显示“太棒了”,答错显示“再想想”,不要把“错误”“不对”这种生硬的字眼放太大。

如果需要适配平板,可以给整个竖式卡片加一个最大宽度约束,然后用.constraintSize({ maxWidth: 360 })限制内容宽度,避免在宽屏上被拉得很散。考虑到这个应用主打手机场景,基础版本不用做得太复杂。

4. 核心交互与校验逻辑实现

4.1 输入校验:不能只相信数字键盘

用户输入答案后,点击“检查答案”按钮,触发校验函数。这里的校验分两层:

  • 第一层:输入是否为空、是否纯数字。
  • 第二层:输入的数字是否等于题目结果。

第一层校验一定要做,因为即使设置了数字键盘,有些输入法还是能输入空格、小数点甚至全角字符。处理方式:

checkAnswer() { const input = this.inputValue.trim(); if (input === '' || !/^\d+$/.test(input)) { this.feedback = '请输入数字'; return; } const answer = parseInt(input, 10); if (answer === this.question.sum) { if (!this.hasAnswered) { this.hasAnswered = true; this.score += 10; } this.feedback = '太棒了,回答正确!'; this.showProcess = true; } else { this.feedback = '再想想,看看下面的计算过程'; this.showProcess = true; } this.total += 1; }

这里的hasAnswered很关键。如果不加这个标记,孩子答对后觉得好玩,反复点“检查答案”,分数会不断累加。加了以后,同一题只计一次分,总分才是真实的练习成绩。

答对后自动展示分步过程,也是一个不错的体验细节。孩子即使做对了,也能再看一遍竖式计算的算法路径,强化记忆。

4.2 分步计算过程的动态展示

分步讲解是“竖式计算达人”区别于普通口算应用的核心功能。我实现的方式不是做动画,而是按步骤生成文本,再用列表渲染出来。这样做开发量小,阅读效果也足够清晰。

生成步骤的函数:

function buildProcessText(q: Question): string[] { const steps: string[] = []; const onesSum = q.ones1 + q.ones2; const step1 = `第1步:个位 ${q.ones1} + ${q.ones2} = ${onesSum}`; steps.push(q.carry === 1 ? `${step1},写${q.resultOnes},向十位进 1` : `${step1},写${q.resultOnes}`); const tensSum = q.tens1 + q.tens2 + q.carry; if (q.resultHundreds === 1) { steps.push(`第2步:十位 ${q.tens1} + ${q.tens2} + ${q.carry} = ${tensSum},写0,向百位进 1`); steps.push(`所以结果是:${q.sum}`); } else { steps.push(`第2步:十位 ${q.tens1} + ${q.tens2} + ${q.carry} = ${q.resultTens},写${q.resultTens}`); steps.push(`所以结果是:${q.sum}`); } return steps; }

展示时用条件渲染加循环:

if (this.showProcess) { Column({ space: 6 }) { ForEach(buildProcessText(this.question), (step: string) => { Text(step) .fontSize(18) .width('100%') .margin({ top: 4 }) .lineHeight(28) }) } .padding(12) .backgroundColor('#F5F7FA') .borderRadius(12) .width('100%') }

这里有个小设计:进位时十位的结果展示为“写0,向百位进 1”,这样 100 这种边界情况也能讲清楚。孩子看到竖式上的进位 1 对应到讲解文本里的“进 1”,理解起来非常直接。

4.3 下一题与状态重置

“下一题”按钮触发:

nextQuestion() { let next: Question; let guard = 0; do { next = generateQuestion(); guard++; } while (isDuplicate(next) && guard < 20); recordHistory(next); this.question = next; this.inputValue = ''; this.feedback = ''; this.showProcess = false; this.hasAnswered = false; }

这里有一个细节:把“生成新题”和“重置状态”分开写,逻辑才清楚。最容易犯的错是只改题目不清空输入框,导致上一题的答案留在空里,孩子看到以为下一题已经填好了。在写代码时,我把所有需要重置的变量列在同一个函数里,逐个检查一遍,基本就不会漏。

另外,为什么用 do...while 而不是直接生成?因为 isDuplicate 有概率连续返回 true,加一个最大重试次数 20 作为兜底,防止极端情况下死循环。虽然实际运行中这个概率非常低,但防御性编程还是必要的。

4.4 按钮状态与用户引导

界面上三个按钮可以进一步区分状态。检查答案按钮在hasAnswered为 true 时禁用,避免重复点击;查看过程按钮和检查答案功能有重叠,但保留它可以让做对的孩子也能主动看讲解;下一题按钮始终可用。

ArkUI 里禁用按钮可以直接控制.enabled():

Button('检查答案') .enabled(!this.hasAnswered) .onClick(() => this.checkAnswer())

按钮禁用后颜色会变灰,正好给孩子一个“这题已经答过了”的视觉信号。这个小细节不需要额外代码状态,但使用体验会提升不少。

5. 常见问题、优化思路与实测心得

5.1 竖式对齐问题:不是玄学,是固定宽度没做对

我用这个应用给身边人测试时,反馈最多的就是“怎么有时候数字会对不齐”。排查后发现基本都是同一个原因:有人在实现时把整个数字当成 Text,然后用空格去凑位置。不同字体下空格的宽度和数字宽度不同,换一台设备就歪了。

解决办法很明确:别依赖空格,把所有数位拆开,每个数位放到固定宽度的容器里。Text 的 width 固定后,textAlign 设为居中,无论数字是 0 还是 9,列的位置都完全一致。这也是为什么 Builder 里每个 Text 都要写.width(40)。

如果后续想支持更多位数(比如三位数加法),只需要扩展列数,把每个 Text 的宽度统一改一下即可,整体结构不需要推倒重来。

5.2 状态不刷新与重复计分

有朋友按我这个思路写,遇到一个问题:输入正确答案后,界面上的得分没变。查了半天发现他把 question 和 score 都写在一个自定义对象里,然后用@State修饰这个对象,再修改this.data.score = this.data.score + 10。在 ArkUI 中,@State 对嵌套属性变化的观察有限制,这种写法并不总能触发 UI 更新。

我的建议是:

  • 把需要响应式更新的字段直接定义成独立的 @State 变量,比如@State score: number = 0。
  • 对于引用类型,整体替换对象,不要只修改它的属性。

遵循这两条,状态刷新问题能减少九成。重复计分问题则靠hasAnswered这个标志位解决,上面已经详细说过。

5.3 出题质量与边界情况测试

这类应用的隐藏风险都藏在边界。我测试时重点跑了以下场景:

  • 99+1=100:百位出现 1,竖式要显示三列。
  • 5+6=11:两个加数都是一位数,竖式左侧会出现大量空白。
  • 12+7=19:第二个加数是一位数,十位留白。
  • 45+55=100:个位进位且十位也进位,分步讲解要正确显示“十位写 0 向百位进 1”。

自己手写测试太累,可以在开发阶段临时把generateQuestion改成带参数的定向生成函数,比如generateQuestionWithCarry(true),专门用来造边界题。我测试时就是这么干的,等边界题都通过后,再换回完全随机模式。

5.4 后续扩展方向与性能优化

这个应用目前的性能没有任何压力,单页面、几个组件、轻量循环,运行非常流畅。后续如果想做成真正能上架给孩子长期使用的产品,可以考虑这些方向:

  • 难度等级选择:入门、进阶、挑战,对应不同的进位规则。
  • 错题本:答错的题自动收集,后续集中复习。
  • 计时挑战:一分钟内能答对多少题,增加游戏化激励。
  • 家长设置:限制每日练习时长、查看正确率曲线。
  • 语音播报:把题目读出来,适合学龄前儿童。

代码层面优化的空间不大,但如果要上版本迭代,建议把出题、校验、步骤生成这些纯函数放到单独的工具文件里,页面只负责状态和渲染。这样将来加测试用例、做单元测试都会容易很多。

我在实际开发中发现,一个看起来简单的“加法练习器”,真正做出来并且把各种边界情况处理好,至少要预留半天时间。竖式排版和随机出题本身不难,难的是站在孩子的视角把所有反馈细节补充完整。比如答错之后要看得到过程、答对之后要有正向激励、题目不能太简单也不能超出范围,这些都会决定它是不是一个真正“能用”的学习工具。

如果你也在做类似的 HarmonyOS 应用实例,我建议先把界面骨架搭出来,再填逻辑,最后专门花一点时间测边界题。别小看这个 100 以内加法的小项目,它把声明式 UI 的状态管理和业务模型配合这件事讲得明明白白,做完之后你会对 @State、@Builder、组件事件这些核心概念有更具体的理解。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进