找次品动画演示:HarmonyOS ArkTS状态管理与ArkUI动画实战
前阵子在 DevEco Studio 里刷华为官方示例集按顺序整理到自己练习库里的时候正好做到“HarmonyOS 应用实例 97找次品动画演示”。这个题目一看就很戳我。名字里的“找次品”是小学数学里特别经典的逻辑题一堆外观完全一样的球里面混了一个重量异常的要求用不带砝码的天平把它找出来。官方把它做成动画演示等于把纯脑内推演的过程变成了看得见的步骤正好把 ArkTS 状态管理、ArkUI 动画、定时回调这三块硬骨头一次串起来练手。这篇文章我打算把从需求拆解、数据建模到动画实现、播放控制的完整思路写清楚新手可以直接抄骨架已经写过几个页面的朋友可以重点看后半部分的踩坑清单那些才是我真正耗时间的点。1. 先想清楚动画演示到底在演示什么1.1 找次品问题的数学内核先把这个问题的底子理明白。假设有 N 个球其中只有一个球重量异常并且在教学场景里通常先假设它“偏轻”。天平每次称量只能给出三种结果左边重、平衡、右边重。这其实是一个三叉决策树每一次称量都能把候选范围最多缩小到原来的三分之一。所以最坏情况下需要的称量次数 k 满足 3^k N也就是说 k 是 N 对 3 取对数的上整。我开发第一个版本时表格直接放在界面右侧当提示用球数范围最坏称量次数说明10不用称2~31一次就能定位4~92第一次三分第二次定位10~273三分两轮后仍要一轮28~814演示九宫格分组这里有个很容易踩的思维惯性很多人第一反应是用二分法一半一半比较。二分法效率低因为天平天平一次的三种结果只用了两种白白浪费了一个“平衡”的信息位。三分法才是这个问题的标准套路。动画演示的意义也在这里把每轮分组、每轮淘汰的过程可视化学生能直接看到为什么每次称完候选球会少一大截。对开发者来说这意味着步骤结构天然是递归的每次称量对应一组 left/right/remain非常适合用数组加状态机驱动。1.2 应用功能与页面结构规划我画功能清单时没有给它加太多花活核心就四件事生成球、按三分法自动产生称量步骤、用动画展示天平和球的动态变化、支持手动步进和自动播放。UI 上我用了一个三段式布局顶部是当前步骤的文字说明中间是舞台区底部是控制条。顶部说明区放一个 Text显示类似“第 1 步将 1~9 号球分为三组先称 1、2、3 号与 4、5、6 号”这种话。这个描述不是写死的而是由当前步骤对象里的 desc 字段动态生成。舞台区是重点我用 Stack 把天平支架和托盘叠在一起球则用自定义子组件渲染每个球的坐标由状态控制。底部控制条依次是上一步、播放/暂停、下一步、重置外加一个 Slider 进度条方便手动拖到任意步骤查看。从技术选型上说这个应用完全不需要引入复杂框架ArkUI 声明式写法就够了。状态集中放在一个可观察对象里动画统一用 animateTo 驱动定时播放用 setInterval 加 clearInterval 控制这三个能力正好是官方示例里最常组合出现的基础功。下面我会按数据层、动画层、控制层三块逐步拆。2. 数据建模与状态管理球的身份与筛选路径2.1 球的建模必须轻且稳定动画里看起来每个球只是一个小圆片但程序里它需要携带身份、当前状态和可选的坐标信息。我一开始图省事直接用数字数组表示候选球后来发现步骤回溯和状态标记特别绕还是老老实实建了一个 Ball 类。enum BallStatus { Normal normal, // 尚未参与筛选 Suspect suspect, // 当前候选 Eliminated eliminated, // 已被排除 Confirmed confirmed // 最终锁定为次品 } Observed class Ball { id: number; label: string; status: BallStatus BallStatus.Normal; group: number -1; // 当前轮次所属分组下标仅在展示时使用 constructor(id: number) { this.id id; this.label 球${id}; } }这里有两个细节值得注意。第一个是字段一定要少。很多初学者建模时喜欢把“是否上秤”“是否在左盘”“是否透明”这种 UI 状态全塞进数据对象结果状态一多互相组合出来的情况根本测不完。我用的是 status 枚举加 group 下标UI 层的临时状态全部由组件内部推算数据层保持干净。第二个细节是 Observed 装饰器。如果在 ArkUI 里直接用普通 class 当数据源修改嵌套属性时界面很可能不会刷新标了 Observed 之后属性级变更才能被观测到。这两个点后面踩坑部分会展开说。2.2 步骤对象把算法结论翻译成动画指令单纯有球的状态还不够动画需要知道“每一步谁在左盘、谁在右盘、结果是什么、剩下哪些候选”。我定义了一个 WeighStep 类class WeighStep { leftIds: number[] []; rightIds: number[] []; verdict: number 0; // -1 表示左盘上翘0 表示平衡1 表示右盘上翘 remainIds: number[] []; desc: string ; constructor(left: number[], right: number[], result: number, remain: number[], desc: string) { this.leftIds left; this.rightIds right; this.verdict result; this.remainIds remain; this.desc desc; } }步骤的生成算法其实就是三分法的递归实现。我简化后的核心逻辑长这样buildSteps(ids: number[], targetId: number): WeighStep[] { const steps: WeighStep[] []; let current ids.slice(); while (current.length 1) { const size Math.ceil(current.length / 3); const left current.slice(0, size); const right current.slice(size, size * 2); const rest current.slice(size * 2); let result 0; if (left.indexOf(targetId) 0) result -1; else if (right.indexOf(targetId) 0) result 1; const remain result 0 ? rest : (result -1 ? left : right); steps.push(new WeighStep(left, right, result, remain, this.buildDesc(left, right, result))); current remain; } return steps; }这里我用的假设是“次品偏轻”所以当次品在左盘时左盘上翘verdict 记为 -1次品在右盘时右盘上翘verdict 记为 1。如果把演示模式扩展为“偏重”只需要把 result 的判定反过来其它逻辑完全复用。这个步骤数组是整个应用的“剧本”动画层和控制层都围绕它转所以我在代码里把 buildDesc 也做成了根据 leftIds、rightIds 自动拼装中文说明避免手写描述和算法结果对不上。2.3 组件的状态通信方案在组件结构上我拆了三个层级页面根组件 Index 持有所有状态中间的 Stage 组件只管布局和动画最底下的 BallView 负责渲染单个球。状态传递我按照数据流方向分了三种方式页面根组件用 State 持有 balls、steps、currentStepIndex、tilt 等核心状态Stage 组件只需要读数据所以用普通成员变量传入即可不需要 Prop 甚至 LinkBallView 需要感知 ball.status 的变化以便切换颜色和透明度所以用 ObjectLink 关联 Ball 实例。这里有个容易搞混的点很多人喜欢把所有变量都标成 State其实 ArkUI 里越往下的组件越应该“被动”。子组件能用普通参数传入就别用 Prop能用 ObjectLink 就别用 Link双向绑定越多状态变更的排查越困难。这也是我在这次项目里体会最深的一条设计原则。3. 天平和球的动画动效不是靠“动图”而是靠状态驱动3.1 天平结构的搭建与倾斜原理看到“动画演示”这四个字很多人第一反应是找一张天平 GIF 放到页面里再叠加球元素。这种方案看着省事实际很难做出“左盘高右盘低”的联动反馈而且 GIF 没法根据步骤动态变化。我选择的是纯 UI 搭建加属性动画用 Column、Row、圆角矩形组件拼出天平的支架和托盘然后用一个 rotation 属性控制整体倾斜。天平的核心结构是支架与横梁。横梁我用一个水平长条表示左盘和右盘分别放在横梁两端下方。为了让左右两个盘能同步倾斜我把“左盘右盘横梁”放进同一个容器倾斜时给这个容器设置 rotation 角度旋转中心放在容器底部中间物理上就模拟了天平支点效果。不要拆开分别旋转左右盘那样两个盘的动作很难保证同步看起来会很假。代码示意如下Stack() { // 底座 Column() .width(8) .height(120) // 支架细节省略 Column() { Row() { Text(左盘) // 实际用圆角矩形 Text(右盘) } // 横梁等 } .rotate({ angle: this.tiltAngle, centerX: 50%, centerY: 100% }) }tiltAngle 是由状态 tilt 计算出来的角度值。我在声明式代码里不放动画逻辑只放状态映射函数所有动效都交给 animateTo 去触发。3.2 animateTo让状态变化自动生成过渡ArkUI 动画的核心思路是“状态变化即动画源”。你要做的不是亲手去改每个像素位置而是定义一个目标状态再告诉框架用多长时间、什么曲线完成过渡。anim 的典型用法是在闭包里修改状态变量。State tilt: number 0; // -1 左高0 平衡1 右高 private performStep(step: WeighStep) { animateTo({ duration: 700, curve: Curve.EaseInOut, onFinish: () { this.autoPlayNextIfNeeded(); } }, () { this.tilt step.verdict; this.balls.forEach((ball: Ball) { ball.status step.remainIds.indexOf(ball.id) 0 ? BallStatus.Suspect : BallStatus.Eliminated; }); }); }在闭包里同时修改 tilt 和各个球的状态它们会在同一段 700 毫秒内同步过渡视觉上就是天平慢慢倾斜同时被排除的球开始淡出。这个同步性是关键卖点如果分两次 animateTo先倾斜再变色视觉节奏就会断裂看起来像两个应用拼在一起。我在第一版犯过这个错后来把所有同一逻辑步骤的状态变更统一塞进一个闭包效果立刻自然了。3.3 球的位移、缩放与最终锁定球的位置变化有两种思路。如果球始终待在托盘上只是组合关系变化那么简单地把球放进对应 Row 就行。但找次品演示有一个特殊环节被排除的球要“离开托盘”最终锁定的球要“放大高亮”如果全用布局切换来做球会瞬间跳动没有任何过渡。我的方案是给每个球维护一套位置偏移数据。在 applyStep 时为每个球计算本轮的目标偏移被排除的球向上平移并降低透明度候选球保持在托盘原位置最终锁定的球整体 scale 放大并增加描边。这些目标值也放在一个状态对象里在同一个 animateTo 闭包中赋值。// 以简化示意为准 private computeTargets(step: WeighStep) { const targets new Mapnumber, Target(); this.balls.forEach((ball: Ball) { const eliminated step.remainIds.indexOf(ball.id) 0; targets.set(ball.id, { x: ball.originX, y: eliminated ? ball.originY - 60 : ball.originY, opacity: eliminated ? 0.2 : 1, scale: step.remainIds.length 0 ? 1.2 : 1 }); }); return targets; }这段代码里我故意把“最终锁定”退化成候选数量为 1 的情况逻辑简单不绕。真正做的时候你还可以给锁定球加一个呼吸效果用 repeat 模式的显式动画让描边闪烁这样最后的结论会非常醒目。3.4 动画曲线的选择动画曲线我统一用了 Curve.EaseInOut搜索过渡开头快、结尾慢看起来最像真实机械动作。前期调试时我试过 Linear放慢到 2000 毫秒时会有一种“机械臂匀速搬运”的僵硬感试过 EaseOut倾斜动作后半段拖得有点长影响连续播放的节奏。如果你想让“天平倒下”更有重量感可以把倾斜动作改成 SpringEffect但下面的球状态变化如果也用弹簧就会出现抖动甚至来回弹跳观感反而差。所以我的经验是一个步骤里不同属性可以使用不同曲线但曲线种类别超过两种。曲线多了观众注意力会被分散教学效果就弱了。4. 播放控制与流程管理把步骤变成可回放、可拖拽的时间轴4.1 演示播放状态机动画本身不难难的是让用户随时暂停、上一步、下一步、拖进度条每次切换都保持界面状态一致。我先定义了一个播放状态机type Phase ready | playing | paused | finished; State phase: Phase ready; State currentStep: number -1; // -1 表示初始未开始ready 是初始状态此时所有球都是 Normalplaying 是自动播放中paused 是手动中断finished 是最后一步已经执行完毕。按钮的禁用逻辑跟着状态机走播放只在 ready 或 paused 时可用上一步在 currentStep -1 时可用下一步在 currentStep steps.length - 1 时可用。如果不做状态机直接在按钮里堆判断后面加 Slider 拖动时很容易漏条件代码越来越乱。4.2 步进与渲染前进一步还是从头回放一开始我试图实现真正的“上一步”也就是记录每一步之前的状态逆序恢复。后来发现这个演示场景根本没必要整个流程最多十几步数据量极小与其维护一个复杂的历史栈不如直接“从初始状态重新执行到目标步骤”。我封装了一个 showStep(targetIndex) 方法先重置所有球的状态再把 steps[0] 到 steps[targetIndex] 全部依次 apply整个过程中关闭动画直接跳到最终状态。private jumpTo(targetIndex: number) { // 先复位所有球 this.balls.forEach((ball: Ball) { ball.status BallStatus.Normal; }); this.tilt 0; // 顺序执行到目标步骤不做过渡动画 for (let i 0; i targetIndex; i) { this.applyStep(this.steps[i], false); } this.currentStep targetIndex; }这里 applyStep 第二个参数控制是否带动画。跳转时传 false表示直接设置最终状态自动播放时传 true表示走动画。这样实现“上一步”只需要 jumpTo(currentStep - 1)实现“下一步”需要多走一条动画而拖动 Slider 则直接连续执行到目标位置。这个方案简单可靠比维护状态快照省心太多。4.3 定时播放与生命周期清理自动播放是典型的需求用 setInterval 驱动。官方示例里这类场景很多但我见过不少初学者在页面销毁时忘了清定时器结果返回上一页还能听到间隔触发回调。我写的时候把启动和暂停收敛在两个方法里生命周期钩子也统一调用 pause。private startAutoPlay() { if (this.phase finished) return; this.phase playing; this.timer setInterval(() { if (this.currentStep this.steps.length - 1) { this.pause(); return; } this.stepForward(); }, 1600); } private pause() { this.phase paused; if (this.timer) { clearInterval(this.timer); this.timer 0; } } aboutToDisappear() { this.pause(); }还有个细节值得注意如果是用户点了“下一步”按钮此时定时器还在跑两步会被叠在一起。所以我在 stepForward 方法开头必须判断 phase 是否为 playing如果不是自动播放就手动执行否则交给定时器。更稳妥的做法是任何手动操作都先 pause再执行相应步骤这样能避免绝大多数竞态问题。4.4 进度条与实时更新ArkUI 的 Slider 组件很适合做进度条onChange 事件返回当前值。我让 Slider 的 max 等于 steps.length - 1value 绑定 currentStep然后在 onChange 里调用 jumpTo(value)。唯一要注意的是Slider 拖动时 onChange 触发频率很高而 jumpTo 里会做大量 Object 遍历。演示场景步骤少还好如果后面扩展成几十步建议加一个 debounce 或者只在 onChangeEnd 时执行跳转避免拖动过程中反复重建状态。5. 实操中的踩坑记录状态不刷新、动画错乱与定时器陷阱5.1 State 数组元素的属性修改不刷新这是我最开始被坑得最惨的一个问题。代码里直接写 this.balls[0].status BallStatus.Eliminated运行后界面纹丝不动。原因在于 State 装饰器对数组的观测粒度是数组整体替换或增删方法对象内部的属性修改如果没有 Observed 标记框架不知道你改了数据。我最后的统一处理方式就是给 Ball 类加 Observed让属性级变更可被观测。如果不想改类结构也可以每次整体替换数组this.balls this.balls.map((ball, index) { if (index 0) { return { ...ball, status: BallStatus.Eliminated }; } return ball; });但要注意对这个项目里 Ball 是类实例的情况展开运算符会丢失原型链上的方法所以更推荐 Observed 方案。5.2 ForEach 的主键不能随手用 indexForEach 的 keyGenerator 参数最好返回一个稳定且唯一的标识。我第一次图省事直接返回 index.toString()结果发现动画播放时球的位置和状态经常串跳转步骤时本该淡出的球反而出现在另一个位置。原因就是 index 会因为数组元素增删而漂移导致旧组件被复用。后来我统一返回ball-${ball.id}问题立刻消失。这个坑在官方文档里反复强调但是不改一遍代码真的记不住。5.3 动画 onFinish 回调里读到了旧引用在 animateTo 的 onFinish 里我一开始习惯直接通过 this 访问 step。但连续快速点击“下一步”时onFinish 触发时 this.currentStep 已经变了紧接着 nextStepIfPlaying 会拿错索引。解决方法是把当前 step 保存为局部变量传入回调private stepForwardWithAnimation() { const targetIndex this.currentStep 1; const step this.steps[targetIndex]; if (!step) return; this.currentStep targetIndex; animateTo({ duration: 700, onFinish: () this.afterStepDone(step) }, () { this.applyVisual(step); }); }只要回调里用闭包捕获的 step而不是再去读 this.steps[this.currentStep]这个竞态基本就规避了。5.4 自动播放与手动操作互相打架我曾在玩到一半时点了播放按钮然后又立即拖 Slider结果球的状态变得很诡异有的已经淡出有的还在托盘上。排查后发现是定时器还活着又叠加了一次 stepForward。最后的解决方式是“操作前先暂停”原则手动上一步、下一步、拖进度条均先 clearInterval再执行对应操作只有播放按钮才会启动定时器。这个策略虽然少了一些“边播边拖”的操作快感但在教学演示场景里稳定压倒一切。5.5 大批次渲染与尺寸适配默认演示是 9 个球看起来毫无压力。但如果把模式改大比如 81 个甚至 243 个一次性把所有球全部铺在舞台上小屏设备会非常拥挤而且动画瞬间变卡。我做的是让球列表容器支持横向滚动超过阈值时用 LazyForEach 懒加载只渲染可视区内的球。这个优化对官方这个示例可能多余但如果你想扩展成通用演示工具这一步一定要提前想好。另一个适配坑是固定 vp 宽度在平板横屏下很丑建议舞台区宽度用相对比例让球均匀分布在容器剩余空间里具体可以用 onAreaChange 拿到容器实际尺寸再动态计算。5.6 每次 build 里的计算量要克制不少人在写 UI 时习惯把步骤描述、剩余候选数、颜色值全都放在 build 里用三元表达式实时推导。这个应用数据量小看不出来但自动播放时 onFinish 连着多次触发build 会被高频调用如果每个球都要在 build 里做 Arrays 查找累计开销很可观。我实际做的时候把“当前候选集合”“当前淘汰集合”预先算好存储在 State 层子组件只做取值显示避免在渲染过程中反复搜索数组。这里的思路很简单能用状态缓存的计算绝不放渲染阶段做越底层组件越只做展示。6. 最后聊一点我的个人习惯整个示例做下来我最深的体会是动画类应用要先把“数据剧本”写好再去碰动效。好多朋友做类似 Demo 时一上来就调 animateTo结果步骤逻辑和视觉表现耦合在一起改一个界面就要改一堆算法。我的顺序恰好相反先把 buildSteps 生成好再用 jumpTo 验证每一步的状态都是对的最后才给 applyStep 加动画和 onFinish 回调。这样即使后面把 Curve 从 EaseInOut 换成 SpringEffect或者加音效数据层都不受影响。另外一个小建议是可以把步骤生成器抽出来作为纯函数单独用日志打印每轮的 left、right、verdict、remain确认算法输出和预期一致。不要靠界面上的视觉效果反推算法对不对那次调试成本真的高到让人崩溃。希望这份记录对你做类似的 HarmonyOS 动画演示项目有帮助尤其是后半段的坑照着排一次能省一晚上。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →