这周终于把《6-10的认识——数量感知与数序》这个小应用在 HarmonyOS Next 上跑通了,正好作为实例系列的第七篇。之所以选这个题目,是因为我家娃正好卡在“认数”阶段:能流利地从 1 唱到 20,但问她“这里有 8 个圆点吗”,她会瞎猜,点数也经常会漏掉或重复。这个应用就是用 HarmonyOS 原生能力解决这个问题的:整体用 ArkTS + ArkUI 纯声明式开发,基于 HarmonyOS Next SDK(API 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 的后面是 10,8 的前面是 7 | 数序缺失题 | 3 次练习中能独立完成 |
这张表不是写着好看的,它直接决定了页面上要放什么控件、出什么题。比如“数量感知”要求孩子扫一眼报数,那 UI 上就不能允许孩子一个一个点着数,圆点要出现一段时间后自动消失,逼孩子用整体感知;而“数物对应”则在卡片页保留圆点,允许孩子慢慢点数。同一个数字在不同模块里呈现方式不同,这才是教学逻辑驱动设计,而不是把一堆效果堆上去。
1.3 页面结构和用户路径
整个应用拆成四个页面:首页、数字认知卡片页、数量感知练习页、数序挑战页。首页用宫格里展示 6、7、8、9、10 五个数字,点选一个数字后可以进入数字认知卡片页;也可以直接进入数量感知练习或数序挑战。为什么不做成一页滑到底的长页面?因为儿童使用时需要清晰的任务边界,一屏一个任务能减少注意力分散;页面跳转也天然起到了“这个任务结束了”的提示作用。
另外,页面间相互独立还有一个好处:后续想加“比大小”“数的组成”模块时,不需要改动已有页面,只需要在首页增加入口。这一点在做需求规划时就要想清楚,不是技术难,而是结构决定了扩展的灵活度。
2. HarmonyOS Next 技术底座与工程结构
2.1 环境版本与工程初始化
开发环境我用的是 DevEco Studio 5.0.0(12),对应 HarmonyOS Next SDK,API 版本 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 应该是 5+1,8 应该是 5+3。每行固定 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): Promise<number> { 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 Record<string, 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 在这种小体量应用上很顺手,状态驱动写起来比传统命令式省心不少,文档和调试工具也算齐全。后续我想给这个应用加入“数的组成”和“比大小”,再试试用分布式能力让家长在平板上看到孩子的练习记录。如果你也在做类似的儿童应用,我的建议是先拿纸笔画清楚“学会”的定义,再写代码,不然最后只是在做一个点按钮的玩具。