☰
HarmonyOS应用开发:用ArkUI打造天平模拟器可视化简易方程
2026/10/2 18:39:33 网站建设 项目流程

这个系列写到第85篇,我一直想做点能把抽象概念“摆到眼前”的东西。HarmonyOS应用实例85,我选了“简易方程:天平平衡模拟器”——用一座虚拟天平来讲清楚方程到底在算什么:左边摆几个标着x的箱子和几个砝码,右边摆砝码,学生通过点击物品完成“等式两边同时减掉同样重量”的操作,天平会实时倾斜或平衡,最后把 x = ? 一步步拆出来。这篇博文适合HarmonyOS初学者、以及想给科普或教学App做交互原型的开发者,我尽量把从数据模型到ArkUI实现的每个环节都摊开讲。

1. 项目概述与需求拆解

1.1 这个应用到底解决什么问题

小学阶段的“简易方程”通常是形如 x + 3 = 7 或者 2x + 2 = 8 这样的等式求解。孩子最难理解的是“等式两边同时加减同一个数,等式仍然成立”这句话。文字很抽象,但换到天平上就特别直观:天平的左右托盘好比等号两边,两边同时拿走一个砝码,天平依然平衡,这个动作本身就是方程的“同解变形”。

所以这个模拟器承担的职责不是教计算技巧,而是把代数变形“可视化、可触摸化”。用户面对的不再是纸面上的符号操作,而是一架能感知重量差的天平:你把左边一个砝码拿掉,右边必须跟着拿掉一个,天平才不乱晃。这样的交互设计,能让孩子在动手试错中记住等式的性质,比我小时候靠背“移项变号”要扎实得多。

我最初的构想甚至想做“自由摆放”模式——学生自己往左右托盘放东西,使天平平衡,然后反推方程。但考虑到“简易方程”的核心教学目标是解未知数,最终我收敛成“点击物品完成等价变形”的玩法。两相对比,自由摆放更像益智游戏,点击变形才更像数学教具,这也决定了后面整个代码结构。

1.2 功能清单与交互流程

需求拆下来,核心功能有四项:

  • 场景展示:在屏幕上呈现一架完整的天平,左右托盘显示当前方程对应的物品数量。
  • 方程生成与关卡化:内置若干简易方程题目,从 x + 2 = 5 到 2x + 3 = 11 不等。
  • 等价变形交互:点击左右托盘的砝码或x箱子,执行“两边同时移除相同物品”的操作,系统自动判定操作是否合法。
  • 求解反馈:当天平完全平衡且左侧只剩一个x箱子时,显示 x 的值,并播放成功动画。

交互流程设计成闭环:用户进入关卡 → 看到天平与题目 → 反复点击砝码或x箱子进行消元 → 直到左侧仅剩一个x、右侧为常量 → 弹出结果。为了避免误操作,我在每个操作前都会做边界检查:左边砝码为0但右边还有砝码时,点击砝码就要给出“两侧砝码数量不足,无法同时拿掉”的提示。这些限制条件看似多余,但缺少的话,学生会很快把天平点成负数状态,反而学不到正确的代数规则。

2. 关键技术选型与整体设计

2.1 为什么选择ArkTS + ArkUI,而不是传统命令式开发

写HarmonyOS应用,绕不开语言和框架的选择。这个项目我用的ArkTS + ArkUI声明式开发范式。ArkTS在TypeScript基础上加了严格的静态类型约束,写业务模型时心里非常踏实,比如我的方程模型字段都是number类型,编译期就能拦住大多数粗心错误。ArkUI声明式UI的好处则是“状态即视图”:我只要把天平倾斜角度、物品数量这些状态定义清楚,UI会跟着自动刷新,不需要像命令式框架那样手动操作DOM或者Canvas重绘节点。

说句实在话,早期的鸿蒙教程里Canvas画天平的方案我见过不少,但那个方案有个明显痛点:你真要响应点击、又要做位移动画,所有坐标计算都得自己维护,代码量直接翻倍。而用ArkUI的布局组件从Column、Row、Stack组合出天平模型,配合rotate旋转动画,开发效率和可维护性都高很多。“尽量用布局表达结构,用样式表达状态”,这是我做这类教学应用一直坚持的原则。

