
这周终于把《6-10的认识——数量感知与数序》这个小应用在 HarmonyOS Next 上跑通了正好作为实例系列的第七篇。之所以选这个题目是因为我家娃正好卡在“认数”阶段能流利地从 1 唱到 20但问她“这里有 8 个圆点吗”她会瞎猜点数也经常会漏掉或重复。这个应用就是用 HarmonyOS 原生能力解决这个问题的整体用 ArkTS ArkUI 纯声明式开发基于 HarmonyOS Next SDKAPI 12版本 5.0.0(12)没有接任何第三方框架交互、动画、音频全部走系统能力。下面我会从选题逻辑、技术选型、核心功能实现到调试踩坑一条线写下来既适合用鸿蒙做教育类或工具类小应用的开发者参考也适合想了解儿童数感启蒙该怎么拆解的家长阅读。1. 项目定位与教学逻辑拆解1.1 为什么偏偏是“6-10”很多家长会疑惑孩子明明能从 1 数到 20为什么还要单独做“6-10 的认识”这里有个容易被忽略的认知规律幼儿的“唱数”和“认数”是两回事。唱数是背儿歌不需要理解数量认数则要求把数字符号、数量多少、先后顺序三者绑定起来。多数孩子 5 以内可以靠视觉瞬间识别也就是扫一眼就知道是几个但到了 6 个以上视觉容量不够用了就必须学会“按群计数”比如一眼看出“5 个加 2 个”或者逐一点数并记住数过的部分。6-10 刚好是第一个“两位数”区间的起点10 又是十进制和凑十法的基础。如果孩子在这个阶段没有建立“满五”“满十”的心智模型后面做 20 以内的加减法会非常吃力。所以我把这个应用的教学重点放在两件事上对应标题里的两个关键词一是数量感知看到一堆圆点能直接报出数量二是数序知道 6、7、8、9、10 谁在前谁在后中间缺了谁能补上。这个定位比单纯做“点一下听发音”的数字卡片高一个层次。1.2 学习目标怎么拆成可验收的小能力做儿童应用最忌讳的是“看起来热闹但不知道学会了没有”。我在写代码之前先列了一张能力拆解表每个能力都对应一个玩法模块和一个通过标准能力维度具体行为表现对应玩法模块通过标准数量感知看到 6-10 个圆点不逐一点数就能直接报数看数量选数字连续 10 题正确率不低于 80%数序能把 6-10 按从小到大排列数字排队连续 3 轮全对数物对应给出数字能摆出或指出对应数量数字认知卡片每轮错误不超过 1 次相邻关系理解 9 的后面是 108 的前面是 7数序缺失题3 次练习中能独立完成这张表不是写着好看的它直接决定了页面上要放什么控件、出什么题。比如“数量感知”要求孩子扫一眼报数那 UI 上就不能允许孩子一个一个点着数圆点要出现一段时间后自动消失逼孩子用整体感知而“数物对应”则在卡片页保留圆点允许孩子慢慢点数。同一个数字在不同模块里呈现方式不同这才是教学逻辑驱动设计而不是把一堆效果堆上去。1.3 页面结构和用户路径整个应用拆成四个页面首页、数字认知卡片页、数量感知练习页、数序挑战页。首页用宫格里展示 6、7、8、9、10 五个数字点选一个数字后可以进入数字认知卡片页也可以直接进入数量感知练习或数序挑战。为什么不做成一页滑到底的长页面因为儿童使用时需要清晰的任务边界一屏一个任务能减少注意力分散页面跳转也天然起到了“这个任务结束了”的提示作用。另外页面间相互独立还有一个好处后续想加“比大小”“数的组成”模块时不需要改动已有页面只需要在首页增加入口。这一点在做需求规划时就要想清楚不是技术难而是结构决定了扩展的灵活度。2. HarmonyOS Next 技术底座与工程结构2.1 环境版本与工程初始化开发环境我用的是 DevEco Studio 5.0.0(12)对应 HarmonyOS Next SDKAPI 版本 12。新建工程时选择“Empty Ability”模板语言选 ArkTS模型选 Stage 模型。Stage 模型是现在鸿蒙应用的标准入口模型它把应用入口和页面解耦应用启动会先走EntryAbility.ets的onWindowStageCreate再加载首页。相比老的 FA 模型Stage 模型对后台任务、权限、生命周期管理都更清晰新项目建议直接选它。工程基本结构是这样的entry/src/main/ets/ ├── entryability/ │ └── EntryAbility.ets ├── pages/ │ ├── HomePage.ets │ ├── NumberCardPage.ets │ ├── QuantityPracticePage.ets │ └── OrderGamePage.ets ├── model/ │ └── NumberModels.ets └── utils/ └── AudioPlayerUtil.etspages放四个页面model放数据模型和题目生成逻辑utils放音频播放这类通用工具。项目不大这个分层足够清晰如果以后模块变多再按 feature 拆分也不迟。2.2 ArkUI 声明式开发改状态别改界面HarmonyOS Next 的 ArkUI 是声明式 UI 框架核心思路是“UI 是状态的函数”。你只需要维护数据状态界面会自动跟着变完全不用像传统命令式那样手动setText、setVisibility。举个例子页面里要显示“当前数字是几”Entry Component struct Demo { State currentNumber: number 6; build() { Column() { Text(当前数字是 ${this.currentNumber}) .fontSize(30) Button(加一个) .onClick(() { this.currentNumber 1; }) } } }State currentNumber一变Text 内容自动刷新。这背后是框架的响应式数据绑定开发者只需要关心数据变化不用操心“哪个组件要更新”。但有个坑要提醒ArkTS 对 TypeScript 做了严格限制不能用any不能随意解构对象写惯了 TS 的同学一开始会有点别扭。解决办法就是老老实实定义接口和类变量类型写清楚反而让代码更稳。2.3 资源、素材和多设备适配这个应用的“素材”非常特殊数字和圆点不用图片全部用组件绘制。数字直接用Text圆点用Row加圆角背景色实现或者用 ArkUI 的绘制组件Circle。这样做的原因有三个一是图片在多种屏幕尺寸下会糊组件绘制是矢量的二是动画可以精细控制比如答对时圆点做缩放三是包体积小几个页面加起来不到 1MB。音频素材放在resources/rawfile/audio/目录下命名是number_6.mp3到number_10.mp3。rawfile 目录适合放原始文件不会被打包混淆而resources/base/media更适合放图标这类需要按资源名引用的文件。尺寸适配方面所有长度单位用vp不用px圆点大小不写死根据可用宽度计算保证在手机和平板上都不会挤到屏幕外面。3. 核心功能从 0 到 1 实现3.1 模型与题库别把随机当设计题目看似简单但怎么做干扰项很讲究。如果选项里混入“3”或“4”孩子可能会靠排除法选对而不是真正识别出数量。正确的做法是让干扰项集中在相邻数字制造认知冲突。比如目标数是 8选项应该是 8、7、9。基于这个思路我写了一个简单的题目生成器// model/NumberModels.ets export interface NumberData { value: number; label: string; } export class Question { id: number; target: number; options: number[]; correctIndex: number; constructor(id: number, target: number, options: number[]) { this.id id; this.target target; this.options options; this.correctIndex options.indexOf(target); } } export function buildQuantityQuestion(target: number): Question { const allNumbers: number[] [6, 7, 8, 9, 10]; const others: number[] allNumbers.filter(v v ! target); // 相邻干扰优先目标数减 1 或加 1 const neighborWrong: number target 6 ? target - 1 : target 1; const randomWrong: number others[Math.floor(Math.random() * others.length)]; const options: number[] [target, neighborWrong, randomWrong].sort(() Math.random() - 0.5); return new Question(Date.now(), target, options); }这段代码逻辑很简单但背后有一个原则题目是教学活动的一部分不能让完全不可控的随机破坏教学节奏。neighborWrong保证每题至少有一个相邻干扰项randomWrong再引入一点变化。数组排序用Math.random() - 0.5在小数组上够用优点是代码短缺点是有轻微不均匀但这里选项只有 3 个影响可以忽略。3.2 首页导航和数字认知卡片首页用Grid做了五个数字的宫格选择选中后高亮显示然后通过按钮进入不同模块。跳转我用的是router.pushUrl小应用用它最简单参数传递也方便。数字认知卡片页的核心逻辑是顶部一行数字切换标签中间显示超大数字下面显示对应数量的圆点点数字或圆点都会播放发音。页面部分代码如下// pages/NumberCardPage.ets import { AudioPlayerUtil } from ../utils/AudioPlayerUtil; Entry Component struct NumberCardPage { State currentNumber: number 6; State numberList: number[] [6, 7, 8, 9, 10]; build() { Column() { Row({ space: 8 }) { ForEach(this.numberList, (num: number) { Text(num.toString()) .fontSize(24) .fontColor(this.currentNumber num ? #FF6B6B : #666666) .backgroundColor(this.currentNumber num ? #FFE3E3 : #F5F5F5) .width(48) .height(48) .textAlign(TextAlign.Center) .borderRadius(24) .onClick(() { this.currentNumber num; }) }, (num: number) num.toString()) } Blank() Text(this.currentNumber.toString()) .fontSize(90) .fontWeight(FontWeight.Bold) .fontColor(#FF6B6B) .onClick(() { AudioPlayerUtil.playNumber(this.currentNumber); }) // 圆点区域每行最多 5 个体现“满五”结构 Grid() { ForEach(Array.from({ length: this.currentNumber }), (_, index: number) { GridItem() { Row() .width(28) .height(28) .borderRadius(14) .backgroundColor(#42A5F5) } }, (_, index: number) index.toString()) } .columnsTemplate(1fr 1fr 1fr 1fr 1fr) .width(90%) .height(200) Blank() Button(去练习它有几个) .onClick(() { // 跳转到数量感知练习并带上当前数字 }) } .width(100%) .height(100%) .padding(20) } }这里特意用Grid的columnsTemplate(1fr 1fr 1fr 1fr 1fr)控制每行 5 个圆点而不是用Flex自动换行。原因是 6-10 的认知关键在“满五”6 应该是 518 应该是 53。每行固定 5 个格子孩子看久了会自然形成“一行是 5多出来几个”的视觉结构这对后面学凑十法非常重要。所以这个布局不是审美选择是教学选择。3.3 数量感知练习候选答案怎么设置数量感知练习的玩法是屏幕中央随机显示 6-10 个圆点下方三个数字按钮孩子要选出正确的数量。这里有个细节圆点不能一直显示否则孩子可以慢慢点数。我把圆点显示时间限制在 2 秒时间到自动隐藏逼孩子启动“整体感知”而不是“逐个数数”。答对后绿色反馈答错出现正确数量对比图然后进入下一题。题目生成直接调用buildQuantityQuestion页面维护question状态。当用户点击选项时比较选项和目标值更新得分和反馈信息。这里用State question而不是直接改target是为了让 UI 自动跟随整题刷新避免手动同步多个字段。这个模块是整个应用的教学核心所以 UI 要尽量干净圆点颜色统一、选项按钮大小一致、没有多余的装饰。儿童应用特别容易做得花里胡哨但注意力资源有限花哨的背景只会干扰数数。我实际测试发现圆点用橙色、按钮用蓝灰色、反馈用红绿色对比足够又不刺眼。3.4 数序挑战点击排序与及时反馈数序挑战页的交互是下方打乱顺序的数字点击一个数字它会按顺序填到上方排序区的下一个空位。如果点错了只提示“再想想”不扣分如果按顺序填满出现成功动画。关键逻辑是维护一个nextIndex它表示当前应该填第几个位置State placed: number[] []; State unselected: number[] [8, 6, 10, 7, 9]; State nextIndex: number 0; private onClickNumber(num: number) { const targetSequence: number[] [6, 7, 8, 9, 10]; if (num targetSequence[this.nextIndex]) { this.placed.push(num); this.nextIndex 1; this.unselected this.unselected.filter(v v ! num); if (this.nextIndex targetSequence.length) { // 全部排好触发成功动画 } } else { // 错误提示让孩子自己修正 } }这段代码的核心是利用“当前应该填谁”这一单一标准来判断。孩子点数字时系统不直接告诉他“这题错了”而是让他在错误中试错、自我纠正。从教育角度这比“错了扣分”更能促进数序内化。为了增加趣味我后来加了一个小功能全部排好后五个数字会在屏幕上按顺序依次变大像一列小火车开过去孩子很吃这一套。3.5 反馈与激励动画不是越花越好正确和错误的反馈我用 ArkUI 的animateTo实现它可以把状态变化包在动画事务里让组件平滑过渡。比如答对时反馈文字先缩放再恢复配合按钮颜色从蓝变绿animateTo({ duration: 200, curve: Curve.EaseOut }, () { this.feedback right; this.feedbackScale 1.2; }); animateTo({ duration: 300, curve: Curve.EaseOut }, () { this.feedbackScale 1.0; });这里要分享一个经验给儿童应用的动画不能太夸张。我第一版做了满屏星星粒子效果孩子确实兴奋但也导致注意力全在特效上根本不在乎题目内容。后来改成只在正确按钮上做局部放大和颜色变化再配一个短音效学习效率反而更高。正确反馈要“确认”错误反馈要“温和”这是儿童产品设计和普通游戏设计的本质区别。4. 状态管理与数据持久化落地4.1 状态管理用哪些别乱上全局HarmonyOS 的状态管理工具很多但我的原则是“够用就好”。这个应用页面间数据耦合不重所以大部分场景用State就够了。父子组件传参时如果只需要父传子用Prop如果子组件要改父组件的状态用Link。两者区别简单说Prop是单向副本子组件改了不影响父组件Link是双向同步子组件改了父组件也会跟着变。小应用里尽量少用Link因为双向绑定多了以后数据流会变得难排查。跨页面共享的数据比如每个数字获得了多少颗星我用AppStorage加PersistentStorage来做。AppStorage是运行时全局存储PersistentStorage会把指定属性持久化到本地。定义一个StorageLink(star_6)之类的状态页面内修改后下次启动还能读回来。这个组合比手动读写 preferences 简单适合轻量场景。4.2 星星和最高分怎么保存虽然PersistentStorage用起来方便但它的属性是写死的不太适合动态保存“6-10 每个数字的星星数”。所以我采用preferences来做键值存储封装了一个工具类// utils/ScoreStore.ets import { preferences } from kit.ArkData; import { common } from kit.AbilityKit; const PREF_NAME math_game_pref; export class ScoreStore { static async saveStar(context: common.UIAbilityContext, number: number, star: number) { const pref await preferences.getPreferences(context, PREF_NAME); await pref.put(star_${number}, star); await pref.flush(); } static async loadStar(context: common.UIAbilityContext, number: number): Promisenumber { const pref await preferences.getPreferences(context, PREF_NAME); const value await pref.get(star_${number}, 0); return value as number; } }在页面里通过getContext(this)拿到 UIAbilityContext 后调用。为什么用preferences而不是文件因为数据量很小就是几个数字的星星数键值存储最合适如果以后要记录每道题的作答历史再用关系型数据库RdbStore不迟。还要注意flush()是异步的但通常可以直接await确保数据落盘后再切换页面。4.3 家长区与防误触设计儿童应用有个特别的需求孩子会乱点可能会把进度清零也可能误触返回键退出。我的处理是首页左上角不显眼地放一个“星星总数”文本长按 5 秒才进入家长设置页。在家长设置页里只有两个功能查看每个数字的掌握进度、清空所有记录。清空操作需要弹窗二次确认避免误触。技术上只要用onClick加一个计时器判断长按时长即可不需要申请任何权限。另外我在主页面用onBackPress拦截了返回键在练习过程中误按返回会先弹确认框而不是直接退出。这个细节看起来不大但对孩子体验很重要。一个三四岁的娃误退后很难自己重新进到刚才的页面很可能就直接关掉应用不玩了。4.4 数据驱动 UI 的小技巧实际开发中我踩过一个典型的坑ForEach的 key 不稳定。比如数序挑战页里我用(num, index) ${num}_${index} 作为 key数组删除元素后 index 会改变导致组件重建。小数据量下影响不大但一旦列表变长会出现动画闪烁或状态丢失。正确做法是给每个数据项一个稳定且唯一的 id如果数字本身不重复直接用数字做 key 就够了有重复项时才需要拼接其他字段。这件事不复杂但很多人会忽略最后查半天才发现是 key 写错了。5. 真机调试与常见问题排查5.1 音频不响或重复播放卡顿这个应用里有大量短音频点数字要响、答对要响、答错也要响。第一版我图省事每次播放都新建一个AVPlayer结果真机上频繁点击时会出现“声音越来越卡最后没声”的情况。原因是每个播放器实例都占用底层解码资源短时间创建销毁大量实例资源没来得及释放。解决办法是维护一个单例播放器每次播放前重置数据源// utils/AudioPlayerUtil.ets import { media } from kit.MediaKit; export class AudioPlayerUtil { private static player: media.AVPlayer | null null; static async playNumber(num: number) { if (!this.player) { this.player await media.createAVPlayer(); } const avPlayer this.player; avPlayer.url resource://RAWFILE/audio/number_${num}.mp3; await avPlayer.prepare(); avPlayer.play(); } }这里要注意AVPlayer的状态机prepare完成后才能play要在play前监听stateChange或者直接await prepare()。另外 rawfile 路径大小写非常严格audio目录名和文件名都不能写错否则真机上一直报资源加载失败预览器却不报错。5.2 跳转后拿不到参数首页点击某个数字后我用router.pushUrl传参跳转到数字卡片页但有时候在aboutToAppear里取不到参数。后来发现是生命周期问题aboutToAppear触发时页面参数还不一定准备好尤其是带复杂对象的参数。解决方法是把参数读取放到onPageShow或者直接放在build首次渲染要使用的状态初始化逻辑里用router.getParams()读取const params router.getParams() as Recordstring, number; if (params params.num) { this.currentNumber params.num; }如果用了新版Navigation组件的NavPathStack则参数读取时机又不一样要在onReady回调里通过pathStack.getParamByName获取。所以我的建议是小应用用router简单直接项目导航层级复杂时再上Navigation不要两种混用否则参数传递时机很容易出错。5.3 数组改了界面不刷新有一次我收到反馈数量感知练习里连续点击同一题的不同选项得分文字不变。排查下来发现我在代码里直接写了this.question.options[0] 8这种修改方式不会触发 ArkUI 的刷新。原因很简单框架监听的是整个State变量的赋值而不是数组内部元素的变更。你必须让状态对象本身发生“变化”最常见的修复方式是整体重新赋值this.question new Question( this.question.id, this.question.target, this.question.options.map((v, i) i 0 ? 8 : v) );或者把数据类标记为Observed组件里用ObjectLink接收这样修改对象属性也能触发刷新。但对于这种小应用我更建议“扁平化状态”尽量拆成简单变量避免嵌套对象带来的状态追踪复杂度。5.4 布局适配和误触问题真机调试时我发现预览器里看起来合适的圆点大小到了真机上偏小。原因是预览器窗口默认尺寸和手机屏幕不一样只用vp不用百分比布局就会出问题。后来我把圆点区域改成Grid且宽高按父容器比例计算圆点大小在组件内部根据容器宽度动态算才解决了手机和平板的差异。误触问题也值得单独说父子组件都绑了点击事件时点孩子会连父组件的事件也触发。这个场景在首页网格里特别明显我点数字卡片时总是先弹出数字认知页面后来才发现是Grid容器也加了onClick。解决方案是用hitTestBehavior控制事件穿透比如HitTestMode.Block让子组件完全消费点击事件父组件不再响应。另外所有可点击区域尽量做到不小于 48vp 见方这是儿童手指操作的最低舒适尺寸。5.5 卡顿与内存按这个应用的体量理论上不会卡。但我测试时还是发现了一个隐患在排序成功动画里我用ForEach连续生成了很多小圆点做庆祝效果动画结束后没有及时清理状态数组导致内存慢慢增长。后来改成动画播放完就清空额外节点问题消失。所以即使是轻量应用也要养成习惯临时创建的 UI 数据用完要释放。如果以后要加历史记录列表或统计图表记得用LazyForEach替代ForEach避免一次性创建大量组件。6. 写在最后一点实操体会这次开发给我最大的感受是儿童教育应用的功能复杂度不高真正花时间的是教学逻辑的取舍。我把圆点按 5 个一行排看似只是布局其实背后是“凑十”的铺垫我把干扰项限制在相邻数字看起来是程序员的随机逻辑其实是刻意制造认知冲突。HarmonyOS Next 的 ArkUI 在这种小体量应用上很顺手状态驱动写起来比传统命令式省心不少文档和调试工具也算齐全。后续我想给这个应用加入“数的组成”和“比大小”再试试用分布式能力让家长在平板上看到孩子的练习记录。如果你也在做类似的儿童应用我的建议是先拿纸笔画清楚“学会”的定义再写代码不然最后只是在做一个点按钮的玩具。