ARTICLE DETAIL

资讯详情

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

HarmonyOS天平方程模拟器:用Canvas与ArkTS实现互动教学

HarmonyOS天平方程模拟器:用Canvas与ArkTS实现互动教学 如果你辅导过孩子学方程大概率经历过这样的场面你反复念叨“x就是未知数3x加4等于2x加10”孩子却一脸茫然。方程这个抽象符号对初学者来说就像天书直到你拿出一台天平——等式两边相等正如天平两端平衡这个物理模型天然就是讲解等式性质的最佳教具。我在HarmonyOS NEXTAPI 12 / 5.0.0(12)上做了个“简易方程天平平衡模拟器”把数学课本里的天平搬进App用户可以通过增删砝码和未知数块来化简方程直观看到“等式两边同时变化”如何保持平衡。这篇文章会拆解整个实现过程从如何把数学概念翻译成数据结构到用Canvas自绘一个会倾斜的天平再到ArkTS状态管理里那些容易踩的坑最后是化简判定和解方程的逻辑设计。适合正在练手HarmonyOS开发、同时对教育类应用感兴趣的朋友参考。1. 从砝码到代码先把“天平平衡”翻译成数据结构1.1 方程与天平为什么能映射到一起要做一个有教育意义的模拟器不能只做表面动画得先想清楚数学模型。初中课本讲等式性质时最常用的教具就是天平等号左右两边各放一堆东西平衡时两边总重量相等。等式的两条基本性质——两边同时加减相同重量仍相等、两边同时乘除相同非零数仍相等——在天平上就是两个具体动作同时往两边加同样重量的砝码横梁仍是平的把两边物品同时拿走同样重量横梁依然不动。所以这个应用的核心数据结构就是“左右两个装着物品的数组”。左边数组对应方程左边右边数组对应方程右边每个元素要么是一个砝码已知重量要么是一个未知数块代表一个x。界面上的所有交互最终都在修改这两个数组。1.2 BalanceItem与天平状态的核心定义用TypeScript接口来定义物品enum ItemType { Constant, // 砝码重量已知 Variable // 未知数块每块代表一个x } interface BalanceItem { id: number type: ItemType weight: number // Constant时表示克数 label: string // 显示文本如 10g 或 x }为什么一定要用数组存物品而不是直接存两个总重量因为用户需要可视化地看到每个砝码和x块且要能单独选中、移除。总重量只是从数组实时算出来的“派生状态”。class BalanceState { left: BalanceItem[] [] right: BalanceItem[] [] }这个类作为整个页面的核心状态所有逻辑都围绕它展开。1.3 平衡判定内部预设一个targetX接下来是整篇设计里最关键的决策天平如何判定平衡物理上如果不知道x的实际重量天平根本没法判断。但我们的目标是让学生通过操作求出x所以应用必须知道答案才能实时反馈“当前天平是否平衡”。做法是每次出题时在内部预设一个targetX学生不知道用这个值计算每个x块的“隐含重量”。左右两边的总重量都用targetX代入计算function totalWeight(items: BalanceItem[], xValue: number): number { return items.reduce((sum, item) { if (item.type ItemType.Constant) { return sum item.weight } else { return sum xValue } }, 0) } // 界面刷新时判断 const leftWeight totalWeight(this.state.left, this.targetX) const rightWeight totalWeight(this.state.right, this.targetX) const isBalanced leftWeight rightWeight当学生进行“两边同时拿走一个10g砝码”这类等价操作时左右总重量同时减少天平保持水平如果学生偷偷只在一侧减东西天平立刻倾斜。这个机制让“等式性质”变成了可感知的物理反馈。1.4 题目生成先有答案再逆推出天平如果随机生成左右两堆物品很可能出现无解、解为负数或非整数的情况。我采用“逆向出题法”先随机选一个targetX比如2到9之间的整数随机选左边x块数量a比如2到3和右边x块数量b强制a比b大1即a - b 1随机选右边常数d比如10到30之间由等式 ax c bx d 推出左边常数 c (b - a) * targetX d。举个例子targetX 4a 3b 2d 18则 c (2 - 3) * 4 18 14。于是左盘初始放3个x块加14g砝码右盘放2个x块加18g砝码天平正好平衡方程3x 14 2x 18的解是x 4。这里有个刻意的约束a - b 1。这样学生在化简时只需要“两边同时拿走相同数量的x块”一次左边就恰好剩下1个x不需要处理“两边同时除以系数”的复杂演示。这个限制让初版实现的教学路径非常清晰先消x再消常数最后直接读答案。2. 会倾斜的天平用Canvas自绘的几何方案2.1 为什么不用自带组件堆界面第一版原型我试过用Row和Stack配合图片旋转来做天平结果发现两个痛点托盘和砝码会跟着横梁一起旋转看起来像整个装置都被甩飞了物品数量变化时重新布局极其别扭。Canvas虽然要手写几何计算但胜在逻辑可控——横梁绕着中心旋转一个角度两端点的坐标可以用三角函数精确算出来托盘始终垂直悬挂这是模拟真实天平的关键。2.2 关键坐标变换横梁旋转托盘保持水平先约定坐标画布中心点作为横梁支撑点横梁长度为beamLength横梁中心在(centerX, centerY)。定义倾斜角度angle左侧重时横梁左低右高。横梁绘制相对简单让整体旋转坐标系即可ctx.save() ctx.translate(centerX, centerY) ctx.rotate(angle * Math.PI / 180) // 画横梁从 (-beamLength/2, 0) 到 (beamLength/2, 0) ctx.fillStyle #8B5A2B ctx.fillRect(-beamLength / 2, -4, beamLength, 8) ctx.restore()但吊绳和托盘不能也旋转它们必须始终铅直向下。因此需要先算出旋转后横梁两端点的画布坐标const leftEndX centerX - (beamLength / 2) * Math.cos(angle * Math.PI / 180) const leftEndY centerY - (beamLength / 2) * Math.sin(angle * Math.PI / 180) const rightEndX centerX (beamLength / 2) * Math.cos(angle * Math.PI / 180) const rightEndY centerY (beamLength / 2) * Math.sin(angle * Math.PI / 180)然后从leftEndX/leftEndY向下画两条吊绳到leftEndY ropeLength再接一个弧形托盘。这样无论横梁怎么转托盘和砝码始终是水平的只是左右托盘会一高一低。这个细节决定了整个动画的真实感也是最容易踩坑的地方。2.3 倾斜角度计算与平滑动画倾斜角根据左右重量差计算。我用了一个带最大角限制的线性映射computeTiltAngle(): number { const diff this.leftWeight - this.rightWeight const maxAngle 18 return Math.max(-maxAngle, Math.min(maxAngle, diff * 0.5)) }差1g对应0.5度差36g以上就卡到最大18度。系数0.5是调出来的——太小了看不出倾斜太大了又显得夸张。你也可以用反正切函数让角度随重量差非线性趋近但我实测在课堂场景里线性映射配合限幅已经足够自然。角度值作为State变量每次重量变化后用animateTo包裹赋值能获得平滑的缓动效果animateTo({ duration: 300, curve: Curve.EaseOut }, () { this.tiltAngle this.computeTiltAngle() })这一步非常推荐因为天平倾斜如果生硬跳变孩子会觉得像开关而不是物理装置。2.4 砝码与x块的绘制细节砝码用灰色椭圆上面写重量数字x块用橙色圆角矩形中间一个加粗的“x”。物品在托盘上的排列要自适应数量我采用“每行最多3个最多排2行”的策略。计算物品位置时以托盘中心为基准点向左扩展排布private drawItems(ctx: CanvasRenderingContext2D, items: BalanceItem[], panCenterX: number, panBottomY: number) { const itemW 34 const itemH 22 const rowMax 3 items.forEach((item, index) { const col index % rowMax const row Math.floor(index / rowMax) const x panCenterX - (rowMax - 1) * itemW / 2 col * itemW const y panBottomY - itemH - row * (itemH 6) if (item.type ItemType.Constant) { ctx.fillStyle #808080 ctx.beginPath() ctx.ellipse(x itemW / 2, y itemH / 2, itemW / 2, itemH / 2, 0, 0, Math.PI * 2) ctx.fill() ctx.fillStyle #FFFFFF ctx.font 12vp sans-serif ctx.textAlign center ctx.fillText(item.weight.toString(), x itemW / 2, y itemH / 2 4) } else { ctx.fillStyle #FF8C00 ctx.beginPath() ctx.roundRect(x, y, itemW, itemH, 6) ctx.fill() ctx.fillStyle #FFFFFF ctx.font bold 14vp sans-serif ctx.textAlign center ctx.fillText(x, x itemW / 2, y itemH / 2 5) } }) }Canvas的onReady回调里绘制首帧之后每次状态变化调用draw方法重绘。要注意的是Canvas尺寸可能到onReady时才确定所以绘制函数里要读取组件实际宽高不能硬编码。3. 操作交互与ArkTS状态管理增删物品背后的深坑3.1 操作面板与两种操作模式屏幕下半部分是一排操作按钮加5g、加10g、加20g砝码加x块移除选中物品撤销重置。其中关键的设计是“同时操作模式”的开关。关闭时点击加砝码只加到当前选中的一侧适合学生自由尝试开启时点击加砝码会一次性在左右两侧同时加入相同重量的砝码这就是等式的“同加同减”操作。实现时用一个枚举来切换enum OperationMode { Free, // 自由模式单侧增删天平会倾斜 PreserveBalance // 平衡模式双侧同时增删相同物品 }3.2 最大的坑State数组原地修改不刷新这是我在实际开发中花了一晚才定位的问题。最初我是这么写的State leftItems: BalanceItem[] [] addConstant(weight: number, side: Side) { const item: BalanceItem { id: this.nextId, type: ItemType.Constant, weight: weight, label: ${weight}g } if (side Side.Left) { this.leftItems.push(item) // 界面纹丝不动 } }明明数组变了UI却不刷新。原因是ArkTS里State对数组的监听主要基于引用变化直接调用push/splice改变的是同一个数组引用的内容框架不一定能感知到。解决方案不是原地修改而是生成新数组整体赋值if (side Side.Left) { this.leftItems [...this.leftItems, item] } else { this.rightItems [...this.rightItems, item] }删除操作同理用filter生成新数组removeItem(side: Side, targetId: number) { if (side Side.Left) { this.leftItems this.leftItems.filter(i i.id ! targetId) } else { this.rightItems this.rightItems.filter(i i.id ! targetId) } }这种写法虽然每次多了一次数组拷贝但物品数量最多也就十几二十个性能完全无压力。换来的是一劳永逸的UI刷新可靠性。3.3 同时操作要保持原子性开启“平衡模式”后一次点击要同时修改left和right两个数组。ArkUI的机制是同一个事件回调里的连续状态赋值会合并为一次UI刷新所以不用担心出现“左边加了右边还没加”的中间态。但逻辑上仍需保证一致性addToBothSides(weight: number) { const item this.createConstantItem(weight) this.leftItems [...this.leftItems, item] this.rightItems [...this.rightItems, item] }移除时更要注意如果学生选中了左侧一个10g砝码点击移除此时若开启平衡模式系统也要尝试在右侧找一个10g砝码同时移除。如果右侧没有10g砝码该操作应被禁止并给出提示“右侧没有10g砝码无法保持平衡”。这正是教学意图让学生理解“等式两边同时减去同一个数”的前提是两边都有这个数。removeSameWeightFromBothSides(weight: number, side: Side): boolean { const otherSide side Side.Left ? this.rightItems : this.leftItems const found otherSide.findIndex(i i.type ItemType.Constant i.weight weight) if (found 0) { return false } this.leftItems this.leftItems.filter(i !(i.type ItemType.Constant i.weight weight)) this.rightItems this.rightItems.filter(i !(i.type ItemType.Constant i.weight weight)) return true }这里有个细节移除时如果直接用filter按重量过滤会一次性删掉所有相同重量的砝码。比如左右各有3个10g砝码我只想删掉1个结果3个全没了。正确做法是按id精确删除只删选中那一个。3.4 撤销与重置历史快照的记录方式撤销功能我采用快照栈实现。每次操作前把当前leftItems和rightItems的引用存入历史数组撤销时弹出栈顶并整体赋值。这里同样受益于“不原地修改数组”的原则——如果之前用push原地改所有历史快照实际指向同一个数组撤销时会全乱套。interface Snapshot { left: BalanceItem[] right: BalanceItem[] } State history: Snapshot[] [] saveSnapshot() { this.history.push({ left: this.leftItems, right: this.rightItems }) if (this.history.length 50) { this.history.shift() } } undo() { const last this.history.pop() if (!last) return this.leftItems last.left this.rightItems last.right }快照里存的是数组引用因为每次操作都会生成新数组旧引用天然不可变这比深拷贝数组高效得多也安全得多。4. 化简判定与自动求解从“天平平衡”到“x等于几”4.1 判断“解出来了”的条件学生最终目标是把左边化简成“只有1个x块”右边全是砝码。系统自动判定isSimplifiedToX(): boolean { return this.leftItems.length 1 this.leftItems[0].type ItemType.Variable this.rightItems.every(i i.type ItemType.Constant) }一旦满足界面弹出提示“你解出来了x等于右侧砝码总重量”并高亮右侧砝码的总和。这一步相当于把“答案就在眼睛前面”的成就感直接递给用户。4.2 合法化简操作的判定化简过程中学生能做的最核心操作是“两边同时拿走相同个数的x块”和“两边同时拿走相同重量的砝码”。系统需要严格校验每次操作后是否仍然平衡。由于题目生成时已保证初始平衡而“同加同减”天然维持平衡所以理论上只要是合法操作天平永远不倾斜。但学生也可能在自由模式下乱操作导致天平倾斜。这时倾斜本身就是反馈——教学上这反而是最有价值的时刻孩子看到倾斜就会意识到“等式被破坏了”。操作面板会同步解释“左侧比右侧重16g天平向左倾斜”。4.3 自动演示求解的动画流程我加了一个“自动求解演示”按钮系统会自动播放化简动画。由于题目生成时约束了左侧x块数比右侧多1动画流程固定为两步第一步左右两侧同时移除x块移除次数等于右侧x块数量。比如左侧3个x右侧2个x就同时移除2个结果左侧剩1个x右侧剩0个x。第二步此时方程已被化简为“x 左侧常数 右侧常数”的形式。左盘只剩1个x和一堆砝码右盘全是砝码。以下一步为例初始左侧3个x加14g砝码右侧2个x加18g砝码。将2个x同时消掉后左侧变1个x加14g右侧变18g。此时问题化为1x加14等于18对应同时拿走14g砝码——于是系统在左侧找到14g砝码同时在右侧找到14g砝码或几个能凑成14g的砝码同时移除。最终左边只剩下1个x右边是4g砝码x 4。为了简化演示的砝码匹配逻辑我在题目生成时做了额外约定左侧常数c必须能直接生成一个独立的c克砝码右侧常数d拆成的砝码组合里必须包含一个c克砝码。这样演示动画执行第二步时总能精确配对。演示动画的实现不是把状态一次性跳到最终而是用定时器逐步执行playSolutionStep() { const removeCount this.countVariables(this.rightItems) this.removeSameVariableCount(removeCount) // 更新状态后延时执行第二步 setTimeout(() { this.removeSameWeightFromBothSides(this.leftConstantWeight()) }, 800) }配上音频或震动反馈效果很好。4.4 学生验证用“猜的x值”让天平说话除了自动求解我还提供了“手动验证”入口。学生口算出一个x值在输入框填进去点击“验证”系统就用这个值替代targetX重新计算左右总重量。如果相等天平保持水平并显示“正确”如果不相等天平根据重量差倾斜提示“左右不平衡检查你的计算”。这个功能极其好用它把一个纯数字计算变成了“你让天平平衡了/没平衡”的物理事件孩子立刻能感觉到自己的答案是“稳”的。实现上只需临时替换计算函数里的xValue不需要改动任何状态。5. 关卡配置与后续扩展从单题到教学题库5.1 把题目抽成JSON配置为了让应用可维护我把题目数据从代码里抽离成JSON放在resources/rawfile下。这样新增题目完全不需要改代码逻辑{ id: 3, title: 天平题三, targetX: 6, left: [ { type: variable, count: 2 }, { type: constant, weight: 8 } ], right: [ { type: variable, count: 1 }, { type: constant, weight: 20 } ], hint: 先同时拿走两侧的x块试试 }加载和解析也很直接const json await getContext(this).resourceManager.getRawFileContent(levels.json) const text util.TextDecoder.create(utf-8).decodeToString(new Uint8Array(json)) const levels JSON.parse(text)注意读取rawfile在API 12里返回的是Uint8Array需要手动解码成字符串再parse。5.2 难度曲线的设计与约束我目前定义的难度从易到难分四级第一级左边只有一个x块右边全是砝码学生直接读出x值。这类题锻炼“读天平”能力。第二级左边有两个x块右边全是砝码。学生需要把砝码总量除以2才会发现“一个x是多少”需要除法。这里就要考虑除法可视化的问题了。第三级两边都有x块但系数差为1且两边的砝码量不多。学生只需“同时消去x”和“同时消去常数”。第四级x块数量增多常数变大需要多次消去操作且砝码组合更考验“找对配对砝码”。这些关卡通过不同的JSON配置实现。需要说明的是第二级里的除法操作在初版中我刻意避开了题目生成时保证右侧砝码总量能被2整除并且界面在右侧砝码上方显示“分成2份每份xxx克”的提示让学生心算得出x的答案而不要求天平分堆。真正做成“把右侧砝码分成n堆”的动画留给了后续版本。5.3 扩展方向负权重、分组除法、题库编辑器有负数的方程怎么办物理天平上不存在“负砝码”但我构思了“气球”模型气球绑在托盘下方产生向上拉力代表负重量。每个气球对象相当于一个负常数额。于是数据模型只需扩展一个ItemType.Balloon总重量计算改为“砝码和 - 气球和”。题目生成同样逆向推导。除法可视化的思路是把右侧砝码“等分成n份”每一份用半透明盒子框起来然后在盒子上标记“x 框内砝码总重量”。这个场景适合用Canvas的分组绘制实现但不适合在初版里硬塞进去会破坏交互的简洁性。题库编辑器则是一个更长远的想法让老师或家长通过图形界面自己出题生成level JSON。本质上就是把这个应用里的题目生成逻辑做成一个可视化表单。5.4 实例中用到的HarmonyOS能力小结这个实例虽小但覆盖了ArkTS状态管理、Canvas自绘、动画、rawfile读取等常见能力。实际开发时我用的DevEco Studio配套的模拟器验证API版本为HarmonyOS NEXT 5.0.0(12)。Canvas的RenderingContextSettings开启抗锯齿可以让图形边缘更平滑angle计算里注意弧度与角度的转换即可。一个小提醒Canvas组件在onReady之前宽高可能为0不要在onReady之前调用draw方法否则会出现空白画面。如果遇到首帧空白检查一下是否用了固定宽高而不是百分比或者把draw调用放在onReady里。做这个模拟器期间我把家里的孩子拉来当小白鼠他最喜欢的是“自由模式”里故意把一边加重、看天平歪掉的过程。对成年人来说这是理所当然的事对孩子来说却是第一次直观看到“等号两边不等”会发生什么。其实这个应用的价值恰恰不在于把方程解出来而在于让ta在一次次倾斜与平衡之间建立起对等式的直觉。如果你也想给自家孩子做点教育工具或者单纯想练一下HarmonyOS的画布和状态管理这个题目是个不错的起点——数学逻辑清晰、界面反馈直观、后续扩展空间也大。
返回列表