2.2 状态模型设计:一杆天平,三个数字

整个应用最核心的状态其实只有三个:左侧x箱子数量、左侧砝码数量、右侧砝码数量。我把它们定义成三个独立的 @State 变量,而不是塞进一个类对象里。很多人可能习惯建一个EquationModel类再 @State 一个实例,但在ArkTS的状态管理V1版本里,@State 修饰对象时,深一层属性变化并不总能触发UI刷新,除非搭配 @Observed 和 @ObjectLink,而后者又会引入组件间通信的额外复杂度。

用三个基础数字类型 @State,每次在按钮点击或者Animator回调里直接修改它们,UI响应是100%可靠的。这个取舍起初看起来“不够面向对象”,但实际调试时帮我省了大量排查时间。我始终认为,在响应式框架里,状态粒度越简单越不容易出诡异问题。至于方程计算逻辑,我单独封装成纯函数,输入这三个数字,输出倾斜角度、是否平衡、是否求解完成。逻辑与视图严格分离,测试起来也方便。

三个数字之外,还有一个时刻在变化的派生状态:天平应该倾斜多少度。它不是用户直接控制,而是由左右重量差计算出来的。ArkUI里计算属性不推荐写在build里每帧调用,我选择把它实现为一个公开方法 calcTiltAngle(),在需要刷新UI的事件回调里取一次值,再赋值给 @State tiltAngle,驱动旋转动画。

2.3 动画与交互方案:状态驱动,动画托底

动画选型上,我用了最简单的属性动画:当三个数字变化后,调用 animateTo 闭包包裹 tiltAngle 的赋值,天平横梁就会自动从旧角度转动到新角度。ArkUI的animateTo 默认支持数值型属性插值,不需要自己写逐帧动画。我先后试过显式动画和关键帧动画——显式动画适合组件挂载时的过渡,关键帧适合做有中间状态的复杂动画,而天平倾斜这种“由数值变化引起的位置变化”,隐式配合 animateTo 的状态刷新是最省心、最稳定的方案。

交互方案上,我给每个砝码和x箱子都绑定了点击事件。点击逻辑并不是“把这个物品删除”,而是“触发一次两侧同步消减”。这种设计保证用户永远只能执行代数上合法的操作。我还特意让砝码和x箱子被删除时做一个透明度+缩放动画——用 animateTo 同时修改透明度属性,会让删除动作显得柔和,而不是“啪”一下消失。这个细节在真机上对小朋友的观感影响很大,动画慢一点,思考时间就长一点,理解更充分。

3. 核心逻辑实现:从方程到平衡判定

3.1 方程数据模型与重量计算

应用里的“简易方程”被抽象成三个整数状态:左侧x数量 leftXCount、左侧常量数量 leftConstCount、右侧常量数量 rightConstCount。例如题目 x + 3 = 7,对应 leftXCount = 1、leftConstCount = 3、rightConstCount = 7。题目 2x + 1 = 5,对应 leftXCount = 2、leftConstCount = 1、rightConstCount = 5。

天平判断平衡的关键在于“重量”必须可计算。我给x箱子赋予10个单位的重量,砝码赋予5个单位重量。为什么不都用1呢?因为如果所有物品重量相同,2个x和2个砝码就完全无法区分,天平永远只会因为“数量差”倾斜,这会误导用户。让x箱子比砝码重,天平才能反映出“一个x并不等于一个砝码”的代数本质。计算代码如下:

// 简易方程状态模型 const X_WEIGHT = 10; // x箱子的重量 const CONST_WEIGHT = 5; // 砝码的重量 // 左侧物品总重量 function leftTotalWeight(xCount: number, constCount: number): number { return xCount * X_WEIGHT + constCount * CONST_WEIGHT; } // 右侧物品总重量 function rightTotalWeight(constCount: number): number { return constCount * CONST_WEIGHT; }

把重量映射写清楚之后,平衡判断就一句话:左侧总重量等于右侧总重量。但真正撑起教学意义的不只是最终平衡,而是“中间每一步两边同步减掉相同重量时,天平倾斜角度不变”。这个性质正是等式性质的物理体现,所以每做一次消减,我都会重新计算倾斜角度,让用户看到“操作前后天平状态的一致性”。

