ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

HarmonyOS Canvas实现小数乘法面积模型教学交互工具

HarmonyOS Canvas实现小数乘法面积模型教学交互工具 这一篇和这个系列前面的大多数都不一样。前80篇我基本都在写功能类应用请求适配、文件解析、地图定位跑起来有明确的功能边界。而第81篇《小数乘法动态面积模型》是我做完以后觉得最有后劲的一个——代码量不大核心就一个Canvas加两个Slider它的价值在于把0.3 × 0.4为什么等于0.12这件事变成孩子看得见、拖得动的图形。给家里小朋友讲了二十分钟效果比我当年学小数乘法时老师画在黑板上的静态图好太多。于是我把这个教学交互工具做成了一个HarmonyOS应用实例运行在HarmonyOS Next SDKAPI 12 / 5.0.0(12)上全程ArkTS ArkUI声明式语法没有任何第三方依赖。这篇文章的记录方式也按我实际开发的顺序来先说清楚面积模型到底是什么、为什么它对小数乘法教学有效再讲工程怎么搭、Canvas怎么画然后是最关键的换算逻辑和重绘交互最后把实机用下来踩到的细节坑和扩展玩法一并交代。无论是想学Canvas绘图的开发者还是做教育类应用的同行应该都能从中拿走一点能直接用的东西。1. 为什么面积模型能讲清楚小数乘法——先解决反直觉的问题1.1 整数乘法的旧直觉在小数这里失灵了在教小数乘法之前孩子脑子里已经根深蒂固地建立了乘法就是重复加法的模型。3 × 4 12怎么解释都行3个4相加或者4个3相加。等号右边大于等号左边的任何一个因数这个规律在孩子做了几百道整数乘法题之后几乎成了一条铁律。小数乘法一来这条铁律当场碎裂。0.3 × 0.4 0.12结果比0.3和0.4都小。孩子第一反应是这不可能第二反应是老师你让我背规则我就背但我不信。竖式能算出正确答案但算完之后没有形成任何关于为什么的认知下次遇到0.7 × 0.8 0.56照样懵。这不是孩子笨是重复加法这个工具在一开始就不适配小数场景——你没法直观地说出0.3个0.4相加到底是个什么东西。1.2 面积模型把乘法重新定义了一遍面积模型的核心思路是把乘法理解为矩形面积而不是重复加法。单位正方形表示数字1把它分成10 × 10共100个小格每个小格就是0.01。此时0.3是横向的3列0.4是纵向的4行两者相交的区域恰好是3 × 4 12个小格。12个小格每个0.01合起来0.12。整个推导链条没有一步需要背列数、行数、格子数、每格的大小全都在图上画着。孩子看到的是两个小数相乘其实就是把边长是小数的一个长方形面积框出来面积大小就是这个乘积。整数的越乘越大只是面积模型的一个特例——当一个因数是1或者更大时矩形自然比单位正方形大当两个因数都小于1时矩形只是单位正方形里的一块角落结果当然小于1也小于任何一个因数。我做应用的时候特别注意了这一点网格一定要是完整的10 × 10不能只画高亮部分。完整网格让孩子看到整体是1这个参照系。只看高亮区域的话0.3是3列就失去了意义——你根本不知道3列占了整体的多少。1.3 静态图够用但动态交互才是真正的杀器说实话这个教学原理不是我的原创小学数学教材里就有面积模型图。但教材里的图是印死的永远只画着一两个固定算式。我做成应用后孩子可以自己拖动滑块从0.1拖到1.0每一档的变化都实时反映在网格上。他试0.2 × 0.4试0.7 × 0.3试1.0 × 0.5试完还会故意把两个滑块都拉到最大看1.0 × 1.0 1.00的样子。这个过程里他实际上是在自己做实验验证规律而不是被动接受我告诉他的结论。这个区别很关键。教育类应用最怕做成电子课本——把纸面上的内容搬上屏幕只是多了一个翻页动画。真正有价值的是利用数字世界的可交互性让孩子动手探索。一个Slider加上一个Canvas成本极低但带来的学习深度完全不是静态图能比的。2. 工程脚手架与绘图画布的准备——HarmonyOS Next环境下的最小实现2.1 工程创建与路由注册开发环境是DevEco Studio配HarmonyOS Next SDKAPI 12也就是5.0.0(12)。创建一个Empty Ability工程后我在entry/src/main/ets/pages目录下新建了页面文件DecimalAreaModel.ets然后在resources/base/profile/main_pages.json里注册路由{ src: [ pages/Index, pages/DecimalAreaModel ] }这个应用不需要任何三方库也不需要网络权限所以module.json5里的配置几乎不用动。整个工程依赖的核心组件就两个Canvas用来画网格Slider用来调因子。这也是我系列里少见的零依赖实例很适合作为HarmonyOS Canvas绘图入门的第一课。2.2 Canvas的生命周期onReady才是绘制的起点接触ArkUI Canvas的第一个坑就是绘制时机。很多人在aboutToAppear里调用绘制方法结果发现画布空白。原因在于aboutToAppear执行时组件还没有完成布局Canvas的宽高是0或者默认值画了等于白画。更稳妥的做法是把绘制动作放到Canvas的onReady回调里——这个回调触发时Canvas组件已经完成了尺寸测量和渲染上下文准备。我在组件里维护一个CanvasRenderingContext2D实例private settings: RenderingContextSettings new RenderingContextSettings(true) private context: CanvasRenderingContext2D new CanvasRenderingContext2D(this.settings)然后在build方法里创建Canvas组件Canvas(this.context) .width(94%) .aspectRatio(1) .backgroundColor(#ffffff) .border({ width: 1, color: #dddddd }) .onReady(() { this.drawAreaModel() })aspectRatio(1)这个属性值得单独说。它强制Canvas保持1 : 1的宽高比也就是正方形。这在面积模型里是刚需——网格必须是正方形一旦拉伸成长方形每个小格就不是每格0.01的直观效果了。我用的是竖屏布局让Canvas撑满94%的宽度通过aspectRatio自动推导高度这样无论手机屏多宽画布永远是正方形。还有个体验细节我给Canvas加了浅灰色边框线这能帮孩子明确感知这是一个整体1的正方形的边界。白色背景也是强制的后面会讲为什么不能依赖系统默认背景。2.3 页面层级公式区、滑杆区、画布区、解释区的排布页面的骨架是一个竖直方向的Column从上到下依次是公式展示区、Canvas画布、两个滑块控制器、解释文本。这个顺序是经过实际课堂测试的公式在最上面孩子先看到问题中间是画布看到答案的视觉化呈现滑块紧接着画布方便边拖边看解释文本在最下面作为辅助说明而不是主角。组件树的关键结构如下Column({ space: 16 }) { // 公式区0.3 × 0.4 0.12 Row({ space: 8 }) { Text(...).fontSize(44) Text(×).fontSize(36) Text(...).fontSize(44) Text().fontSize(36) Text(...).fontSize(44).fontColor(#0a59f7) } // 画布区 Canvas(this.context) .width(94%) .aspectRatio(1) // 滑块区 Column({ space: 8 }) { Row() { Text(0.1); Slider({...}); Text(1.0) } Row() { Text(0.1); Slider({...}); Text(1.0) } } // 解释区 Text(蓝色区域表示...) Text(共 n 个小格...) } .padding(16) .expandSafeArea([SafeAreaExtensionType.SYSTEM], [SafeAreaExtensionStyle.TOP])公式区和滑块区都有左右各一个静态文本标明0.1到1.0的范围。这两个端点文本看起来不起眼但它给了滑块一个坐标系的锚点。没有它们孩子拖滑块只知道数字在变不知道0.1和1.0之间到底还有多少档。加上之后滑块就变成了在0.1到1.0之间选择某个值的直观工具。expandSafeArea是让页面内容延伸到状态栏区域避免顶部被遮挡。这个API在API 12里用起来也比较顺手一行搞定刘海屏适配不需要手动量状态栏高度。3. 核心算法从两个小数到高亮矩形的换算3.1 状态设计用整数步数而不是浮点因子这是我在写这个应用时最想分享的实战经验。最初我把两个因数直接存成State frontFactor: number 0.3这样的浮点数Slider的value用frontFactor * 10变更时再除以10写回。结果在真机上出现了匪夷所思的跳变拖到0.4显示却是0.39999999999999997拖到0.7显示0.7000000000000001。虽然toFixed能把这些尾巴截掉但状态内部已经乱了偶尔会计算出9.99999格这种结果。后来我把状态全改成整数步数State frontSteps: number 3 // 表示0.3 State backSteps: number 5 // 表示0.5 private divisions: number 10 // 网格精度Slider的min、max、step全用整数Slider({ value: this.frontSteps, min: 1, max: 10, step: 1 })。这样滑块永远输出整数除以10得到小数乘以10也能精确回到整数。整个程序里一次浮点数乘除都没有从根上消灭了精度问题。这个做法不只是为了省事。在HarmonyOS这种需要写State响应式状态的框架里状态值越简单后续的排查越容易。整数在ArkTS里是严格可比较的而浮点数哪怕展示对了内部的IEEE 754表示也可能带着细小的误差。教学应用尤其不能容忍显示怪异数字——孩子看到0.39999999999999997会直接对应用失去信任。3.2 三层绘制顺序列高亮、行高亮、交集重色drawAreaModel是整个应用的核心逻辑分为四步清空画布、画列方向区域、画行方向区域、画交集区域、最后画网格线。我先给出完整代码private drawAreaModel(): void { const rawWidth Math.floor(this.context.width) const rawHeight Math.floor(this.context.height) // 将画布尺寸强制修正为10的整数倍避免网格线出现半像素模糊 const width Math.floor(rawWidth / this.divisions) * this.divisions const height Math.floor(rawHeight / this.divisions) * this.divisions this.context.clearRect(0, 0, rawWidth, rawHeight) const cellW width / this.divisions const cellH height / this.divisions const highlightCols Math.round(this.frontSteps) const highlightRows Math.round(this.backSteps) // 第一层列方向被乘数占用的区域 this.context.fillStyle #b3d9ff this.context.fillRect(0, 0, cellW * highlightCols, height) // 第二层行方向乘数占用的区域 this.context.fillStyle #ffd9b3 this.context.fillRect(0, 0, width, cellH * highlightRows) // 第三层行与列的交集即乘积结果 this.context.fillStyle #ff8c42 this.context.fillRect(0, 0, cellW * highlightCols, cellH * highlightRows) // 最后画满整个10×10网格线 this.context.strokeStyle #c0c4cc this.context.lineWidth 1 for (let i 0; i this.divisions; i) { const x i * cellW this.context.beginPath() this.context.moveTo(x, 0) this.context.lineTo(x, height) this.context.stroke() } for (let j 0; j this.divisions; j) { const y j * cellH this.context.beginPath() this.context.moveTo(0, y) this.context.lineTo(width, y) this.context.stroke() } }为什么要分三层而不是直接画一个交集矩形因为三层绘制能同时展示三个信息被乘数占了多少列、乘数占了多少行、乘积占了多少格。孩子一眼就能从颜色分布上看出哦0.3是竖着的三列0.5是横着的五行它们碰在一起的地方就是0.15。这正好呼应了面积模型的核心教法——因数分别对应长和宽乘积对应面积。如果只画交集等于把答案涂掉了推导过程。颜色选择上我也花了点心思。浅蓝色#b3d9ff和浅橙色#ffd9b3代表两个因数区域视觉上有明显的冷暖区分但又不刺眼。交集用深橙色#ff8c42饱和度最高面积再小也能一眼锁定。这里有个规律可以分享面积模型应用里交集区域往往是最小的一块所以它的颜色必须是三个色块里对比度最高的不能只改透明度。透明度叠加可能会出现意料之外的混色而我从设计上选择了不透明的三个纯色想要什么颜色就是什么颜色不依赖Canvas的混合模式。3.3 网格线的像素对齐问题网格线的绘制有一个很多入门教程不会提到的细节如果Canvas的实际宽度不是10的整数倍每一格的宽度就会带着小数。比如375px宽的Canvas除以10等于37.5px第3根竖线画在112.5px的位置——这是一个半像素位置最终渲染出来会是一条发虚的灰线像被磨过一样。我的处理方式是在绘制前先把宽度和高度向下取整到10的整数倍const width Math.floor(rawWidth / this.divisions) * this.divisions这会让画布最右边和最下边留出一小块空白但肉眼完全看不出来因为Canvas背景是白色的。换来的是所有网格线都落在整数像素上线条利落清晰投影到教室大屏幕上也不会糊。还有一个相关的小坑一定要在取整之后、绘制之前调用clearRect时使用原始尺寸rawWidth和rawHeight否则上一次绘制残留的半个像素痕迹会留在边缘。我最初就是先计算修正后的width再清空结果Canvas边缘一直有一条若隐若现的旧图残影排查了半天才发现是清空范围没盖满。4. 交互重绘与状态管理Slider怎么驱动Canvas4.1 Canvas不是声明式的它不会自己刷新ArkUI的UI组件是声明式语法状态变了组件自动刷新。但Canvas例外——CanvasRenderingContext2D的绘制本质上是一系列命令式操作画完之后画布上的内容就固化了。State变了组件会重新走build但Canvas不会自动重新执行我的绘制函数因为build里只声明了一个Canvas(this.context)并没有声明画什么。所以每次Slider变化时我必须手动调用绘制方法。最简单的做法是在onChange回调里同步调用Slider({ value: this.frontSteps, min: 1, max: 10, step: 1 }) .layoutWeight(1) .onChange((value: number) { this.frontSteps Math.round(value) this.drawAreaModel() })frontSteps先更新drawAreaModel随后执行两者顺序不能反。因为绘制函数内部会读取最新的width和height变量如果先绘制再赋值这一帧画出来的还是旧值。有人可能会问为什么不用Watch监听frontSteps变化来触发重绘也可以用但要注意Watch回调的触发时机。Watch在状态变化时同步执行而Canvas的绘制如果发生在build渲染阶段的某些节点可能会干扰UI刷新。我在实践中的原则是能用组件回调就优先用组件回调只有跨组件联动才用Watch。这个实例里Slider和Canvas在同一个页面直接调用是最清晰可控的方案。4.2 两个Slider的联动设计两个Slider分别控制被乘数和乘数。它们完全独立互不干扰但共用同一个绘制函数。这意味着任意一个滑动整个画布都要重新计算和重绘。性能上完全不是问题——10 × 10的网格一次全量重绘的消耗可以忽略不计。这里有个交互上的细节值得一说不要把两个Slider堆在同一行。最初我用了一行两个Slider的布局看起来紧凑但实际使用时孩子的左手会挡住右边滑块的范围而且两个滑块横向尺寸被压缩到不足很难精准拖到想要的档位。后来改成竖排两行每个滑块占满整行宽度拖动体验明显提升。竖排还有一个附带好处解释文本里可以明确写上面是第一个因数下面是第二个因数和孩子从左到右的阅读顺序一致。我还给两个Slider加了step 1的档位约束让滑块只能落在1~10的整数档上。这样网格高亮永远是一整列一整列地跳变不会出现0.35个格子这种没法绘制的中间态。从教学角度来说整数档也更适合初期学习——小数乘法的第一步是理解0.1精度等这个基础扎实了再引入0.05精度也不迟。4.3 结果区文本的格式化避免精度陷阱页面上方的公式区需要实时显示类似0.3 × 0.5 0.15的文本。因为状态里存的是整数步数所以文本的计算必须从整数出发Text(${(this.frontSteps / 10).toFixed(1)}) .fontSize(44) .fontWeight(FontWeight.Bold) Text(${(this.frontSteps * this.backSteps / 100).toFixed(2)}) .fontSize(44) .fontWeight(FontWeight.Bold) .fontColor(#0a59f7)这里有两个踩过的坑。第一展示小数因子时用toFixed(1)强制保留一位小数不会出现0.30000000000000004这种显示。第二乘积计算必须放在整数层面完成——frontSteps * backSteps / 100而不是(frontSteps / 10) * (backSteps / 10)。前者是整数乘完除以100结果最多引入一次浮点除法后者是两个已经存在误差的浮点数相乘误差会被放大。实测中3 * 5 / 100在ArkTS里精确得到0.15而0.3 * 0.5可能会得到0.14999999999999999。虽然toFixed(2)最终都能修回来但整数层面计算的习惯一旦养成能省下很多排查时间。乘积文本的字体大小我也特意设为44且使用蓝色加粗。这样即使孩子站得远也能第一时间看到结果的变化。公式区的蓝色和交集区域的深橙色形成了结果总是用特殊颜色标识的视觉约定我很推荐在类似教学应用里建立这种颜色语义——它让孩子不需要读说明就能理解哪些信息是一类的。4.4 解释文本的动态更新画布下方的解释文本也是动态拼出来的Text(蓝色区域表示 ${(this.frontSteps / 10).toFixed(1)} × 1 浅橙区域表示 1 × ${(this.backSteps / 10).toFixed(1)} 深色交集表示乘积 ${(this.frontSteps * this.backSteps / 100).toFixed(2)}) .fontSize(14) .fontColor(#555555) Text(共 ${this.frontSteps * this.backSteps} 个小格每格是 0.01) .fontSize(14) .fontColor(#888888)第二句话尤其重要共n个小格每格是0.01。它把抽象的小数换算拉回到可数的整数——15个小格这个数量孩子是能数的。先数格子再看每个格子的价值最后得到0.15。这个过程完整复现了面积模型的推理链条每次拖动滑块都会重新经历一遍孩子在重复中自然建立了格子数×0.01乘积的神经通路。解释文本我用14号字刻意做得比公式区小一些。原因是我希望孩子先看图和公式自己说出答案再看解释文本确认理解是否正确。如果解释文本做得太大会变成读文字而不是看模型。5. 实测体验与扩展玩法从能跑到好教5.1 真机测试发现的三个细节问题第一个问题是颜色对比度。我把应用装到手机上给孩子试玩时一开始用的是浅灰色网格线加浅色高亮在白色背景下看着很舒服。但有一次在阳光直射的户外使用浅橙色区域几乎看不清。我后来把高亮色整体提饱和了一档并且给Canvas强加了白色背景。这提醒我教学工具必须考虑教室投影、户外屏幕这些非理想场景颜色设计要往高对比、大色差方向走而不是追求所谓的高级感。第二个问题是滑块档位提示不明显。孩子拖动滑块时因为step1滑块是咔哒咔哒地一格一格跳但在视觉上滑块本身没有显示当前值。我加了端点文本0.1和1.0但孩子仍然不知道当前拖到几。后来我在两个滑块中间位置各加了一行小字实时显示当前滑块对应的因子值才解决了这个问题。你也可以用Text组件的${this.frontSteps / 10}实现非常轻量。第三个问题是文字渲染在不同设备上的表现差异。折屏手机和普通直板机的Canvas宽度不同我在Canvas中间用fillText写了15格这样的标注在模拟器上正常但在真机上字偶有偏移。原因是我用textAlign center后又基于cellW * cols / 2这个浮点坐标定位遇到半像素位置时渲染会有毛边。我最后把中间的格数标注去掉了因为下面那行共15个小格的文本已经承担了同样的信息避免了Canvas文字渲染在不同设备上的不确定性。5.2 扩展一网格密度切换从0.1精度到0.05精度10 × 10网格只能表达0.1的精度。等孩子熟练之后可以加一个密度切换按钮在10和20之间切换。20 × 20网格下每格是0.05此时0.3 × 0.4就不再是3列乘4行而是6列乘8行乘积仍然是0.12。这个升级看似简单但背后有一个深刻的数学道理面积模型不依赖具体的网格划分无论把单位正方形分成10份还是20份只要两个因数的比例关系不变乘积就不变。实现上只需要把divisions变成StateSlider的范围和步进也要跟着调整20格模式下min1max20step1。绘制函数完全不用改因为divisions已经作为变量参与计算了。唯一要注意的是网格线会变密线条颜色要适当调浅避免视觉噪音。5.3 扩展二随机出题与自我检验另一个我觉得很实用的扩展是随机出题模式。点击按钮后程序随机生成两个0.1~1.0之间的小数因子更新状态并重绘但先把公式区的乘积隐藏显示一个问号。孩子先在脑海里估算点击显示答案按钮后再把乘积文本显示出来。这个模式利用了面积模型图形已经完全给出答案的特性——高亮区域就画在那里孩子需要做的是把格子数数出来并换算成小数。这种设计比传统的给算式求结果更能锻炼数感因为它考察的是从图形到数字的映射能力而不仅仅是计算能力。我在给小朋友试的时候还发现他会故意拖动Slider去凑某个格子数比如我想要20个格子然后在两个滑块之间往返调解。这种自发探索是教学应用能收到的最好反馈。5.4 模拟器与真机的实际差异开发阶段我主要用模拟器调试到了测试阶段换真机后发现两个差异。一是Canvas的width属性在不同分辨率下的取值精度不同模拟器上可能是规整的360真机上是393或412这种非整数所以像素对齐处理在真机上更必要。二是低端真机连续拖动Slider时如果onChange回调里的绘制逻辑太重会出现掉帧。我的10 × 10网格完全扛得住但如果将来扩展成50 × 50等高密度网格可以考虑用requestAnimationFrame做节流或者在onChange里只更新状态用Watch合并重绘。还有一个小经验调试Canvas时建议在页面里放一个隐藏的Button点击后强制调用drawAreaModel()。这样当滑块和绘制逻辑之间的联动出现问题时你可以先排除是否是Slider回调没触发再去看绘制函数本身。这个调试入口我几乎每个Canvas应用都会留成本极低排查效率提升很大。做这个应用最大的收获不是又掌握了一个API的用法而是让我重新理解了教育类软件和工具类软件的本质区别工具软件追求效率把步骤变少教育软件追求解释把过程变多。这个面积模型应用故意不隐藏任何中间步骤甚至用三层颜色把列、行、交集拆开展示就是为了让学习者看到乘法的过程本身。如果你也在做类似的教学可视化应用我建议多想想怎么能让过程更透明而不是怎么能让结果更快出现。孩子拖动滑块时脸上那种原来是这样的表情会让这几十行代码显得特别值。
返回列表