在HarmonyOS的学习路径上,翻来覆去都是登录、打卡、图表这类业务型Demo,真正能把一个“算法 + 交互 + 动画”完整串起来的例子其实不多。我最近在整理《HarmonyOS应用实例》系列时,挑中了“找次品动画演示”这个题目,做完之后发现它特别适合拿来当教学案例:业务逻辑足够经典,涉及ArkTS的数据建模、状态管理、Canvas绘制、显式动画控制,还带一点算法复杂度的小门槛,学会这一个案例,后面写类似的教学演示类应用基本都能触类旁通。
这篇博文就用我自己实际开发的思路来拆解这个实例。适合正在学HarmonyOS应用开发、想从“会写页面”进步到“能写完整功能”的开发者参考。就算你的目标是做工具类App,里面关于状态分层、动画编排的思路也一样能用。我会把需求、算法、代码、踩坑全部分享出来,照着做就能复现出完整可跑的应用。
1. 项目背景:为什么选“找次品”作教学实例
1.1 从一道经典数学题说起
“找次品”是一道非常经典的数学建模题:有一堆外观完全相同的零件,其中混入一个重量异常的产品,需要用一台天平,用最少的称量次数把它找出来。标准解法是三分法——每次把候选品尽量平均分成三堆,称其中两堆,根据天平倾斜情况判断次品在哪一堆,然后递归缩小范围。这个问题的经典之处在于它背后是信息论里的决策树,每次都尽可能消除三分之二的不确定性,所以称量次数按 3 的指数规律减少。
比如 9 个零件,其中 1 个较轻,称 2 次就能找出来;27 个零件也只要称 3 次。如果只是把答案写在纸上,学生很容易理解“要尽量三等分”这个结论,但很难真正感觉到天平倾斜、候选范围变小、下一次称重选择如何变化这个过程。这就是我选择做动画演示的原始动机:把抽象的决策过程变成具象的视觉反馈。
1.2 动画演示的核心价值
在HarmonyOS上做这个演示,本质上是用可视化方式解释一个递归决策过程。相比静态文字或图表,动画能带来三点不可替代的价值:
第一,展示“状态转移”。学生能看到每次称重前的候选集合、天平倾斜后的结果、以及候选范围如何收缩,而不是只看到最终答案。第二,强化“策略选择”。引导用户理解为什么要把球分成三份而不是两份,动画里球的移动分组能直观对比剩余数量。第三,提供“过程回放”。通过上一步、下一步按钮反复观察某个关键步骤,这比老师口头讲效果好得多。
所以这个实例不用做到非常花哨,但必须做到“准确反映算法状态”。我最终实现的是一个带步骤控制、天平拟真动画、以及文字说明的交互式应用。效果上用手机跑起来,既能自己玩,也可以当作课堂上给学生的演示工具。
1.3 这个实例适合什么开发者
如果你处于以下阶段,这个案例的学习价值很高:刚学完HarmonyOS基础组件,想练一练状态管理和动画;已经写过一些列表、表单类页面,但没碰过Canvas绘图;对算法感兴趣,想看看怎么在客户端里真实落地一个递归模型;准备做教学类、科普类或带演示功能的应用。反过来,如果你只对纯UI动效感兴趣,这个案例可能会让你觉得算法部分太重,但它中间其实也包含了不少动画细节,同样值得参考。
2. 需求拆解与整体架构设计
2.1 功能清单与用户交互流程
在动手写代码前,我先把功能列成一个清晰的清单:
- 支持配置总零件数,预设范围我做成 6 到 27 个,因为超过 27 个天赋盘不太好画,容易超出屏幕。
- 支持选择次品性质:偏轻或偏重。做这个开关是为了适配不同讲课场景,实际上算法分支会略有差异。
- 一键生成演示步骤:点击“开始演示”后,自动计算出一整套称重序列,并显示总共需要的称重次数。
- 逐步播放:有“上一步”“下一步”按钮,每一步都伴随天平动画和球的分组动画。
- 重置:恢复初始状态,方便重新换参数尝试。
- 状态栏信息:显示当前是第几次称重、当前候选球数量、次品所在区间提示。
整个交互流程是这样的:用户先选择总数和轻重属性,然后点击“开始演示”,程序立刻计算步骤序列。接着用户点击“下一步”,天平上的球会动画进入左右托盘,托盘倾斜后,界面上方的候选区会更新,同时文字框讲解这步的判断依据。再点下一步继续,直到定位到次品并高亮显示。
这个流程设计里,最关键的一点是“先算完,再播放”。如果一边算一边播放,中间会卡顿,而且很难做回退。所以我让算法模块一次性把完整步骤序列存到数组里,动画界面只负责按索引取数据展示,职责分离,后续调试起来非常省心。
2.2 技术选型:ArkTS + ArkUI + Canvas
这个实例我用Pure语法开发,语言是ArkTS,UI框架用ArkUI声明式开发。主要有几个选型考量:
- 状态管理:用
@State和@Observed。演示运行过程中,当前步数、候选球列表、天平状态都会频繁变化,@State能自动触发UI更新,非常契合。 - 动画方案:天平摆动、球体移动这类复杂动画用
Canvas绘制,通过CanvasRenderingContext2D配合属性变量变化来控制图形重绘。为什么不直接用组件动画?因为球要分组移动到托盘上,还要根据天平倾斜角度调整托盘位置,用Canvas这种命令式绘图反而直观。按钮的出现和消失、高亮提示则用ArkUI内置动画,分工明确。 - 弹层与说明文字:直接用
Text组件 + 条件渲染,不需要自定义弹窗,保证演示界面信息密度合适。 - 数据模型:用
interface和class定义球、步骤节点。ArkTS对类型的约束比较严格,所以我在写模型时尽量用明确的类型,避免用any,这样后期维护和调试都能省不少时间。
2.3 为什么需要分层设计算法与界面
在项目一开始,我差点就把算法写进页面组件里,后来发现这是最坑的方案。因为找次品算法是一个递归搜索过程,里面包含大量临时变量和状态分支,如果和UI状态混在一起,页面会变得很难读,而且每改动一次动画效果,算法逻辑可能就崩了。所以我用了一个非常经典的分层结构:
- model层:定义
Product、WeighResult、StepNode等数据结构。 - algorithm层:
FindFaultyProductSolver类,负责生成完整步骤序列,完全不知道UI的存在。 - view层:页面组件负责展示,只从算法层拿结果。
- animation层:Canvas绘制函数根据当前步骤的索引和状态数据渲染画面。
这样分层后,我甚至可以单独给算法写单元测试,比如用 27 个球跑一遍,看结果是否能在 3 次内找出次品。页面和动画完全不需要打开模拟器,开发效率大幅提升,这也是我强烈建议你拷贝这套分层思路的原因。
3. 核心数据结构与找次品算法实现
3.1 定义球、称重结果、步骤这三类模型
数据模型是整个应用的地基。我先定义一个Product类来表示一个零件:
export class Product { id: number; // 编号 weight: number; // 重量,标准品为10,次品在此基础上偏离 isFaulty: boolean; // 是否为次品 constructor(id: number, weight: number, isFaulty: boolean) { this.id = id; this.weight = weight; this.isFaulty = isFaulty; } }实际演示时会有一个Product[]数组保存所有球。为了动画好处理,我给每个球固定了位置坐标和半径,这些属于视觉属性,不该塞进Product里,所以我额外用一个BallDisplayInfo来保存坐标、颜色等。模型和视图属性分离,这是保持代码清晰的第二道保障。
接下来定义称重结果:
export enum WeighResult { LEFT_DOWN = 'LEFT_DOWN', // 左盘重 RIGHT_DOWN = 'RIGHT_DOWN', // 右盘重 BALANCE = 'BALANCE' // 平衡 }判断称重结果时,只需要比较左右两盘总重量即可。如果次品比标准品轻,那么次品所在的一侧总重量更小,天平会高高翘起;如果次品更重,则相反。这里需要注意,演示时不能只显示“左盘重”这种生硬文字,应该根据次品性质翻译成“次品在左盘/右盘/未称量的那一组”,这才是学生真正需要理解的信息。
最后是步骤节点:
export class StepNode { stepIndex: number; // 第几次称重 leftProductIds: number[]; // 本次称重放在左盘的编号 rightProductIds: number[]; // 本次称重放在右盘的编号 unweighedProductIds: number[]; // 本次没有称量、留在外面的编号 result: WeighResult; // 天平结果 remainingCandidateIds: number[]; // 本次之后候选范围 explanation: string; // 对这一步的算法说明 }StepNode是连接算法和动画的桥梁。界面拿到一个StepNode,就知道左右托盘该画哪些球、剩余候选是哪些、文字说明是什么,后续动画组件的代码几乎都是围绕这个结构展开的。
3.2 三分法算法的递归实现与称重序列生成
算法主体我封装在一个FindFaultyProductSolver类里。核心逻辑可以分解成三步:分组、称重、递归。伪代码如下:
- 如果候选球数量为 1,直接判定它就是次品,结束。
- 把候选球尽量平均分成三组 A、B、C,保证 A 和 B 的数量相同且尽可能接近(若候选数不够三分,则 C 可能为空)。
- 用天平比较 A 和 B 的重量。
- 根据天平结果缩小候选范围:
- 平衡:次品在 C 组。
- 左盘和右盘不平衡:若已知次品偏轻,则次品在重量较小的那组;若已知次品偏重,则次品在重量较大的那组。
- 用缩小后的候选范围递归执行,直到找到次品。
关键代码可以这样写:
export class FindFaultyProductSolver { private steps: StepNode[] = []; private faultyLighter: boolean; constructor(faultyLighter: boolean) { this.faultyLighter = faultyLighter; } solve(products: Product[]): StepNode[] { this.steps = []; const candidateIds = products.map(p => p.id); this.recursiveSolve(products, candidateIds, 1); return this.steps; } private recursiveSolve(products: Product[], candidateIds: number[], stepIndex: number): void { if (candidateIds.length <= 1) { return; } // 将candidateIds三等分 const n = candidateIds.length; const groupSize = Math.floor(n / 3); let leftGroup: number[] = []; let rightGroup: number[] = []; let restGroup: number[] = []; if (n === 2) { // 只剩两个时,左边放一个,右边放一个,剩下的为空 leftGroup = [candidateIds[0]]; rightGroup = [candidateIds[1]]; } else { leftGroup = candidateIds.slice(0, groupSize); rightGroup = candidateIds.slice(groupSize, groupSize * 2); restGroup = candidateIds.slice(groupSize * 2); // 如果restGroup数量比leftGroup多,则移动一个到rightGroup,保证left/right数量接近 while (restGroup.length > leftGroup.length) { rightGroup.push(restGroup.shift() as number); } } // 计算左右两盘实际重量 const leftWeight = this.calculateTotalWeight(products, leftGroup); const rightWeight = this.calculateTotalWeight(products, rightGroup); let result: WeighResult; if (leftWeight === rightWeight) { result = WeighResult.BALANCE; } else { result = leftWeight > rightWeight ? WeighResult.LEFT_DOWN : WeighResult.RIGHT_DOWN; } let nextCandidates: number[] = []; if (result === WeighResult.BALANCE) { nextCandidates = restGroup.length > 0 ? restGroup : (leftGroup.length >= rightGroup.length ? leftGroup : rightGroup); // 这个分支处理n=2时的边界,保证还能继续递归 } else { // 根据次品偏轻还是偏重决定候选范围 if (this.faultyLighter) { nextCandidates = result === WeighResult.LEFT_DOWN ? rightGroup : leftGroup; } else { nextCandidates = result === WeighResult.LEFT_DOWN ? leftGroup : rightGroup; } } const remaining = nextCandidates.length > 0 ? nextCandidates : candidateIds; this.steps.push({ stepIndex: stepIndex, leftProductIds: leftGroup, rightProductIds: rightGroup, unweighedProductIds: restGroup, result: result, remainingCandidateIds: remaining, explanation: this.buildExplanation(leftGroup.length, rightGroup.length, restGroup.length, result) }); this.recursiveSolve(products, remaining, stepIndex + 1); } private calculateTotalWeight(products: Product[], ids: number[]): number { let sum = 0; for (const p of products) { if (ids.includes(p.id)) { sum += p.weight; } } return sum; } private buildExplanation(leftCount: number, rightCount: number, restCount: number, result: WeighResult): string { // 生成适合教学的中文解释 if (result === WeighResult.BALANCE) { return `两边平衡,说明次品在未称量的 ${restCount} 个产品中`; } return `天平倾斜,次品在重量${ this.faultyLighter ? '较轻' : '较重' }的那一侧,当前候选还剩 ${result === WeighResult.LEFT_DOWN ? rightCount : leftCount} 个`; } }需要特别说的是分组细节。很多初学者会直接slice(0, n/3)、slice(n/3, 2n/3),但真跑起来会发现当n不能被 3 整除时,第三组可能比前两组多很多元素,这会影响算法的最优性。我的策略是先把候选数组分成三份,然后动态把多余元素从第三组“挪”到第二组,尽量让三组数量接近。除了n=2这种边界情况,这个分组方法都能保证最少的称重次数。
3.3 从“已知偏轻”到“未知轻重”的复杂度升级
上面代码默认了次品是偏轻还是偏重,但经典的捕次品问题里还有一种更难的场景:只知道有一个次品,但不知道它比正品轻还是重。这种情况下,天平的每次倾斜都只能说明“次品在这堆中,且可能偏轻或偏重”,需要维护“候选集合 + 偏轻/偏重假设”这样的复合状态。
我在当前版本里没有做这个模式,因为动画演示的主角是“三分法策略”,引入未知轻重会让步骤数量明显增加,对教学反而不够直接。如果你打算扩展,我建议单独写一个UnknownWeightSolver,它的数据结构要比StepNode多一个hypotheticalState字段,算法也需要根据历史称重信息排除矛盾。等基础版跑通了,再去挑战这个版本,成就感会翻倍。
4. 动画演示界面的构建与交互控制
4.1 界面布局与状态变量设计
页面整体用Column布局纵向排列。从上到下依次是:参数选择区、状态信息条、Canvas演示区、控制按钮区、文字讲解区。这样在手机竖屏上不需要滑动就能看到全部核心信息。
状态变量我定义成下面这样:
@State totalCount: number = 9; @State faultyLighter: boolean = true; @State allProducts: Product[] = []; @State steps: StepNode[] = []; @State currentStep: number = -1; // -1表示尚未开始 @State isPlaying: boolean = false; @State leftAngle: number = 0; // 天平左倾角度 @State highlightId: number = -1; // 最终找到的次品编号这里最关键的是currentStep。它既控制算法步骤索引,也直接决定Canvas每帧画什么。每切换一个步骤,我就把天平角度重置为 0,再根据steps[currentStep].result设置目标角度,配合animateTo做摆动态效果。而isPlaying则是为了防止用户在动画播放的间隙疯狂点击“下一步”,导致索引错乱。
4.2 天平动画的Canvas绘制方案
Canvas是整个界面里最复杂的部分。我维护了一个CanvasRenderingContext2D对象,在Canvas组件的onReady回调里初始化,然后在页面每次状态变化后调用drawScene()重绘。
天平绘制的坐标体系我这样设计:画布宽度设为canvasWidth,高度canvasHeight。天平支点固定在顶部中央,横梁从支点向左右延伸,长度各为固定值。左右托盘的位置随角度变化,倾斜角度在-10到10度之间。左盘下降时leftAngle为正,右盘下降时leftAngle为负。所有球都绘制成圆形,半径统一为 14,标准品用淡灰色填充,次品最后用红色高亮。
球在托盘上的排列需要动态计算。左盘的中心点坐标可以这样求:
const pivotX = canvasWidth / 2; const pivotY = 120; const beamLength = 140; const leftPanX = pivotX - beamLength * Math.cos(angle * Math.PI / 180); const leftPanY = pivotY + beamLength * Math.sin(angle * Math.PI / 180); const rightPanX = pivotX + beamLength * Math.cos(angle * Math.PI / 180); const rightPanY = pivotY - beamLength * Math.sin(angle * Math.PI / 180);角度为 0 时,两个托盘保持水平。角度为正时,左盘高度下降,右盘上升。球的绘制位置就在托盘中心点附近,按三个弧线排列。为什么用弧线?因为托盘是有深度的,球不能全部挤在同一个点上,视觉上错开一点更真实。这个细节一开始没注意,画出来的球会全部叠在一起,很难看。
4.3 步骤播放、回退与重置的实现技巧
步骤控制我采用索引加锁的组合:
nextStep() { if (this.isPlaying || this.currentStep >= this.steps.length - 1) { return; } this.isPlaying = true; this.currentStep++; const step = this.steps[this.currentStep]; this.playWeighAnimation(step); } playWeighAnimation(step: StepNode) { // 先播放托盘动画 this.animateToAngle(this.getAngleForResult(step.result)); // 动画结束后更新候选状态 setTimeout(() => { this.isPlaying = false; if (step.remainingCandidateIds.length === 1) { this.highlightId = step.remainingCandidateIds[0]; } }, 600); }回退的时候,不能简单把索引减一,还要把高亮状态清掉,把天平角度归零。重置就更简单:重新生成所有球,清空步骤,currentStep设为 -1,高亮设为 -1。
我还加了一个“自动播放”模式:点击后每隔 1.2 秒自动执行一次 nextStep,直到结束。这个模式适合展示给课堂上的学生看,不用手动一直点。自动播放时按钮文字会变成“暂停”,用同一个按钮控制两种状态,交互更紧凑。
5. 完整关键代码解说(可以直接抄作业)
5.1 工具类与算法模块
算法模块已经给出大致代码,实际项目里我还会多写一个工厂方法,用来生成初始产品数组:
export function generateProducts(count: number, faultyIndex: number, faultyLighter: boolean): Product[] { const products: Product[] = []; const normalWeight = 10; const faultyWeight = faultyLighter ? 9 : 11; for (let i = 0; i < count; i++) { if (i === faultyIndex) { products.push(new Product(i, faultyWeight, true)); } else { products.push(new Product(i, normalWeight, false)); } } return products; }这里faultyIndex在运行时是随机的,但生成步骤序列之前必须确定下来。我建议用固定种子或让用户在测试时可以手动指定次品编号,方便验证算法正确性。比如加一个“指定次品编号”的输入框,输入 0 到总数减 1 之间的数,这样你调试的时候想验证某个边界情况会容易很多。
5.2 页面状态与动画触发
页面的状态绑定用ArkUI的@State变量。按钮事件里最多的操作就是调用算法层的solve()然后赋值给this.steps。有一处需要特别小心:StepNode内部的数组都是普通对象,不是@Observed,所以当currentStep变化时,Canvas重绘需要读取最新的步骤数据,但不会触发组件更新。我的做法是让currentStep本身作为@State变量,每次点击后让this.currentStep++,这样页面整体刷新,自然能拿到最新数据。
动画部分我推荐使用animateTo,而不是自己开定时器。因为在HarmonyOS上,animateTo能和状态管理、组件属性动画完美配合,用定时器反而容易造成卡顿。天平角度就是一个普通@State属性,调用animateTo修改它时,Canvas的drawScene会通过onDraw刷新。我的简化实现是:
private animateToAngle(targetAngle: number) { animateTo({ duration: 500, curve: Curve.EaseOut }, () => { this.leftAngle = targetAngle; }); }animateTo闭包里改变的状态会自动产生动画效果。而Canvas的每帧绘制,实际上是在状态变化后重新执行drawScene。如果你发现动画不生效,检查一下animateTo是不是在异步回调外面调用的,以及是否真的改变了@State变量。这两个都是高频出错点。
5.3 绘制天平的Canvas代码梳理
我抽出核心的绘制函数,方便你对照理解:
buildCanvas() { this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight); this.context.lineWidth = 4; this.context.strokeStyle = '#704214'; const pivotX = this.canvasWidth / 2; const pivotY = 120; const angleRad = this.leftAngle * Math.PI / 180; const leftX = pivotX - 140 * Math.cos(angleRad); const leftY = pivotY + 140 * Math.sin(angleRad); const rightX = pivotX + 140 * Math.cos(angleRad); const rightY = pivotY - 140 * Math.sin(angleRad); // 画横梁 this.context.beginPath(); this.context.moveTo(leftX, leftY); this.context.lineTo(rightX, rightY); this.context.stroke(); // 画左盘 this.context.beginPath(); this.context.arc(leftX, leftY + 30, 40, 0, Math.PI * 2); this.context.stroke(); // 画右盘 this.context.beginPath(); this.context.arc(rightX, rightY + 30, 40, 0, Math.PI * 2); this.context.stroke(); // 根据currentStep绘制对应球 const step = this.steps[this.currentStep]; if (step) { this.drawBallsOnPan(step.leftProductIds, leftX, leftY + 30); this.drawBallsOnPan(step.rightProductIds, rightX, rightY + 30); this.drawUnweighedBalls(step.unweighedProductIds); } }这里有一个简化:圆弧托盘是画半圆还是圆,并不影响角落里的球显示。真正要保证的是球的坐标要和托盘中心对齐,所以我在drawBallsOnPan里以托盘圆心为基准,在水平方向做一行排列,最多一行放 5 个球,超出部分换行。对于 27 个球的场景,每侧最多放 9 个球,画布宽度足够,但球的半径要稍微缩小,所以在页面布局时会根据总数动态调整球半径。
6. 开发中遇到的坑与排查实录
6.1 Canvas不刷新的问题
我一开始在currentStep更新后调用context.clearRect(),但画面纹丝不动。后来排查发现:单纯改变Canvas绘制内容不会触发UI刷新,必须有一个@State变量作为触发器。Canvas组件并不会自动监听context内部状态。解决方法是把所有需要重绘的标志位放到@State里,比如currentStep,然后在页面状态更新时重新调用buildCanvas()。还有一点:onReady回调里才初始化CanvasRenderingContext2D,不要在此之前调用clearRect,否则会报错。
6.2 动画过程中的事件穿透
快速点击“下一步”时,会同时触发好几次animateTo,造成角度忽正忽负,视觉上像天平抽搐。我一开始没设置锁,结果演示到一半界面完全乱掉。后来强制用isPlaying变量作为锁,点击后先判断是否在播放中,播放期间直接返回。另外还要在动画结束前禁用按钮,我通过Button.enabled(this.isPlaying === false)绑定,这样用户看到按钮变灰,交互反馈也更明确。
6.3 递归生成步骤太多导致内存抖动
当候选数达到 81 甚至更高时,步骤序列本身并不长,但每一步的remainingCandidateIds都保存了大量数组引用,如果产品对象也复制一份,内存就会明显增长。我的做法是Product对象全程只创建一次,StepNode里保存的只是number[]编号数组,而不是产品对象数组。这样即使有 20 步,每步只存储几十个整型编号,内存几乎可以忽略。这也是为什么我要单独定义id字段的原因,否则动画时得查对象引用,既啰嗦又费内存。
6.4 折叠屏与横屏适配的一点经验
最初我在平板上跑,发现天平比例会被拉伸,因为Canvas宽度固定,但平板屏幕宽度更大,导致画面左侧出现大片空白。后来我把Canvas尺寸改成根据组件宽度动态计算:
onAreaChange(oldValue: Area, newValue: Area) { this.canvasWidth = newValue.width as number; this.canvasHeight = 280; if (this.context) { this.buildCanvas(); } }onAreaChange在组件尺寸变化时触发,横竖屏切换也会自动调整。调整之后,天平的横梁长度根据canvasWidth动态缩放,球的半径也根据总数自适应。如果你打算适配折叠屏,记得不要在aboutToAppear里写死尺寸,一定要用实际渲染高度。
7. 扩展思路与个人经验收尾
做完这个“找次品”动画演示后,我有几个明显的体会。第一,HarmonyOS应用开发并不难,难的是把复杂逻辑塞进“状态驱动”的思维模型里。这个案例里currentStep带动整个界面刷新,跟我平时做业务表单联动高度一致,练一次比写十个静态页面都有用。第二,算法类和动画类代码一定要分层。项目后期我几乎只改Canvas绘制函数,算法代码一行没动,这种隔离带来的安全感非常值得。第三,教学类应用,宁可动画慢一点,也不能让用户看不懂关键状态。
最后分享一个小技巧:我给StepNode里的explanation字段写了解释文案,并在每次生成步骤时动态渲染。很多初学者会把这个字段当成静态文本直接写在界面上,但实际它的内容依赖每次分组结果,所以必须在算法层就拼接好。用同样的思路,你也能把“汉诺塔”“六度分隔”“最小生成树”之类的算法做成HarmonyOS动画演示,无非是先设计数据模型,再设计状态机,最后画Canvas。希望这篇实例对你有用,欢迎拿去改造出你自己的教学Demo。