3.2 倾斜角度计算:重量差怎么变成视觉角度

倾斜角度的计算逻辑比较直观,但也藏着几个坑。最简单的是线性映射:定义角度 = 重量差 × 系数。比如左右重量差为 20,系数取 0.5,那么角度就是 10度。但重量差可能非常大(一个3个x+5个砝码,一个0个物品),如果不做钳制,横梁会旋转到90度甚至反转,看起来非常滑稽。

所以我的计算函数里加入了最大角度限制,把输出区间稳定在正负15度之间。超过15度就按15度算,反向同理。这模拟的是真实天平的物理极限——天平横梁不可能无限倾斜。代码实现:

// 由左右重量差计算天平倾斜角 function calcTiltAngle(leftX: number, leftConst: number, rightConst: number): number { const leftW = leftTotalWeight(leftX, leftConst); const rightW = rightTotalWeight(rightConst); const diff = leftW - rightW; // 将重量差映射为倾斜角,每4个重量单位对应1度 let angle = diff / 4; // 钳制最大倾斜角,避免横梁过度翻转 const MAX_TILT = 15; if (angle > MAX_TILT) { angle = MAX_TILT; } if (angle < -MAX_TILT) { angle = -MAX_TILT; } return angle; }

关于映射系数4,有人可能会问为什么不是1。原因是重量差单位是5的倍数(砝码重5),如果系数过大,倾斜角在每次操作时跳变太明显,视觉上很生硬;系数过小,则倾斜不明显。我实测下来,系数4或者5比较合适,重量差每增减一个砝码,角度变化1到2度,连续操作后累计效果清晰又不夸张。

3.3 操作合法性与求解完成判定

点击交互“两侧同时拿掉一个砝码”不是无条件的,必须满足左右砝码都大于0。这既是交互约束,也是代数规则的强制体现。如果一侧已经是0,还强行执行“两边同减”,天平状态会崩溃,教学逻辑也会穿帮。我在点击函数开头做了完整校验:

// 两侧同时移除一个砝码 function removeConstBoth(leftConst: number, rightConst: number): { leftConst: number; rightConst: number; } { if (leftConst <= 0 || rightConst <= 0) { // 不合法操作,返回原值 return { leftConst, rightConst }; } return { leftConst: leftConst - 1, rightConst: rightConst - 1 }; }

这个纯函数的好处是可以在单元测试里直接验证边界条件:左砝码为0时不允许减,右砝码为0时不允许减。我在项目中用了个简单的命令行脚本跑这些纯函数,保证逻辑正确后再接入UI,比在模拟器里反复点按钮高效得多。

求解完成判定我定义为三个条件同时满足:左侧x箱子数量等于1、左侧砝码数量等于0、天平完全平衡。数学上这意味着方程已经被化简为 x = 右侧砝码数。这里有一个容易被忽略的细节:右侧砝码数量必须大于0,否则方程会变成 x = 0 或没有意义。我在关卡设计时避免出现右侧为0的题目,求值逻辑里也做了兜底判断:

// 判定求解是否完成 function isSolved(leftX: number, leftConst: number, rightConst: number): boolean { return leftX === 1 && leftConst === 0 && rightConst > 0; } // 获取解的值 function getSolution(rightConst: number): number { return rightConst; }

把逻辑写成纯函数之后,UI层只负责调函数、拿结果、更新状态,代码结构非常清爽。我在若干个关卡之间反复切换,也从来没有出现过“显示x等于0”这种错误结论。

4. 界面实现:用ArkUI把天平“搭”出来

4.1 页面整体结构与层级关系

页面最外层我用一个垂直方向的Column:顶部是题目文本和关卡序号,中间是重量感十足的天平主区域,底部是操作引导和结算信息。由于天平需要叠放多个层——底座、立柱、横梁、托盘、物品——主区域我用了Stack容器,让所有元素在同一坐标系里居中堆叠。

Stack的好处在于,它可以让我把“固定不动的支架”和“会旋转的横梁托盘层”完全解耦:支架放在Stack底层,横梁托盘整体包在Column里,只对这个Column施加rotate变换。这样旋转时不需要关心哪根柱子动了、哪根没动,代码只用管“旋转层”这一棵子树。真天平的支架当然是不动的,这个视觉分层也符合物理直觉。

