直接上一线场景:我在给家里小孩讲数学广角里的“烙饼问题”时,发现光靠课本上的流程图和老师的口诀,孩子能记住“3张饼要9分钟”的结论,但根本不理解“为什么要先放两张再换一张”背后的调度逻辑。于是我自己动手,在HarmonyOS上做了一个“烙饼问题模拟器”小应用,让人通过拖拽饼、开关锅盖、看倒计时,亲手把每一轮操作跑一遍。这篇文章就把这个项目的完整拆解、数学原理的映射、ArkTS的关键实现,以及我在开发过程中踩过的坑全盘写出来,给同样想用鸿蒙做教育类小工具的朋友当参考。
1. 项目核心设计与需求拆解
1.1 烙饼问题的数学模型到底在说什么
先花半分钟把烙饼问题的数学本质说透,因为整个应用的代码结构完全是围着这个模型转的。问题描述很简单:一口锅同时最多烙2张饼,每张饼有正反两面,每面需要烙3分钟。问烙n张饼最少需要多少分钟。
经典结论大家可能都听过:1张饼6分钟,2张饼6分钟,3张饼9分钟,4张饼12分钟,n张饼(n≥2)就是3n分钟。但真正理解这个结论,需要抓住两个关键视角。
第一个视角是“面”的视角:n张饼一共产生2n个“面”需要处理,锅每轮能处理2个面,所以最少需要n轮,每轮3分钟,总时间就是3n分钟。这个推导是最底层的,也是程序里计算最优时间的数学依据。
第二个视角是“空闲”的视角:为什么2张饼明明只需要2轮共6分钟,而直觉做法“先烙两张,再单独烙第三张”要12分钟?差别就在于锅每轮必须放满2个面,否则就有锅的空闲时间。3张饼的最优方案9分钟,本质是通过“第1轮放A正面+B正面,第2轮放A反面+C正面,第3轮放B反面+C反面”,让锅在每一轮都保持满载。
我在设计模拟器时,把“每一轮锅放满两个面”作为核心规则强制约束,用户如果在某轮没有放满2个面,就提示“锅没放满,时间浪费了”,这样比直接给结论更能让学生建立优化意识。
1.2 为什么用HarmonyOS做教学模拟器
选HarmonyOS而不是Web网页或者微信小程序,有几个实际考虑。
一是多设备适配。教育类应用最常见的场景是学生在平板上操作、老师在教室大屏上演示、家长在手机上陪练。HarmonyOS的ArkUI声明式布局天然支持多端自适应,一套代码可以在手机、平板、折叠屏上跑,这点比传统Android Fragment适配省事不少。
二是碰一碰流转能力。我在应用里预留了分布式流转的接口,后续可以做“老师控制、学生观看”的课堂模式,老师手机上的演示画面可以直接流转到教室智慧屏上,学生跟着老师的操作节奏走。这个能力是鸿蒙生态独有的,也是我选这个平台的重要原因。
三是ArkTS的开发体验。声明式UI + 状态管理驱动的模式,非常适合“饼的位置、正反面状态、当前轮次”这种强状态类应用。把饼拖进锅里,UI自动刷新,逻辑和视图的耦合度很低。
这个项目适合的读者有两类:一类是鸿蒙开发者,想看看教育类场景怎么拆解、状态怎么管理、动画怎么设计;另一类是老师和家长,想给学生做一个能动手操作的数学教具。我尽量把数学推导和代码实现都讲清楚,两边都能对上。
1.3 功能清单与技术指标
在动手写代码之前,我把功能拆成了以下模块:
| 模块 | 具体功能 | 技术要点 |
|---|---|---|
| 参数选择 | 设置饼的数量(1-10张) | 滑块组件 + 数值校验 |
| 模拟控制 | 开始、暂停、重置 | 状态机管理 |
| 锅具展示 | 2个锅位,饼的放入、翻面、取出 | 位移动画 + 悬停状态 |
| 轮次计时 | 每轮3分钟(教学模式可加速) | 定时器 + 倒计时显示 |
| 结果对比 | 用户操作总时间 vs 最优时间 | 动态计算与反馈 |
| 策略提示 | 高亮显示可优化的轮次 | 规则引擎判断 |
技术栈选型:语言用ArkTS,UI框架用ArkUI,状态管理用@State + @Observed + @ObjectLink的组合,动画用animateTo + 显式动画。整个项目只有手机端入口,不涉及复杂后台。
2. 核心实现原理:从数学问题到状态机
2.1 数据模型:饼、锅位、轮次的关系设计
烙饼问题的关键状态有三个维度:饼本身(正反面状态)、锅(两个锅位的占用情况)、轮次(当前处于第几轮、这一轮过了多少时间)。这三个维度如果拆成散落的变量,代码很快就乱套了。我参照传统“类 - 对象”的思路,设计了三个模型类。
第一是饼的模型。每张饼需要记录:唯一ID、名称(A/B/C)、正面是否已熟、反面是否已熟、当前位置(锅位1、锅位2、等待区、完成区)。正反面状态用枚举定义:
enum SideStatus { RAW = 0, // 生 COOKED = 1, // 熟 } enum BreadLocation { READY_AREA = 0, // 等待区 PAN_SLOT_1 = 1, // 锅位1 PAN_SLOT_2 = 2, // 锅位2 DONE_AREA = 3, // 完成区 } @Observed class Bread { id: number; label: string; frontStatus: SideStatus = SideStatus.RAW; backStatus: SideStatus = SideStatus.RAW; location: BreadLocation = BreadLocation.READY_AREA; constructor(id: number, label: string) { this.id = id; this.label = label; } }这里有个关键设计决策:正反面状态直接挂在饼对象上,而不是挂在“锅位”上。因为一张饼被翻面后,它的“当前朝上的面”变了,但锅位并没有变。如果状态挂在锅位上,翻面时还得同步修改锅位和饼两份数据,容易出现不一致。挂在饼对象上,锅位只负责记录“放着哪张饼”,翻面时只需要修改饼对象内部的状态,锅位不用动,职责清晰很多。
第二是锅的模型。锅只有两个锅位,每个锅位要么为空,要么放着一张饼。锅本身还有一个“是否在加热”的状态,因为饼放进锅不代表马上开始烙,用户需要点“开始加热”才进入倒计时。这个设计是为了模拟真实场景——你放饼进去还要盖锅盖,盖了才算开始。
@Observed class Pan { slot1: Bread | null = null; slot2: Bread | null = null; isHeating: boolean = false; currentRound: number = 0; roundElapsed: number = 0; }第三是控制状态。整个模拟器的运行状态用枚举管理:IDLE(待开始)、READY_TO_ROUND(当前轮次准备完毕,等待加热)、HEATING(正在加热)、ROUND_FINISHED(当前轮结束)、FINISHED(全部完成)。这个状态机的设计是为了防止用户乱操作,比如没放饼就点加热、正在加热就把饼拿出来之类的非法操作。
2.2 轮次机制:为什么用“轮”而不是“分钟”做时间轴
烙饼问题的核心调度单位是“轮”,每轮固定3分钟。如果用分钟做时间轴,用户就得每过1分钟去看一眼锅,操作粒度太细,而且容易混淆“第几分钟在做什么”。用轮做时间轴,用户只需要关注“这一轮锅里的状态是什么”,逻辑上更贴近数学模型的推导过程。
每轮的操作流程固定为4步:选饼放入锅位(必须放满2个) -> 确认本轮要烙的面(默认烙朝上的面) -> 开始加热,倒计时3分钟 -> 计时结束,本轮自动翻面。一轮结束后,锅里的饼自动执行翻面动作(正反状态互换),然后用户决定哪些饼出锅、哪些饼留在锅里进入下一轮。这个“出锅决策”正是烙饼问题中最考验策略的地方。
我举个例子说明这个机制的重要性。假设现在有3张饼,用户如果第一轮放A和B,第二轮放A和C,第三轮放B和C,每轮都保持锅满,最终用的时间是9分钟。但如果第一轮放A和B,第二轮把A取出放C(B还在),第三轮再放B和C之外的组合,锅就会在某轮空一个位,时间浪费了。轮次机制让用户必须清楚地知道“当前锅里是哪两张饼、它们各自还有哪面没熟”。
在实现上,我用currentRound记录当前轮数,每轮开始时把锅的isHeating设为false,等用户确认锅位状态后点“开始加热”,isHeating变为true,定时器启动。倒计时归零时自动调用翻面逻辑,并把currentRound加1。这样整个时间轴就非常清晰,代码的可读性也好。
2.3 最优时间计算与策略判断逻辑
应用里有一个“最优时间对照”功能,用户模拟完成后,系统会计算最优时间并显示对比。这个计算是非常简单的数学公式:
最优时间 = (饼数 === 1) ? 6 : 饼数 * 3公式背后是“总面数 / 每轮处理面数 × 每轮时间”的推导:n张饼有2n个面,锅每轮最多处理2个面,所以至少需要n轮,每轮3分钟,总计3n分钟。只有1张饼的情况下,每轮只能处理1个面,所以是2轮共6分钟。这个公式在代码里就是一行if判断,不需要复杂的算法。
但光有最优时间不够,用户实际操作时可能绕了弯路,用了比如10分钟、11分钟,他要知道自己“浪费”在哪里。所以我还做了策略提示功能:每次轮次结束后,检查锅里饼的状态,如果发现某个面已经熟了但还在锅里加热,就提示“这张饼的这面已经熟透,放在锅里浪费锅位”。这个检查逻辑遍历所有在锅中的饼,判断它们朝上的面是否已经是COOKED状态,如果是就给出提示。
function checkOvercook(pan: Pan): string { let tips: string[] = []; if (pan.slot1 && pan.slot1.frontStatus === SideStatus.COOKED) { tips.push(pan.slot1.label + ' 的上面已经熟了'); } if (pan.slot2 && pan.slot2.frontStatus === SideStatus.COOKED) { tips.push(pan.slot2.label + ' 的上面已经熟了'); } return tips.join(';'); }这个功能的价值在于把抽象的“贪心策略”具象化了。学生能直接看到“浪费的地方”,比老师说一百遍“要让锅不空着”都有效。
3. 实操过程与核心模块实现
3.1 页面整体布局:三区一底
整个应用只有一个主页面,但视觉上分成四个区域。顶部是参数设置区,中间是操作展示区(等待区、锅、完成区),底部是控制按钮和状态提示。
顶部参数区很简单,一个文本显示“饼的数量”,一个滑块(1到10),一个“生成饼”按钮。滑块的值改变时,会触发重新生成饼列表,并把所有状态重置。
中间操作展示区是核心。从功能上分为三个横向区域:左侧的等待区(所有还没入锅的饼排列在这里)、中间的大锅(两个锅位并排)、右侧的完成区(已经两面都烙完的饼)。三个区域用Stack叠加的方式实现拖拽交互,但为了兼容性和减小开发量,我用的是一个简化的方案:饼不做自由拖拽,而是通过点击选择“哪张饼放入哪个锅位”来实现。
为什么不做自由拖拽?因为ArkUI的拖拽事件在跨容器传递时,坐标换算和动画回退的逻辑相当繁琐,尤其在平板和手机两种不同分辨率的屏幕上,手势响应会有一堆边界问题。对教育类应用来说,点击选择的方式教学效率更高,学生也能更清晰地理解“我选这张饼放到1号锅位”这一步,而不会被拖拽的视觉反馈带偏。如果后续有时间,我可以再升级成真正的拖拽体验。
锅的展示区域由一个圆角矩形容器表示,两个大圆形槽位并排,饼放入后显示在槽位上。等待区和完成区用简单的网格布局排列饼卡片。
底部操作区包含几个按钮:开始加热、本轮结束(翻面并进入下一轮)、重置。状态提示区用滚动文本显示当前轮数、剩余时间和操作反馈。
3.2 核心交互流程:放饼、加热、翻面、出锅
这一段是整个应用最核心的交互逻辑,我拆成四个步骤细讲,每一步都对应代码里的一个方法。
放饼操作的流程是:用户在等待区点击一张饼,饼进入“选中”状态,高亮边框;然后点击锅位1或锅位2,如果该锅位为空,饼就移动到锅位。代码里的核心逻辑是修改饼的location属性,同时把锅的对应槽位指向这张饼。
// 等待区的饼被点击后,记录选中状态 @State selectedBread: Bread | null = null; function onBreadClicked(bread: Bread) { if (this.pan.isHeating) return; // 正在加热时禁止操作 this.selectedBread = bread; } // 点击锅位 function onPanSlotClicked(slot: number) { if (this.pan.isHeating) return; if (this.selectedBread == null) return; if (slot === 1 && this.pan.slot1 === null) { this.selectedBread.location = BreadLocation.PAN_SLOT_1; this.pan.slot1 = this.selectedBread; this.selectedBread = null; } else if (slot === 2 && this.pan.slot2 === null) { this.selectedBread.location = BreadLocation.PAN_SLOT_2; this.pan.slot2 = this.selectedBread; this.selectedBread = null; } }加热操作是必须满足“两个锅位都有饼”才能触发。点开始加热后,进入HEATING状态,启动定时器。在完整模式下每轮定时3分钟,但为了课堂教学演示,我加了一个“快速模式”开关,每轮压缩到10秒。两种模式下,倒计时显示和轮次推进逻辑完全一致,只是定时器的间隔不同。
翻面操作发生在计时归零时,自动执行。逻辑很简单:锅里每张饼的frontStatus和backStatus互换(因为翻面后,原本朝下的变成朝上)。但有一个细节需要注意:如果饼的正面已经熟了,背面还是生的,翻面后正面变成朝下,背面朝上,下一轮烙的应该是背面。如果正面熟了、背面也熟了,这张饼就不再需要入锅了。所以翻面后要做一次状态检查,判断饼是否完成。
出锅操作完全由用户手动控制,这是训练策略的关键环节。当一张饼已经两面全熟,用户把它从锅位移到完成区。如果用户没有及时把熟饼拿出来,下一轮开始放饼时就会提示“锅位被全熟的饼占用,请先取出”。
3.3 动画与反馈:让每一次操作都有视觉回应
教育应用最忌讳界面“僵硬”——学生操作后没有任何反应,会很快失去兴趣。我在关键操作上都加了动画和声音反馈(声音用震动代替,避免课堂环境噪音)。
饼从等待区移动到锅位,使用的是位移动画。由于ArkUI的animateTo可以隐式监听状态变化,我只需要在状态变更后调用animateTo包装,UI就会自动平滑过渡。具体实现如下:
// 使用animateTo实现位移动画 animateTo({ duration: 300, curve: Curve.EaseInOut }, () => { bread.location = BreadLocation.PAN_SLOT_1; this.pan.slot1 = bread; });饼入锅时有一个“压扁”的效果:宽度稍微变大,高度稍微变小,模拟饼被放进锅里的形变。这个效果用scale属性配合动画曲线实现,视觉上很生动。
翻面动画是重点。我用了一个3D旋转效果,饼沿中心Y轴旋转180度,旋转到90度时切换正反面的颜色显示,动画完成后饼就像真的被翻了个面。这个效果的关键是分两次变换:第一次从0度转到90度,在转完一半时切换到背面图案,第二次从90度转到180度。用ArkTS的Animation属性可以精确控制这两段。
倒计时显示也是动态反馈的重要部分。每轮的剩余时间用数字显示,同时用一个环形进度条表现时间流逝。环形进度条用的是Canvas绘制,根据剩余时间占比动态更新。每秒刷新一次,视觉连贯性够用了。
3.4 定时器与生命周期管理:不起眼的难点
定时器是这类应用里最容易出bug的地方,尤其是页面退出或应用切后台时,定时器还在跑,导致状态错乱。
完整模式下每轮3分钟,需要180秒倒计时,我用setInterval每秒递减剩余秒数。但setInterval在ArkTS中有一个必须注意的问题:页面进入后台(比如用户按Home键返回桌面),定时器可能被系统冻结或延迟执行,回到页面时倒计时和真实时间会产生较大偏差。用系统时间戳计算剩余时间可以解决这个问题——记录轮次开始的时间戳,每次刷新时用当前时间减去开始时间来计算剩余时间,不受定时器冻结影响。
代码实现:
private roundStartTime: number = 0; function startHeating() { this.roundStartTime = Date.now(); this.timerId = setInterval(() => { let elapsed = Date.now() - this.roundStartTime; let remaining = this.roundDuration - Math.floor(elapsed / 1000); if (remaining <= 0) { this.finishRound(); } else { this.roundRemaining = remaining; } }, 500); // 每500毫秒刷新一次,倒计时精度控制在0.5秒内 }同时,必须在页面的生命周期回调中清理定时器。在aboutToDisappear中clearInterval,防止页面销毁后定时器仍然执行导致的内存泄漏和状态错乱。这是初学者很容易忽略的坑,我在4.2节详细展开。
3.5 快速模式与教学场景适配
为了课堂教学演示,我设计了一个“快速模式”的切换开关。完整模式下每轮180秒,教学演示根本等不起;快速模式下每轮10秒,40秒就能完成3张饼的完整模拟。快速模式不仅改变了定时器间隔,还自动跳过了动画的等待时间——动画时长缩短到150毫秒,保证整体节奏紧凑。
这个开关的实现非常简单,就是一个布尔状态。所有涉及时间戳计算和定时器间隔的地方都通过这个状态来取不同的值:
const NORMAL_ROUND_SECONDS = 180; const QUICK_ROUND_SECONDS = 10; let roundDuration = this.isQuickMode ? QUICK_ROUND_SECONDS : NORMAL_ROUND_SECONDS;快速模式对学习理解并没有负面效果,因为核心的轮次逻辑和操作决策没有变,变的是时间尺度。学生可以在两分钟之内反复尝试多种策略,对比哪个方案用时最短,这种“快速试错”的体验对建立优化思维非常有帮助。
4. 常见问题与排查技巧实录
4.1 锅位状态与实际UI不同步
典型表现:饼明明显示在等待区,但点锅位时提示锅位已满;或者饼显示在锅里,但翻面后UI没有变化。
排查后定位出的根因是——饼对象的location属性和锅的slot引用没有同时更新。我用的是两套数据:饼自身记录“我在哪儿”,锅的槽位记录“我放着谁”。这两套数据只要有一次更新忘记同步,就会出现状态分裂。后来统一改成“以锅的slot引用为准,饼的location只是展示辅助”,所有判断都优先读pan.slot1和pan.slot2,饼的location只在动画搬运时更新。这样锅位状态就永远不会和饼的位置冲突。
这类问题在ArkTS的@Observed嵌套对象中尤其容易踩。@Observed装饰的类如果内部属性被修改,依赖它的组件会刷新,但前提是修改的属性本身也必须是可观察的。如果在一个@Observed类里嵌套了普通类的实例,内层实例的属性变化不会触发外层观察者。我的解决方案就是避免深嵌套,所有状态都拍平到顶层。
4.2 定时器残留导致页面假死
我在开发过程中遇到过一次严重问题:从完整模式切换到快速模式时,上一个定时器没有清理,新的定时器又启动了,两个定时器同时递减倒计时,倒计时数字跳来跳去,最后逻辑直接崩了。
这个问题的根因是切换模式时只修改了roundDuration,没有先clearInterval再重新setInterval。因为定时器的interval回调里读取roundDuration,但旧定时器每秒触发一次,新定时器也每秒触发一次,两套递减逻辑叠加,剩余时间的值就变得不可预测。
修复方法很简单:任何模式切换、重置、轮次结束时,第一优先级的操作是clearInterval,然后再评估是否需要启动新的定时器。
function resetAll() { if (this.timerId !== -1) { clearInterval(this.timerId); this.timerId = -1; } // 重置所有状态 this.currentRound = 0; this.roundRemaining = this.roundDuration; // 待实现:清空锅位、重置饼状态 }在aboutToDisappear中清理也是同样的逻辑。养成“定时器使用前必查清理路径”的习惯,能避免一大类偶现崩溃。
4.3 跨设备布局错位
开发完成后,我在Phone和Foldable两种设备上测试,发现锅的布局在折叠屏上会被拉伸变形,等待区的饼卡片在平板上间距过大,在手机上又挤成一团。
ArkUI的布局系统支持百分比和自适应,但单纯的百分比在超大屏上会使元素显得空旷,在小平板上又显得拥挤。我的解决方案是使用GridRow + GridCol栅格布局替代固定宽度的Column/Row。锅具区域设置为栅格列跨度6(中间),左右等待区和完成区各跨度3,在不同屏幕宽度下自动调整列的比例。
为了更精细地适配,我还引入了断点媒体查询:在宽度小于600vp时,三个区域改为纵向排列,锅在上、等待区和完成区在下,方便小屏用户操作。这个适配思路与Web开发中的响应式设计完全一致,ArkUI提供了MediaQuery能力,可以根据设备宽度动态调整布局。
4.4 学生误操作与教学引导
教育应用面对的用户是儿童,误操作非常多。最常见的一种情况是:学生还没确认锅位拼好,就点了“开始加热”,锅空着一个位子就开始计时,导致这一轮浪费了锅位,总时间变长。这虽然在教学上有“反面案例”价值,但如果提示不够明确,学生会以为是自己操作成功了,反而学不到正确方法。
我的处理是在按钮启用逻辑上做限制:只当锅位全部有饼时,才允许点击“开始加热”。如果没放满,按钮置灰并显示提示“两个锅位都放上饼,才能开始加热”。这种“硬约束”比弹窗提示更直观,学生根本不会获得进入错误流程的机会。
还有一种误操作是:正在加热时学生点翻面或者出锅,导致状态错乱。我在所有操作入口都加了isHeating判断,加热期间除了“停止加热”按钮,其他所有按钮全部禁用。这样学生即便乱点,也不会打扰到正在进行的轮次,操作引导更稳健。
4.5 性能优化:动画和定时器的协同
在低端设备上测试时,发现连续操作动画会掉帧,尤其是翻面动画和倒计时刷新同时进行时,界面会有卡顿。这里的性能瓶颈在于动画帧刷新和定时器刷新同时在UI线程上竞争资源,加上Canvas环形进度条每次都需要重绘,负载不小。
优化方案有两个方向。一是降低不必要的重绘频率:环形进度条从每秒刷新30次降到每秒刷新10次,视觉上几乎无差异,但CPU占用明显下降。二是将动画时长与定时器周期错开:定时器只在整秒边界刷新UI,动画只在操作发生时触发,避免二者在同一帧内同时更新。
对这类轻量应用,优化核心不是减少计算量,而是减少UI刷新次数。控制好刷新频率,用户体验就能从“卡顿”提升到“流畅”的及格线。
5. 教育意义与后续扩展思考
5.1 不只是烙饼:启发式教学的应用范式
这个模拟器的价值不仅在于解决烙饼问题本身,更在于展示了一种“抽象问题具象化”的通用方法。数学广角里的很多问题——沏茶问题、田忌赛马、排队论——本质上都是调度优化问题,都可以用“操作模拟器”的方式呈现。学生通过动手操作建立的直观认知,远比背下公式要牢固。
我在这套代码框架里预留了策略替换的接口。如果把饼的模型换成任务列表、锅换成机器、饼面换成工序步骤,就变成了一个简易的“流水线调度模拟器”。换汤不换药的改造,可以覆盖更多的教学场景。
5.2 可扩展的方向
第一个扩展方向是自动演示模式。当前版本需要用户自己操作,后续可以增加“最优策略自动演示”,程序按照贪心算法的步骤自动模拟一遍最优过程,学生先看自动演示再自己操作,形成“观察 - 模仿 - 练习”的完整学习链路。
第二个扩展方向是数据统计与分析。记录学生每次操作的轮次、放入的饼、翻转的时机,生成操作日志,老师可以看到学生在哪一步最容易出错、是不是理解了最优策略、有没有反复尝试,这些数据对个性化教学非常有用。
第三个扩展方向是多人竞速。两个学生可以同时操作各自的锅,比赛谁用最短时间完成所有饼。在HarmonyOS的分布式能力下,甚至可以让两台设备互联对战,这能极大地激发学生的参与热情。
从我个人开发经验来说,这类教育应用的代码量并不大,真正花精力的是把数学逻辑拆成清晰的状态变换,再用简单的交互把变换过程可视化。烙饼模拟器只是起点,顺着这个思路,数学广角的整个单元都可以做出一套完整的交互式教具。如果你也在折腾HarmonyOS上的教育类应用,希望这篇记录能帮你少踩几个坑,也期待看到你的作品。