如果你辅导过孩子学方程,大概率经历过这样的场面:你反复念叨“x就是未知数,3x加4等于2x加10”,孩子却一脸茫然。方程这个抽象符号对初学者来说就像天书,直到你拿出一台天平——等式两边相等,正如天平两端平衡,这个物理模型天然就是讲解等式性质的最佳教具。我在HarmonyOS NEXT(API 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 = 4,a = 3,b = 2,d = 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的画布和状态管理,这个题目是个不错的起点——数学逻辑清晰、界面反馈直观、后续扩展空间也大。