4.2 天平底座、立柱与横梁的构建细节

天平底座和立柱我用纯粹的布局组件拼出来:底座是一个宽矩形加一个三角状顶部,立柱是一个细高的矩形,横梁是一根宽矩形。三角形状我直接用Column配合左右各放一个旋转45度的正方形来实现——在ArkUI里画三角形通常有三条路:用Path形状组件、用预置旋转矩形、或者用图片资源。考虑到整体包体要小,也为了不引入额外素材,我选了预置旋转矩形法,虽然写起来稍微绕一点,但效果完全够用。

横梁是旋转层的主干,我把它做成一个宽220、高6的圆角矩形,颜色用深棕色系,突出“木质秤杆”的感觉。横梁两端各向下延伸一根短绳索,绳索我用细长的Column(宽4,高24、圆角2)模拟,手感上比真实绳索硬一些,但视觉识别度更好。绳索末端连接托盘。

这里必须提醒一个布局陷阱:rotate旋转的默认中心是元素自身中心。在Stack里如果我直接对包含横梁和托盘的Column整体旋转,旋转中心在Column中心,而不是横梁中线与立柱顶点的交点,这会导致横梁的支撑点看起来悬空。解决办法是在rotate方法里显式指定旋转中心:

.rotate({ angle: this.tiltAngle, centerX: '50%', centerY: '10%' })

centerY取10%意味旋转中心在旋转层顶部偏下一点的位置,刚好对应横梁与立柱接触点。不调这个参数,你会在真机上看到横梁绕着自己肚子转圈,天平像断了一样,特别出戏。

4.3 托盘与物品的绘制:x箱子与砝码

托盘我用了圆角矩形模拟盘面,内部用Flex容器排列物品。这里有个很关键的交互细节:物品数量在操作中会变化,所以托盘区域要自适应增删卡片。我用ForEach循环遍历一个物品数组来渲染每个砝码或x箱子。砝码用圆形、金色边框;x箱子用蓝色圆角矩形,中央写一个白色“x”。为了让小学生看懂,两种物品的视觉差异要非常大,不能只用颜色区分,还要有形状差异——色弱的孩子只看颜色可能无法分辨。

我定义了物品数组类型:

// 每个物品的显示类型:'x' 表示x箱子,'const' 表示砝码 type ItemKind = 'x' | 'const'; interface TrayItem { id: string; kind: ItemKind; }

当模型里的 leftXCount、leftConstCount、rightConstCount 任一变化时,我调用一个重建数组的方法重新生成托盘物品列表:

function buildTrayItems(xCount: number, constCount: number): TrayItem[] { const items: TrayItem[] = []; for (let i = 0; i < xCount; i++) { items.push({ id: `x-${i}`, kind: 'x' }); } for (let i = 0; i < constCount; i++) { items.push({ id: `c-${i}`, kind: 'const' }); } return items; }

每个物品卡片的宽度约32,托盘Flex宽度约200,换行规则用wrap。物品多时自动换行,不会溢出。给每个物品绑定同一个点击处理方法,点击后重新计算两侧数量并重建数组,UI自然刷新。

4.4 点击操作与动画触发:一张卡片两种含义

物品卡片的点击事件并不只是“删除自己”。我在事件回调里先判定被点击的物品类型:

  • 如果点的是砧码,执行两侧同时减砝码;
  • 如果点的是x箱子,执行两侧同时减x箱子(要求左侧x数量大于等于2,且右侧存在对应数量的x类型物品。实际上右侧通常没有x箱子,所以这条操作很少用,但在2x + 3 = 11这种关卡里,我会在右侧也放一个透明的“x占位”,接收移除操作,保证数量守恒)。

这里就要额外说一句:右侧不是永远只有砝码的。题目 2x + 1 = 7 时,理论上右侧没有x,但求解过程第一步往往是“两边同时减1”,第二步是“两边同时除以2”。如果交互上硬要“两边同时移除一个x”,就必须右侧也有x才能移除。为了不搞乱教学逻辑,我干脆规定:2x类型的题目右侧会先放入1个x占位物品,两侧“同时移除x”后,左侧还剩1个x,右侧的x占位也消掉,这才符合“2x除以2等于x”的变形。这个设计或许不完全符合传统算式写法,但教学上抓住了等价变形的实质,孩子操作起来零困惑。

