家里有个刚上一年级的小朋友,辅导作业时你大概率会经历这样的画面:13减8,他在草稿纸上画了一排圈,数了半天,最后抬头问你“为什么3不够减,要向前面借一个?”个位不够减、要向十位借一当十,这个“位值”概念成人都懂,但对刚接触两位数的小朋友来说,它完全是另一套抽象规则。破十法就是给抽象规则搭一座桥:把13拆成10和3,先用10减8得2,再算2加3得5,把退位减法转成两步已经会的10以内口算。道理不复杂,难的是让孩子真正看见“拆、减、合”的过程。
本系列做到第十二篇,正好赶上家里小朋友学这个知识点,我就用HarmonyOS的ArkTS写了一个20以内退位减法的破十法动画演示器:自动生成36道核心退位题,用五步分镜动画展示从拆分到运算的完整路径,支持自动播放和手动翻页。这篇文章里,我会把题库生成的数学约束、分镜设计、Canvas绘制实现,以及实测时踩过的三个坑完整放出来。适合正在做儿童数学启蒙应用、或者想用ArkTS写Canvas动画的开发者参考。
1. 先弄明白退位减法为什么难教:破十法的数学本质
1.1 从“不够减”到“借一当十”:孩子到底卡在哪
退位减法难,难在它同时涉及两套计数规则。第一套是“十进制”:十位上的1代表10个一;第二套是“减法”:个位相减不够时要跨位。比如13减8,个位3减去8不够,必须从十位取1个十,变成10个一,和原来的3合起来是13个一,然后从里面拿走8个一。这个过程里,孩子要理解“十位上的1可以被拆成10个一”,这就是位值的核心。可大多数孩子在一上阶段,数的认识还停留在“数几个点”的水平,你突然让他把1当成10,他当然会懵。
很多家长会让孩子背口诀:“不够减,向前借,借一当十。”但这个口诀是操作指令,不是数学理解。背下来会算13减8,遇到17减9还是不会变通,因为他不明白“为什么要借”。破十法的关键,恰恰是把“借一当十”这个抽象动作,变成看得见的“拆分”:13就是1个十和3个一,十位上那个1,本来就是10个一。孩子看到10个蓝点摆在那里,才能真正理解“借”来的不是凭空多出来的,而是本来就藏在十位里的。
1.2 破十法与连减法,为什么选破十做演示
20以内的退位减法,教材里通常讲两种思路:破十法和连减法(也叫平十法)。两个方法的区别在拆分对象不同:
| 策略 | 拆的对象 | 运算路径 | 孩子的记忆负担 |
|---|---|---|---|
| 破十法 | 13拆成10和3 | 10-8=2,2+3=5 | 两步,都是10以内口算,结果一眼可见 |
| 连减法(平十法) | 8拆成3和5 | 13-3=10,10-5=5 | 两步,但中间数10要临时记住,多一次拆 |
破十法先用10去减,天然对应“从十位拆出10”的位值理解,而且“10减几”的结果就是“几的补数”,图形上能直接看出来。动画演示破十法还有个额外好处:10个圆点被划掉8个,剩下2个,“10-8=2”这个算式和图形一一对应,孩子可以直接数出结果。连减法里“8拆成3和5”的分法画起来相对绕,得先在13里划掉3个,再划掉5个,图形语义不够直接。所以这个演示器锁定破十法,不是拍脑袋,而是基于图形和算式对应关系做出的取舍。
1.3 静态图解和视频为什么代替不了交互演示
练习册上的破十法图解通常是这样:13画成一捆小棒加3根,然后给10根那一捆画一个箭头,劈开,划掉8根,旁边写“10-8=2”。这个图是结果,不是过程。孩子看到的是“已经拆好、已经减完”的成品,中间“为什么这样拆”“谁先谁后”全靠脑补。视频能展示过程,但孩子往往在最需要停下来看的“拆十”环节停不下来,等他想明白了,画面已经播到结果。
交互式演示器解决的是“节奏由孩子掌握”。每按一次“下一步”,只展示一个关键转变:看到13变成10+3,再看到10里划掉8,再看到2和3合并成5。每个画面可以反复回退,配合语言讲解,孩子就能把“拆—减—合”三个动作和算式对应起来。这个需求用HarmonyOS做很顺手,ArkTS的声明式状态管理和Canvas绘制,刚好覆盖了“以状态驱动动画”这种教学工具的核心场景。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 36道退位题从哪来:题库生成的数学约束与完整代码
2.1 “哪些题需要破十”——退位减法的边界
20以内的退位减法,是指被减数在11到19之间、减数在2到9之间、而且个位不够减的减法。这里有个容易被忽视的约束:不是所有“十几减几”都需要破十。13减8需要,因为3小于8;15减3不需要,因为5大于3。判断标准就是被减数的个位数小于减数。如果个位够减,直接减就好,再演示破十反而会把简单问题复杂化。
按这个规则穷举,被减数从11到19,每个数对应多少道题一目了然:
| 被减数 | 个位 | 允许的减数 | 题目数 |
|---|---|---|---|
| 11 | 1 | 2~9 | 8 |
| 12 | 2 | 3~9 | 7 |
| 13 | 3 | 4~9 | 6 |
| 14 | 4 | 5~9 | 5 |
| 15 | 5 | 6~9 | 4 |
| 16 | 6 | 7~9 | 3 |
| 17 | 7 | 8~9 | 2 |
| 18 | 8 | 9 | 1 |
| 19 | 9 | 无 | 0 |
总数是8加7加6加5加4加3加2加1,一共36道。这36道就是20以内退位减法的全部核心题型,小学口算里翻来覆去练的,也就是这些。
2.2 生成36道题的完整逻辑
规则清晰之后,就可以用两层循环生成。外层遍历被减数11到19,内层遍历减数2到9,判断条件就是个位小于减数。写成代码非常短:
typescript复制function generateQuestions(): Array<[number, number]> {
const result: Array<[number, number]> = [];
for (let minuend = 11; minuend <= 19; minuend++) {
const ones = minuend % 10;
for (let subtrahend = 2; subtrahend <= 9; subtrahend++) {
if (subtrahend > ones) {
result.push([minuend, subtrahend]);
}
}
}
return result;
}
这个判断条件翻译成数学语言就是“退位条件”:减数大于个位时才需要借十。注意我没有让减数等于1或10。减数等于1时,任何十几减1个位都够减,不需要破十;减数大于9,又超出了20以内减法的范围。范围定在2到9,是跟小学数学口算范围对齐的。你如果打开调试日志跑一遍,会发现返回数组的长度正好是36,和穷举表一致。
2.3 洗牌、难度分层与“顺子模式”
生成36道题之后,如果每次打开都从11减2开始,孩子很快会产生路径依赖——不是学会了破十,而是记住了这道题的答案。所以要洗牌。我在应用里用了Fisher-Yates洗牌,代码短,随机性对36个元素来说也足够好:
typescript复制function shuffle(arr: Array<[number, number]>): void {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
}
难度的调节放在另一个维度:按减数分组。8、9作减数的题最难,因为“10减8、10减9”本身就不容易;5、6作减数的题相对平缓。我做了一个分组练习模式,先只出减数5以内的题,再慢慢加。另外还有一个“顺子模式”,按被减数从小到大出题,方便老师在课上按顺序讲。三种模式对应同一套题库,只是排序和筛选不同,改动成本很低。
3. 分镜设计:把破十法翻译成可回放的动画状态机
3.1 圆点筹码:为什么放弃教材里的小棒
很多数学App喜欢用小棒演示破十法,因为教材里就是小棒。但真在Canvas里做就会发现,小棒难画:一捆10根要用斜线捆起来,散开时要保持每根间距一致,减去8根时还得从捆里拆出8根,绘制坐标的计算量远大于圆点。我最终决定用圆点筹码,原因有二:圆点位置可以按行列坐标精确计算,5个一行的网格里,10个点就是两行,谁多谁少一眼能看出来;圆点也方便做位移和缩放的插值动画。
颜色上我做了严格约定:十位上的1,也就是被拆出来的10个点,用蓝色;个位原有的点,用橙色。以13为例,画面里就是两行蓝点(每行5个)加3个橙点。这个颜色约定在整个演示过程中不变,孩子只需要盯住蓝色部分的变化。每次换题,蓝点永远是10个,变化只在橙点的数量和位置,这本身就是一种模式化认知。
3.2 五步分镜:从完整算式到结果归位
动画真正的核心,是把破十法的运算步骤翻译成一组分镜。我用了五个步骤,每个步骤内部还有自己的小动画:
- 第0步(初始):完整显示13个点,两行蓝点加3个橙点,算式“13-8=?”。
- 第1步(拆十):3个橙点不动,10个蓝点向右上方微微移动,同时出现一个虚线框把它们框住,算式位置显示“13=10+3”。
- 第2步(去十):虚线框内,8个蓝点依次被红叉划掉,虚线框内剩下2个蓝点,算式显示“10-8=2”。
- 第3步(相加):剩余的2个蓝点从虚线框滑出,和3个橙点汇合到中部区域,5个点自动排成一行,算式显示“2+3=5”。
- 第4步(结果):5个点旁边出现一个等号和5,整屏归拢为完整算式“13-8=5”。
每个步骤都要给足停留时间。我给每步的基础时长是900毫秒,但第2步“去十”单独设成1200毫秒,因为要容纳8个红叉依次出现。如果孩子点了暂停,画面就停在那一步,方便家长或老师就地讲解。
3.3 用step+progress做可回放状态机
我把动画状态拆成两个变量:step,表示当前处于哪个分镜;progress,表示当前分镜内部的播放进度,范围0到1。这两个变量一组合,任何时刻的画面都是确定的。“上一步”按钮很简单:step减1,progress归0。“下一步”则是step加1,progress归0。回放时不需要倒着执行动画,只需要切到目标step,重新播一遍。
绘制时每个元素的坐标都从这两个变量算出来。比如第1步拆十,10个蓝点的目标位置在虚线框内,那么蓝点当前坐标就是原位置和框内位置的插值:
typescript复制pos = startPos + (endPos - startPos) * easeInOut(progress)
easeInOut我用的是一个缓动函数,避免点位移显得生硬。所有子元素都遵循同一套插值公式,整体节奏就会很统一。这个设计的意义在于,播放、暂停、跳步、回退,本质上都只是改变step或progress,画面自动跟随,不会出现“回退后动画状态错乱”这种问题。
4. 用ArkTS把动画画出来:Canvas绘制与帧驱动实现
4.1 页面骨架:一个声明式页面装下所有内容
整个页面用ArkTS写,结构不复杂。顶层是纵向排列:题目文本、Canvas画布、按钮区。画布宽度撑满,高度固定320,按钮区横向排开四个按钮。
typescript复制@Entry
@Component
struct BreakTenDemo {
@State currentStep: number = 0;
@State progress: number = 0;
@State minuend: number = 13;
@State subtrahend: number = 8;
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private canvasContext: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
private timerId: number = -1;
build() {
Column({ space: 16 }) {
Text(`${this.minuend} - ${this.subtrahend} = ?`)
.fontSize(32)
.fontWeight(FontWeight.Bold)
Canvas(this.canvasContext)
.width('100%')
.height(320)
.onReady(() => {
this.drawFrame();
})
Row({ space: 12 }) {
Button('上一步').onClick(() => this.prevStep())
Button(this.playing ? '暂停' : '播放').onClick(() => this.togglePlay())
Button('下一步').onClick(() => this.nextStep())
Button('复位').onClick(() => this.reset())
}
.width('100%')
.justifyContent(FlexAlign.Center)
}
.width('100%')
.height('100%')
.padding(16)
}
}
你可能会问:画布里的内容怎么更新?答案是在每个需要刷新的时机,显式调用drawFrame()去重绘Canvas。ArkTS的Canvas并不会因为某个@State变量改变就自动重绘,要靠CanvasRenderingContext2D手动绘制到画布上。所以我把重绘封装成drawFrame(),在任何状态变化后调用它。
4.2 Canvas绘制:圆点、虚线框、红叉的API用法
绘制部分没有特别高深的东西,无非是圆、线、矩形。圆点用arc,虚线框用setLineDash,红叉用两条线段:
typescript复制private drawDot(x: number, y: number, r: number, color: string) {
this.canvasContext.beginPath();
this.canvasContext.arc(x, y, r, 0, Math.PI * 2);
this.canvasContext.fillStyle = color;
this.canvasContext.fill();
}
private drawDashedBox(startX: number, startY: number, w: number, h: number) {
this.canvasContext.beginPath();
this.canvasContext.setLineDash([6, 4]);
this.canvasContext.strokeStyle = '#888888';
this.canvasContext.lineWidth = 2;
this.canvasContext.strokeRect(startX, startY, w, h);
this.canvasContext.setLineDash([]);
}
private drawCross(x: number, y: number, size: number) {
this.canvasContext.beginPath();
this.canvasContext.strokeStyle = '#FF3B30';
this.canvasContext.lineWidth = 2;
this.canvasContext.moveTo(x - size, y - size);
this.canvasContext.lineTo(x + size, y + size);
this.canvasContext.moveTo(x - size, y + size);
this.canvasContext.lineTo(x + size, y - size);
this.canvasContext.stroke();
}
画圆点时有个小技巧:先定义好每个点基于“原点”的行列偏移,再对外传一个整体偏移量。这样做位移动画时,只需要修改整体偏移量,所有点就一起动了,不需要逐个点重算坐标。
4.3 帧驱动:定时器累加progress,并在每帧重绘
动画驱动我用了最直观的定时器方案。开启播放时,每隔16毫秒累加一次progress,超过1就切到下一步;每帧都调用drawFrame()重绘:
typescript复制private startPlay() {
if (this.timerId !== -1) {
clearInterval(this.timerId);
this.timerId = -1;
}
this.timerId = setInterval(() => {
this.progress += 0.02;
if (this.progress >= 1) {
if (this.currentStep >= 4) {
this.pause();
return;
}
this.currentStep++;
this.progress = 0;
}
this.drawFrame();
}, 16);
}
你可能觉得16毫秒累加0.02不够精确,实际上用固定增量比用“每帧实际经过的时间”更稳定。setInterval本身有误差,如果按真实时间计算,动画时长会忽快忽慢;固定增量会造成总时长略有偏差,但教学场景完全够用。我也试过用requestAnimationFrame做帧循环,效果一样流畅,但setInterval在这个场景里代码更少,也就没有刻意换成帧回调。
4.4 不同分镜的坐标插值:让每步动画“演”得有逻辑
drawFrame()里要根据currentStep和progress算出每个圆点当前该在哪。我维护两个数组,一个记录10个蓝点的基准位置,一个记录3个橙点的基准位置,再各自维护目标位置:
typescript复制private drawFrame() {
this.canvasContext.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
const t = this.easeInOut(this.progress);
switch (this.currentStep) {
case 0:
// 所有点都在初始位置,直接绘制
this.drawAllDots(this.startBluePos, this.startOrangePos);
break;
case 1:
// 蓝点从初始位置向虚线框位置插值移动
const bluePos = this.interpolatePoints(this.startBluePos, this.boxBluePos, t);
this.drawAllDots(bluePos, this.startOrangePos);
this.drawDashedBox(this.boxX, this.boxY, this.boxW, this.boxH);
break;
case 2:
// 蓝点固定在框内,根据t依次划掉前8个
this.drawDotsWithCross(this.boxBluePos, this.startOrangePos, Math.floor(t * 8));
this.drawDashedBox(this.boxX, this.boxY, this.boxW, this.boxH);
break;
case 3:
// 剩余2个蓝点和3个橙点向中间汇合
break;
case 4:
// 所有点在中部汇齐,显示结果
break;
}
}
第2步“去十”的细节值得多说一句:红叉不是一下子全画出来,而是按progress依次画在前8个被减去的蓝点上。具体做法是把蓝点数组从左到右编号,第0到第7个在progress从0到1的过程中逐渐被红叉覆盖,第8和第9个始终保持完好。这样孩子能看到“8个被划掉、还剩2个”的动态过程,比一下出现8个红叉更符合破十法的语义。
5. 实测记录:三个影响体验的坑和修复过程
5.1 坑一:定时器被反复启动,动画越跑越快
第一版在真机上一跑,点击播放按钮后画面快到根本看不清,几秒就把五步播完。我以为是setInterval精度问题,后来发现是点击一次播放,就创建了一个新的定时器,旧定时器没有被清除。系统里同时跑着两三个定时器,progress叠加速度自然翻倍。
修复很简单:startPlay()开头先clearInterval旧的定时器,并把timerId重置为-1。这属于最基础的定时器管理,但真机调试时非常容易忽略。另外,我在组件的aboutToDisappear()里也做了清理,整页退出时绝不让定时器残留。HarmonyOS的组件生命周期和Web前端不太一样,页面销毁后定时器如果还在运行,轻则耗电,重则崩溃,这个习惯一定要养成。
5.2 坑二:数字结果比动画先出现
第二版出现了一个很反直觉的问题:动画还在播,画面下方的答案文本已经变成了5。排查后发现,文本显示逻辑直接判断了currentStep,只要currentStep大于等于4,就显示“=5”。但progress还在0到1之间,画面根本还没汇合到5个点。
修的时候我把“显示什么数字”也改成从视觉状态推导。我写了一个getVisualAnswer(),它不看currentStep,而是看当前画面实际还剩多少个完成态的点。只有在所有点已经到达最终位置、红叉全部消失时,才返回真结果;否则返回一个问号或者当前可见点数。核心原则就一条:界面上的所有文字,必须和画布内容同源,不能各管各的。文字抢跑一帧,孩子的注意力就会被严重带偏。
5.3 坑三:复位后画面一动不动
还有一个更隐蔽的坑。点击复位,currentStep回到0,progress回到0,但画布没有重绘。原因在于我给Canvas绑定的刷新函数是“如果状态没变化就不重绘”,而复位瞬间某些变量恰好和初始值相同,绘制函数认为无需更新。加上CanvasRenderingContext2D是手动绘制,并没有依赖ArkTS的自动diff,所以漏掉了重绘。
解决方法是加一个version计数器,每次状态强制变化就加1,drawFrame()开头无条件重绘。我干脆把所有按钮点击路径都统一走一遍“setStateAndRedraw()”,保证凡是状态变化,必然调用一次绘制。这个版本之后,再也没出现“按了按钮画面不动”的情况。
5.4 真机与模拟器差异:性能问题的定位思路
最后是性能层面的观察。模拟器上动画非常顺,一度让我以为没有问题;换到真机,尤其在低端平板上,偶尔出现掉帧。分析下来不是绘制太重——这个页面最多19个圆点,负担很小——而是定时器叠加或者文本频繁刷新引起的。优化方向就两个:一是严格定时器管理,二是把每帧重绘中不变的绘制对象缓存起来,不要每次clear之后重新计算。等把这两处改完,真机就稳了。
做这类儿童教育应用,还要考虑屏幕常亮。孩子思考时间可能很长,我建议在演示播放时把屏幕设为常亮,暂停或结束时恢复自动熄屏。这个小细节对课堂使用体验提升很大,很多家长反馈平板搁一会儿就熄屏,特别打断节奏。
6. 让演示器真正服务教学的几个细节
6.1 每步一句口诀:把画面和算式讲给孩子听
动画做得再直观,也要配语言。我在每个分镜下方放了一句口诀:第1步“把13拆成10和3”,第2步“先用10减8,得2”,第3步“2加3等于5”,第4步“13减8等于5”。这些文案参考了小学数学课堂上常用的表达。有条件的话,可以用系统TTS朗读,或者自己录一小段音频。实测下来,语音比文字更有效,因为孩子的视线在画布上,低头看文字会打断动画节奏。
6.2 自动轮播和手动点按,两种模式都要
家长辅导和老师投屏,使用节奏完全不一样。老师课堂上需要连续放完整个演示,学生再跟着操作;家长一对一辅导时,孩子往往想停在某一帧慢慢琢磨。所以应用里保留了自动播放和手动翻页两种模式。自动播放的默认间隔我设定为1秒,但这不重要,真正实用的是手动模式下每一步都允许“上一步”回退。这个需求是在实测中出现的,一开始只做了“下一步”,后来孩子说想再看一遍拆十那一步,我才加了回退按钮。
6.3 换个分镜就是“凑十法”:同一套框架的扩展
做完了破十法,很容易想到凑十法。20以内的进位加法,比如9加5,通常用“凑十法”:把5拆成1和4,9加1得10,10加4得14。和破十法对照着看,结构几乎一样:都要完成一次拆分,都要先凑出10,都是10以内的加减法加一步。复用的思路是:题库生成条件改成“被加数9到19,加数是2到9,且个位相加结果超过10”,分镜从“拆13”变成“拆5”,文案从“先算10减8”变成“先算9加1”。换汤不换药,一个下午就能改出来。这种扩展价值,比单独做一个新页面高得多。
6.4 一点实际使用心得
如果你也想做给孩子用的教学工具,我的体会是:动画别做得太花。这个演示器最终只有圆点位移、红叉和虚线框,没有任何粒子特效,但孩子上手后反而很容易安静下来——因为每一帧都对应一个可以数、可以猜的数学事实。把节奏交给孩子,比替孩子炫技更有用。后来有位家长朋友反馈,他家小朋友喜欢把演示器停在“第2步去十”的画面,自己按着圆点数:“1、2……8,被划掉8个,还剩2个。”那个瞬间我就知道,这套分镜设计算是做对了。如果你正在做类似的启蒙工具,不妨也先想清楚“动画到底在解释什么”,再动手写第一行Canvas代码。
