鸿蒙开发实战:用ArkTS打造儿童数位与组成探索应用
最近在整理闺女一年级数学的时候发现教材里有一类活动设计得特别妙“摆一摆想一想”。用几根小棒、几个圆片让孩子把数字拆开再合上个位、十位摆一摆数位和组成的概念就有了具体的样子。我随手拿手机上的计算器给她演示她完全没感觉只有真正动手去摆她才能说出“3个十和5个一组成35”。后来我想既然是做鸿蒙开发为什么不直接把这个“摆一摆”做成一个在平板上的小应用呢这一篇是HarmonyOS应用实例系列的第二十篇标题里写得也很明白——做一个数位与组成探索的小工具。整个应用坚持一个核心思路不靠念规则靠动手摆。孩子在屏幕上点珠子、摆数位凑满十个自动打包成十位的一个大珠子数字在里面拆来拆去数位的“位值”和数字的“组成”就反复被体验到了。适合两类人看一类是正在学HarmonyOS声明式UI和动画的开发者可以拿来当一个小而完整的练手项目另一类是给孩子做数学启蒙的家长这套交互设计本身也可以直接拿去用。下面就把我整个实现过程、踩过的坑、做过的取舍都摊开讲一讲。1. 项目背景与设计思路解析1.1 儿童数位学习到底难在哪大人眼里“35是3个十加5个一”是理所应当的但对刚接触数位的孩子来说这里面其实藏着两层很抽象的规则。第一层是“位值”——同样一个数字3写在十位就表示30写在个位就表示3同一个符号因为位置不同代表的数量完全不同第二层是“组成”——一个数可以由不同的加数组合出来比如35可以是305也可以是2015还可以是1025。传统的纸面练习往往是直接写算式孩子通过记忆和计算来刷题但这种方式并没有真正建立“数量与符号”之间的连接。而“摆一摆”恰好能补上这个缺口。你把人教版教材里那个活动搬到屏幕上左边放一捆小棒表示一个十右边放一根小棒表示一个一孩子用手指一点小棒就多一根凑够十根小棒自动捆成一捆跳到左边。孩子眼睛看到的是“数量变化”和“符号变化”同步发生的过程。这个东西在纸质练习册上只能靠想象在屏幕上却可以做成真实的动态反馈。所以我给应用定了三个设计原则可视化每个数字背后都有对应数量的珠子、可操作孩子用手指就能改变珠子数量、即时反馈数字卡片跟着珠子的增减同步变化。1.2 为什么选 HarmonyOS ArkTS 来做做儿童学习工具设备形态很关键。手机屏幕太小孩子手指一按可能就误触到旁边的珠子电脑又不方便触控。ArkUI 的声明式写法天然适合这种界面频繁变化的应用更重要的是借助跨设备的特性同一个工程可以轻松适配平板、折叠屏甚至以后放到教室里的智慧黑板上。技术层面我也考虑过用传统的命令式框架或者直接做小游戏引擎。但分析下来这个项目并不需要复杂的游戏物理和帧循环核心交互就是“点击珠子 - 状态变化 - 界面刷新”用 ArkTS 的 State 驱动 UI 更新反而最直接。而且事件系统里 animateTo 做圆珠的位移、缩放动画非常顺手配合系统预置的振动和音效能力一套学习反馈闭环很快就能搭起来。还有个很实际的考量这类教育 App 对性能和包体积要求不高但对开发效率和迭代速度要求高。一个页面、两个模块、几十个状态变量用 ArkTS 写就是一个普通的小工程发到平板上运行体感非常流畅完全没必要杀鸡用牛刀去上重型引擎。2. 整体功能规划与界面分区设计2.1 两个核心模块的边界划分整个应用被拆成两个独立模块顶部用标签页切换一个负责“数位认知”一个负责“数字组成探索”。数位认知模块解决的是“位值”问题。屏幕上有一块数位架左边是十位区右边是个位区。底部是一颗颗散落的珠子孩子点击一次个位区就多一颗珠子个位区凑满10颗自动打包成一颗大珠子飞到十位区。数字卡片显示当前摆出的数量比如十位区2颗大珠子、个位区4颗小珠子卡片就显示“24”。这个模块还内置了目标数出题机制系统给出一个目标数孩子需要自己动手摆出对应的珠子摆对了星星点亮。数字组成探索模块解决的是“组成”问题。系统给出一个目标数比如8界面展示一个等式的左半部分“? ? 8”下面排着一列组合卡片写有“1和7、2和6、3和5、4和4”等选项。孩子点选某一组上方的两个珠子集合就动态变成对应的数量左侧一滩、右侧一滩中间一个加号对应等式清晰展示出来。选完一组再点另一组珠子会重新“分堆”直观看到数字在不同加法组合下的拆分。两个模块分开设计的好处是每个模块的交互逻辑都足够简单孩子不会因为多个功能挤在一起而失去焦点。实际用下来孩子的注意力确实能集中在单一任务上不会东点一下西点一下。2.2 界面布局与组件的选型逻辑布局上我坚持“上面是展示区下面是操作区”的大原则。顶部放数字卡片与任务状态中部放数位架或者珠子拆分展示区底部放可点击的操作区比如散珠池和组合卡片列表。这样孩子眼睛从上往下扫先看结果再看能做什么不容易乱。组件选型上我没有使用任何自定义绘制的复杂图形所有的“珠子”都用最基础的 Row 组件加圆角样式实现一个 40vp 左右的正方形borderRadius 设为宽度的一半再配上背景色和边框看起来就是一个饱满的圆片。为什么不用 Canvas 画圆因为普通组件可以天然支持点击事件、动画变换和状态样式切换改一个背景色就是一个高亮效果不用自己去管理和重绘坐标。这对一个需要频繁变化的儿童交互界面来说开发效率高得多。颜色的区分我用了一个固定的视觉映射个位珠子用蓝色系十位大珠子用橙色系组合探索里左边的堆用蓝色右边的堆用绿色。这样颜色本身就承担了一部分教学语义孩子不需要读文字也能看出“这两种东西不一样”。2.3 儿童场景里的视觉与交互规范既然面向低龄儿童视觉和交互上的规矩比一般应用更严格。按钮和珠子的点击热区最小做到 48vp所有文字主字号不小于24fp重要数字卡片用40fp以上的粗体。界面里刻意避免了下拉菜单、长按菜单这类需要精细操作的手势所有操作都是单击或点击长按只用来听语音提示。对比度也是个容易被忽略的点。很多成人应用的浅色文字在儿童平板上会显得灰扑扑的我统一把关键信息做成白字深底或者深字浅底保证高对比。背景用了大面积的米白色珠子用高饱和色视觉上既醒目又不刺眼。还有一个细节是触摸反馈。每次珠子数量变化系统会自动触发一次轻微的振动配合珠子落下的弹簧动画手感和观感是一致的。没有声音的时候孩子也能通过振动感知到“我已经点上了”。3. 数位认知模块的代码实现3.1 状态数据结构的搭建数位认知模块是整个应用开发的第一步先想清楚状态长什么样再动手写界面后面会省很多事。我的页面级状态只有四个数字型和两个标记位简洁到不需要建类Entry Component struct DigitPlacePage { State tenCount: number 0; // 十位上的大珠子数量 State oneCount: number 0; // 个位上的小珠子数量 State targetValue: number 0; // 当前目标数 State isAnimating: boolean false; // 动画锁防止连点 State isSuccess: boolean false; // 是否已正确摆出目标数 private maxTen: number 4; // 目标数十位最大范围控制在0~4 }这里我用两个基础 number 类型而不是一个完整的“数字对象”是因为珠子的数量在界面上要直接参与循环渲染和加减变化拆成两个独立变量语义清晰动画切换时也不用考虑对象的深层更新。State 绑定的是基础类型的值ArkUI 的响应式系统能最直接地感知到变化并且刷新界面。目标数生成逻辑上我刻意把十位限制在 0~4个位限制在 1~9。为什么呢一年级认知范围通常不超过50而且个位从1而不是0开始是为了避免出现“十位有珠子但个位一颗都没有”这种容易让孩子困惑的情况。十位允许是0是为了保留“一位数”的练习场景比如目标是6孩子就知道摆6颗蓝色小珠子就够了。3.2 点击摆珠与“满十进一”的实现核心交互是点击操作区的一颗蓝珠子个位就加一。珠子我做成一个 Builder 方法方便在多个位置复用Builder Bead(color: string, size: number, label: string ) { Row() { if (label ! ) { Text(label) .fontSize(size * 0.4) .fontColor(#FFFFFF) .fontWeight(FontWeight.Bold) } } .width(size) .height(size) .borderRadius(size / 2) .backgroundColor(color) .border({ width: 3, color: #FFFFFF }) .shadow({ radius: 8, color: rgba(0,0,0,0.2), offsetY: 4 }) .margin(6) }大珠子传橙色和“10”标签小珠子传蓝色和空标签。孩子点击底部珠子池执行的是加一逻辑点击个位区里已经摆好的小珠子执行的是减一逻辑点击十位区里的橙色大珠子则是把它“拆开”退回10颗小珠子到个位。三个操作对应三个函数最核心的是加一private addOneBead() { if (this.isAnimating) { return; } this.isAnimating true; if (this.oneCount 9) { animateTo({ duration: 200, curve: Curve.EaseOut, onFinish: () { this.isAnimating false; } }, () { this.oneCount; }); } else { // 个位已经有9颗再添1颗就要打包成十位的大珠子 animateTo({ duration: 450, curve: Curve.Friction, onFinish: () { this.isAnimating false; this.checkFinish(); } }, () { this.oneCount 0; this.tenCount; }); } this.checkFinish(); }满十进一的实现其实只有两行个位清零十位加一。但是动画过程我用了450毫秒的 Friction 曲线让“个位珠子消失、十位出现一颗大珠子”的过程有从快到慢的阻尼感孩子能明显看到“这10颗变成一个10”的连续关系。这里不需要真的做10颗珠子飞向十位的物理动画那样反而会显得乱一个干净利落的切换加一个放大出现的大珠子认知效果更好。减一操作对应的是退位思想private removeOneBead() { if (this.isAnimating) { return; } this.isAnimating true; if (this.oneCount 0) { animateTo({ duration: 200, onFinish: () { this.isAnimating false; } }, () { this.oneCount--; }); } else if (this.tenCount 0) { // 个位没了就从十位拆一个大珠子相当于借一当十 animateTo({ duration: 350, onFinish: () { this.isAnimating false; } }, () { this.tenCount--; this.oneCount 9; }); } else { this.isAnimating false; } this.checkFinish(); }这里“借一当十”的逻辑很自然地嵌进去了。孩子如果已经认识了减法里的退位在玩这个模块时会隐约体会到“十位借一个个位变十颗”的规律为后续学习埋下经验基础。3.3 目标校验与成功反馈校验函数被加一、减一和随机出题后都会调用private checkFinish() { let currentValue this.tenCount * 10 this.oneCount; if (currentValue this.targetValue) { this.isSuccess true; this.playSuccessFeedback(); } else { this.isSuccess false; } }成功反馈我特意做成了“环境变化”而不是“弹窗”。屏幕右上角的星星从小变大并带上旋转动画系统播一声清脆的叮咚平板轻微振动一次同时目标数字卡片的边框变成金色。孩子自己的操作没有被强制中断他可以继续摆弄已经成功的数字也可以按“下一题”进入新挑战。这个设计的心理依据很简单频繁的打断式表扬会让孩子把注意力从“数字本身”转移到“获取奖励”上学习效果反而打折。还有一个小细节值得提校验是在动画结束后才做的。比如从9颗加到10颗的进位动画动画没完成时 oneCount 已经为0但界面上十位的大珠子还没“出现”这时候如果立刻给出成功反馈孩子会看到“数字已经变了但画面上还没摆出来”产生割裂感。所以我把成功判定放在了动画 onFinish 回调里等画面稳定了再校验体验就顺了。4. 数字组成探索模块的实现4.1 组合数据与状态确认数字组成探索模块单独占一个页面。它的状态比数位模块多一个“当前选中的组合”数据结构上也多一个二维数组Entry Component struct ComposePage { State targetNum: number 8; State leftCount: number 0; State rightCount: number 0; State selectedIndex: number -1; State isAnimating: boolean false; private comboList: number[][] []; aboutToAppear(): void { this.refreshComboList(); } private refreshComboList() { this.comboList []; for (let i 1; i this.targetNum; i) { this.comboList.push([i, this.targetNum - i]); } this.selectedIndex -1; this.leftCount 0; this.rightCount 0; } }组合列表不是写死的而是根据目标数动态生成。目标数是8时自动生成 [1,7] [2,6] [3,5] [4,4] [5,3] [6,2] [7,1] 七组目标数是13时就生成十二组。为什么不直接固定几组因为目标数可以随机变化写死组合列表会导致每次换题都要手动维护数据而且逻辑上也不够通用。用循环生成自动覆盖所有拆分方式代码量还少。界面布局是典型的上中下三段最上面是等式题卡“? ? 8”中间的空白区根据 leftCount 和 rightCount 各渲染一滩珠子下面是一排可横向滚动的组合卡片按钮。孩子滚动卡片列表时每点一张中间珠子立刻重新分堆等式题卡上的问号也替换成具体数字。4.2 拆分珠子的界面与动画联动点击组合卡片的处理函数private onSelectCombo(index: number) { if (this.isAnimating) { return; } if (index this.selectedIndex) { // 再次点击同一个组合取消选中 this.resetSelection(); return; } this.isAnimating true; let combo this.comboList[index]; animateTo({ duration: 300, curve: Curve.EaseInOut, onFinish: () { this.isAnimating false; } }, () { this.selectedIndex index; this.leftCount combo[0]; this.rightCount combo[1]; }); this.updateQuestionText(); }每个组合卡片是一个 Row里面两个大号数字中间夹着一个“”号。选中状态的卡片背景变橙色未选中是蓝色。这个交互非常直观孩子可以快速扫过所有拆分组合逐个观察珠子变化。中间珠子的渲染用了两个 ForEachRow() { ForEach(this.leftBeadsArray(), (item: number) { this.Bead(#4A90D9, 36, ) }, (item: number) item.toString()) Text() .fontSize(40) .fontWeight(FontWeight.Bold) .margin({ left: 20, right: 20 }) ForEach(this.rightBeadsArray(), (item: number) { this.Bead(#5BBA6F, 36, ) }, (item: number) item.toString()) }leftBeadsArray 按 leftCount 生成一个等长的索引数组这是 ArkTS 里比较常用的渲染手法。每次状态变化ForEach 重新执行珠子的个数就跟着变数量变化时珠子出现或消失都带上了 animateTo 的渐变效果孩子在视觉上能明显看到“一堆珠子分成两堆、数量重新分配”的过程。选中组合后上方的等式卡片会同步变化private updateQuestionText() { // 使用一个显示文本的 State this.questionText ${this.leftCount} ${this.rightCount} ${this.targetNum}; }这个“显示文本”其实可以在 build 里直接通过 leftCount 和 rightCount 计算出来不需要单独的状态变量。我用了一个计算属性减少状态数量也让 UI 渲染更轻量。ArkUI 的声明式绑定允许在 Text 组件里直接写模板字符串只要 leftCount 或 rightCount 变化文本就会自动刷新。4.3 随机出题与进度记录为了让工具更像“学习游戏”而不是“玩具”我加了一个出题按钮。每次点击目标数在5到20之间随机生成组合列表和题目卡同步刷新。这里有一点要注意目标数本身的十位不能太大否则组合卡片会非常多滚动列表太长孩子反而无法聚焦。我做过一次目标数20的测试下面排了19张卡片孩子基本是迷茫的。所以最后限制目标数范围在5到15之间一次最多14张卡片配合卡片高度约60vp刚好两屏内可以看完。进度记录做成一个简单的计数答对一次正确组合后顶部的小星星数加一。为什么只记录“组合探索的答对数”而不记录其它行为因为数位认知模块本来就是个自由摆弄的工具不适合用进度去“考核”孩子。而组成探索是任务型操作每完成一个目标数才算一次有效学习计数更合理。当然这个进度只是本地的状态变量没有做持久化本来就是启蒙工具没有积分的压力反而更轻松。5. 开发中踩过的坑与体验优化5.1 动画锁与连点问题的处理最开始的版本里没有 isAnimating 保护。结果在真机上测试时孩子的手指速度比动画完成速度快得多连续快速点击五六下珠子的数量会错乱。具体表现是个位从5跳到7然后又跳回4而界面上的珠子数跟底部操作区的状态对不上。原因分析animateTo 是异步执行的状态变量在动画开始时就改变了但界面在动画过程中还没完全刷新。连续点击会让多个动画同时排队中间的状态层层叠加最后呈现的可能不是用户点击次数的最终结果。解决方式就是我刚才代码里加的那把“动画锁”所有改变珠子的函数第一步判断 isAnimating 是否为 true是就直接 return第二步把 isAnimating 置为 true第三步在动画 onFinish 回调里复位为 false。这样无论手速多快每一帧最多只响应一次点击丢了中间几次点击也没关系对孩子来说点一下看一个结果节奏刚好。5.2 ForEach 刷新与 Key 管理问题第二个坑是珠子渲染闪烁。具体场景数位模块个位数从9变成10再清零进位时个位区的蓝色珠子有时会出现“闪一下再消失”的现象看起来不干净。捣鼓了一会儿发现是 ForEach 的 keyGenerator 写得太随意。ArkUI 的 ForEach 在数组内容变化时会根据 key 判断哪些节点需要重建、哪些可以复用。如果 key 不稳定比如我一开始用了 item 的值本身当数组内容发生重叠变化时渲染引擎可能误判把不该重建的节点重建了一遍视觉上就是闪烁。解决办法是给每个珠子用它在数组中的位置生成一个稳定且唯一的 key形如key_${index}。这样珠子的增删就能精确定位到对应节点动画过渡也顺了。这个问题在只有几个珠子的场景里可能不痛不痒但当卡片列表里有十几张组合卡片滚动时频繁删除重建节点性能差距就非常明显。所以但凡用到 ForEach 渲染可变列表我现在的习惯都是第一优先级写清 key。5.3 儿童体验优化的几个细节清单现象原因处理方式孩子点不到小的珠子点击热区小于手指触碰精度所有可点击元素尺寸不低于48vp珠子间距加大操作区被手掌误触布局底部太密集操作区加安全边距点击区域之间保留16vp以上间隙珠子颜色分不清相邻色相太接近个位蓝、十位橙、拆分右堆绿用色相差距最大的三色动画太长孩子着急动画超过800ms会泄气所有核心动画控制在200~450ms内反馈要快任务失败看板太严厉红色大叉会给孩子挫败感不判定失败只让数字边框变灰继续调整即可还有音效的开关问题。真机上如果系统处于静音模式Audio 播放可能会失败但应用本身不知道导致孩子以为点了没反应。我的处理是初始化时主动读取铃声/媒体音量如果音量为0就默认走振动反馈为主、视觉反馈为辅不依赖声音。这样静音平板也能用不会出现“点了没反馈”的错觉。5.4 “教”与“玩”的边界把握还有一个偏产品层面的经验。最初我给数位认知模块加了一个校验逻辑如果孩子摆出的数比目标数大就弹出红字提示“太多了”再摆错就清空所有珠子。测试后立刻发现这种反馈方式让好几个小朋友产生了明显的畏难情绪有一个小姑娘直接不愿意再点珠子了。后来我把“错误提示”删掉改成只显示目标数字孩子摆什么数字就显示什么数字完全自由操作摆对了星星亮一下摆错了不提示、不清空、不惩罚。这样做的效果反而更好孩子放松地反复尝试通过自己观察数字变化来修正歪打正着做对时的喜悦也更真实。这个“不纠正、只反馈”的原则后来被我沿用到了整个应用。对低龄儿童来说探索过程中的试错是学习的一部分应用要做的是给足反馈而不是代替他们判断对错。6. 从这一个实例得到的经验与扩展建议做完这一篇我最大的感受是像这种儿童数理启蒙的小工具技术难度其实不高但对“交互逻辑”的要求比普通业务应用更敏感。成年人用应用会根据文字提示推断下一步操作而儿童是纯靠“点一下看反应”来建立操作模型的。你可以没有复杂炫酷的特效但每一次点击都必须有清晰、即时的反馈而且反馈的含义要一致。举个例子我在做动画锁的时候最开始想的是“防止用户误操作”后来才发现它更大的意义是让交互节奏稳定。孩子在不确定某个珠子点了会发生什么时会小心翼翼地快速点好几下试探如果应用能一次只响应一下这种试探就会变成一步一观察实际上是在帮助孩子建立“因果关系”的认知。这是开发通用应用时不会去想的细节但在儿童教育场景里就成了核心体验。后续这个项目的扩展空间很大。我打算把同样的“状态 动画 即时反馈”的架构复刻到天平称重的小工具里左边放一个物品右边放砝码天平倾斜度根据两边重量实时变化孩子可以直观看到“两个5g砝码等于一个10g砝码”。还可以做一个时钟认知应用用珠子表示小时和分钟拨动珠子改变时间。整个架构模板是现成的——一组视觉化对象、若干个状态变量、一套点了就变的动画反馈机制。另外现在这版应用只有同一个设备上的交互。如果后面把它升级成多端协同版本可以让孩子在平板上摆珠子家长在手机上看到实时的摆数步骤和正确率这样远程辅导也就有了抓手。或者接入语音模型让孩子开口说“我摆的是34”应用语音识别后反馈“对了3个十和4个一组成34”这样又多了一层口语表达的练习。这些都是后话了先把现有的交互体验打磨扎实比堆功能重要得多。