动画触发代码我控制在三行以内,核心就是:

animateTo({ duration: 350, curve: Curve.EaseOut }, () => { this.tiltAngle = calcTiltAngle( this.leftXCount, this.leftConstCount, this.rightConstCount ); });

animateTo会把tiltAngle从旧值平滑过渡到新值,350毫秒的时长既不会拖沓,也不会快到看不清。对于移除物品的透明度动画,我额外用一个 @State removingItemId 记录正在移除的物品,给它附加一个 scale 0.6 + opacity 0 的变化。这样学生能清楚看到“是哪一个物品被拿走了”,注意力不会被一整个托盘的变化带走。

5. 实操记录与调试经验

5.1 工程初始化与目录结构

我用DevEco Studio新建了一个空Ability工程,选择 Empty Ability 模板,语言选 ArkTS,设备类型勾选了Phone和平板。因为天平的左右宽度在平板上可以拉得更开,横梁能显示得更舒展。工程里代码目录按“模型、工具、视图、资源”划分:模型目录放方程计算纯函数,组件目录放BalanceScale自定义组件和TrayItem组件,页面目录放Index入口。对一个小App来说这个划分或许略重,但后续加关卡、加数据统计时不会把代码拧成一团。

创建完工程我立即在模拟器上跑了一个hello world,确认开发环境链路没有问题,再开始填充业务逻辑。这一步看起来多余,但实际上它能快速排除“工具链坏了”这种低级问题,避免后面调试时把环境问题和代码问题混在一起。我常用的排查顺序是:先跑通最小工程,再逐步加复杂度,每加一个模块就编译一次。

5.2 核心逻辑联调:先测纯函数,再接UI

这次开发我坚持了一个习惯:纯函数先独立测试,再接到ArkUI里。方程计算、重量计算、倾斜角度计算、合法性校验、求解判定,这些都不依赖UI框架,我拿一个临时脚本在每个函数边界传入几个典型值,打印输出核对结果。比如 x + 3 = 7 经过一次“两边同时减1”后,leftConst应该从3变2,rightConst从7变6,tiltAngle保持不变。这种测试覆盖的是教学逻辑的正确性,比在界面里肉眼判断可靠得多。

接上UI之后,我把模型的三个状态变量分别初始化成不同题目的样子,逐个跑通“题目自检”:x + 2 = 5、x + 5 = 9、2x + 2 = 8。第二步2x类型还要额外测“同时移除x后左右重量差不变”这个性质。中途我发现一个bug:当 leftX = 2、leftConst = 2、rightConst = 8 时,第一次移除一个砝码后 leftX=2, leftConst=1, rightConst=7,此时左侧重量 2×10 + 1×5 = 25,右侧重量 7×5 = 35,天平右倾。但如果按照“同时移除x”的逻辑,leftX减1后左侧变成1×10 + 2×5 = 20,右侧 x占位被移除但没有重量变化(占位重量为0),重量差反而增大,看起来就不符合代数一致性。最终我的解法是给x占位赋予和x箱子一样的重量,右侧的x占位也参与重量计算,这样两边同时移除x时,左右重量同步减少10,重量差不变。这个修正让我意识到:教学模拟器里的每个视觉元素都必须有明确的重量模型,占位符也不能偷懒。

5.3 真机与模拟器的观感差异

逻辑通了之后我分别在模拟器和真机上各跑了一遍。模拟器上一切正常,但真机上发现横梁旋转时物品文字有点发虚。原因很简单:模拟器用的是静态截图渲染,真机则是独立像素密度。我在物品卡片上加了 minFontSize 和 maxFontSize 自适应,并把x的字体设为粗体,虚影才消失。

还遇到一个真机特有的问题:点击砝码时偶尔会出现连续触发两次操作——一次点击,一次是系统把触摸识别成双击。排查后确认是我在OnClick之外还注册了OnTouch事件并调用了event.stopPropagation,又没有处理事件冒泡。最后我把交互统一收敛到OnClick一个入口,去掉OnTouch监听,双击误触就消失了。建议做这类“每个物品一个点击事件”的小应用时,事件入口越单一越好,不要为了追求高级效果同时挂多个事件监听。

6. 常见问题与避坑指南

6.1 ForEach 的 key 不稳定导致物品乱跳

这是我最先踩到的坑。最初我给托盘物品数组生成的key是Math.random(),导致每次重建数组时,所有物品的身份都变了,ArkUI无法复用已有组件节点,物品会全部重新创建。视觉上就是一删除砝码,整个托盘闪烁一下。后来我把key固定为x-0、x-1、c-0、c-1这种索引型字符串,只在删除最后一个物品时让最后一个节点消失,其余节点全部复用,闪烁问题彻底消失。记住:ForEach的key必须是稳定、可预测的,随机数在渲染里是大忌。

6.2 rotate 旋转后点击热区偏移

我给横梁和托盘整体加 rotate 后,发现左侧托盘的点击区域和显示区域对不上。尤其是倾斜角度较大时,看起来在托盘中心的物品,实际点击命中的却是另一个位置。ArkUI的rotate是对渲染结果做的变换,但点击热区在某些低版本API上并没有同步旋转。解决思路有两个:一是把交互层级从旋转层中拆出去,用覆盖在旋转层之上的透明点击层;二是把角度值限制在15度以内,偏移量控制在十几像素,拖拽命中率尚可接受。我先采用了第二种,未来如果要支持自由拖拽物品,建议改用第一种方案。

6.3 @State 对象深层修改不刷新

开发中我还试过用 @State equation 这样一个对象管理全部游戏状态,然后在点击回调里写this.equation.leftConst--。模拟器上半数情况不刷新UI,需要点击两次才能看到变化。原因前面提过,V1状态管理对对象内部属性的监听并不彻底。推荐做法是基础类型拆开定义,或者使用 @Observed 装饰对象类配合 @ObjectLink 传给子组件。考虑到这个项目的复杂度,我把状态全部拆成了number,简洁且不会有历史包袱。

6.4 关卡切换时状态残留

我刚开始只做了方程状态变化,没有做关卡重置功能。连续玩几关之后发现上一关的物品数量还会残留在托盘上。后来我抽了一个 resetLevel() 方法,负责把三个状态变量一次性重置,并调用 animateTo 把倾斜角归零。重置时还要把求解完成标志位和成功弹窗的文字清空。这里最容易漏的是“成功弹窗的显示标志”,一旦上一关的 isSolved 没有复位,新关卡瞬间判定为求解成功,非常尴尬。每次加新状态时,都要同步检查reset方法是否覆盖了它。

6.5 真机通话打断后的状态恢复

这个小坑一般测试发现不了:正在操作天平时,电话打进来或者屏幕锁定再解锁,应用状态可能回不到原来的样子。因为ArkUI某些版本的默认行为是 Activity 重建后,内存中的 @State 变量恢复成初始值。应用内加一行配置可以避免:在 module.json5 的 ability 配置里给 MainAbility 设置"launchType": "singleton",让应用始终保留同一个实例,状态就不会因为系统生命周期回调而丢失。如果你在这类教学应用上发现玩到一半状态突然归零,优先检查launchType配置。

7. 后续扩展思路分享

其实写到第85个实例,我对这类“用直观模型讲抽象概念”的应用有了更深的体会。天平平衡模拟器完全可以再扩展成系列:加上方程系数 a > 1 的关卡,要求学生先做“合并同类项”再做“两边同除以系数”;或者把砝码改成可拖拽模式,学生自己设计一个平衡等式,系统反过来生成方程;再或者录一段语音讲解,每步操作播报“两边同时拿走一个砝码,天平仍然平衡”。我个人试下来,最有价值的方向是“自由摆放+自动出题”的组合:学生自己摆出一个平衡态,系统把它写成方程,孩子看到自己创造题目的过程,理解会深很多。

最后分享一个小技巧:如果你也打算做这类教学模拟器,建议把所有提示文案做成常量数组集中管理,比如“操作不合法”的提示、求解成功后的庆祝语、关卡说明等。这样后续换语言包或者调整措辞时,不用满代码找文本,改一处就行。我在这次开发里把所有按钮的点击校验都写进了统一的校验函数,调试时只看函数输出就能判断交互是否正确,省下了不少翻代码的时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询