在互动实验页面里,最难处理的通常不是画出一个烧杯,而是让“参数、引擎、动画、结果”始终说同一件事。
以化学实验室 1.0.5的实验模拟页为例,页面同时维护开始、暂停、完成、倍速、参数、进度、实时指标、Canvas 和结果页参数。如果这些状态各自变化,用户很容易遇到几类问题:暂停后被误判为完成、改变浓度后画面没有重置、结果页展示了上一轮数据,或者页面参数名与引擎参数名不一致,导致某个滑块看似可用,实际没有进入计算。
本文基于项目中的真实 ArkTS 文件拆解这条链路,并针对两个可复现问题给出可落地的改造方案。需要先说明:下列问题在本文核对的源码快照中仍然存在,文中的状态枚举、共享参数键和真实结果载荷属于建议实现,不能当作当前版本已经具备的能力。
- 页面向酸碱引擎传入
baseConc,引擎却读取baseVolume,第二个参数没有按预期生效。 currentResultTable()为所有实验生成同一组 pH 数据,结果页可能显示与当前实验无关的表格。
最终目标不是增加更多布尔变量,而是建立一份可以检查的运行契约:页面只控制交互,引擎只推进过程,Canvas 只读取快照,结果页只接收真实载荷。
唯一复核标记:CHEM-LAB-ONE12-SIM-STATE-20260729
一、项目环境与源码范围
这次分析对应以下工程环境:
| 项目 | 当前值 | 说明 |
|---|---|---|
| 应用名称 | 化学实验室 | 离线化学实验模拟与学习工具 |
| 应用版本 | 1.0.5 / versionCode 1000005 | AppScope/app.json5当前声明 |
| HarmonyOS SDK | 6.0.2(22) | target 与 compatible 版本一致 |
| 模型 | Stage Model | EntryAbility作为主入口 |
| 设备 | phone / tablet / 2in1 | 页面必须考虑不同窗口宽度 |
| 核心页面 | ExperimentSimPage.ets | 参数、控制栏、Canvas 和结果入口 |
| 引擎抽象 | PhysicsEngine.ets | 定义初始化、步进、展示值与表格数据 |
| 酸碱引擎 | FreeFallEngine.ets | 当前项目中实际承载AcidBaseEngine |
这里需要特别说明:引擎文件名保留了项目早期的物理实验命名,但导出的类已经是化学引擎。文章按真实类名分析,不建议在没有回归测试的情况下只为“看起来整齐”批量改文件名。
二、先确定页面的唯一运行流程
实验模拟页的主路径可以压缩为六步:读取路由、创建引擎、校验参数、开始步进、绘制反馈、生成结果。
项目当前在aboutToAppear()中读取expId与expName,随后执行初始化:
interface SimRouterParams { expId?: string expName?: string } aboutToAppear(): void { const params = router.getParams() as SimRouterParams | undefined if (params?.expId) { this.expId = params.expId } if (params?.expName) { this.title = params.expName } this.initEngine() this.resetExperiment() this.loadFavoriteState() }这段入口有两个值得保留的点。
第一,路由参数是窄接口SimRouterParams,页面没有把不确定对象直接传给引擎。第二,创建引擎和重置实验分开:initEngine()决定当前实验类型,resetExperiment()才根据当前参数初始化一次运行。这样改变参数时可以只重置运行,不需要重新进入页面。
更稳的补充是校验expId是否存在。否则错误参数会静默落到默认实验,用户看见的标题和引擎可能不一致:
private resolveExperimentId(rawId?: string): string { const fallback = 'acid_base' if (!rawId) return fallback const exists = getAllExperiments().some((item) => item.id === rawId) return exists ? rawId : fallback }校验函数属于页面入口边界,它只决定“可以打开哪个实验”,不参与化学计算。
三、用状态枚举替代互相组合的布尔值
当前页面同时保存:
@State isRunning: boolean = false @State isFinished: boolean = false @State progress: number = 0 private timerId: number = -1它能运行,但两个布尔值可以组成四种情况,其中isRunning=true && isFinished=true没有业务意义。随着“暂停后继续”“重做实验”“页面切后台”等路径增加,非法组合会越来越难排查。
建议把运行阶段收敛成枚举。下面代码用于说明改造后的状态契约,并非当前源码原样摘录:
enum ExperimentRunState { IDLE = 'idle', RUNNING = 'running', PAUSED = 'paused', FINISHED = 'finished' } @State runState: ExperimentRunState = ExperimentRunState.IDLE private canEditParams(): boolean { return this.runState !== ExperimentRunState.RUNNING } private canOpenResult(): boolean { return this.runState === ExperimentRunState.FINISHED }页面按钮不再猜测多个标志,而是根据一个阶段渲染。参数滑块使用canEditParams(),结果按钮使用canOpenResult()。这能直接消除“暂停后出现结果按钮”或“完成后仍显示暂停”的歧义。
| 当前阶段 | 主按钮 | 参数滑块 | 结果入口 | 允许的下一步 |
|---|---|---|---|---|
| IDLE | 开始 | 可调 | 隐藏 | 开始、重置 |
| RUNNING | 暂停 | 禁用 | 隐藏 | 暂停、完成 |
| PAUSED | 继续 | 可按产品规则决定 | 隐藏 | 继续、重置 |
| FINISHED | 再做一次 | 可调 | 显示 | 查看结果、重置 |
状态枚举并不是为了增加代码量,而是让每个控件都能回答“我为什么现在可用”。
四、参数定义必须与引擎键名共用同一份契约
项目用ExperimentParam描述滑块范围:
export interface ExperimentParam { name: string unit: string min: number max: number defaultValue: number step: number }页面再将数组位置转换为引擎参数:
case 'acid_base': params.set('acidConc', this.paramValues[0] ?? 0.5) params.set('baseConc', this.paramValues[1] ?? 0.5) break问题出现在引擎一侧:
init(params: Map<string, number>): void { this.reset() this.acidConc = params.get('acidConc') ?? 1 this.baseVolume = params.get('baseVolume') ?? 20 }页面写入baseConc,引擎读取baseVolume。因为读取失败后回退到默认值,页面不会崩溃,测试也很容易漏掉;用户拖动“碱浓度”滑块时,第二个参数实际上没有进入引擎。
修复方式不是在两处各改一遍字符串,而是共享参数键。下面代码展示建议改造:
export class AcidBaseParamKey { static readonly ACID_CONC: string = 'acidConc' static readonly BASE_CONC: string = 'baseConc' } // Page params.set(AcidBaseParamKey.ACID_CONC, this.paramValues[0] ?? 0.5) params.set(AcidBaseParamKey.BASE_CONC, this.paramValues[1] ?? 0.5) // Engine private acidConc: number = 0.5 private baseConc: number = 0.5 init(params: Map<string, number>): void { this.reset() this.acidConc = params.get(AcidBaseParamKey.ACID_CONC) ?? 0.5 this.baseConc = params.get(AcidBaseParamKey.BASE_CONC) ?? 0.5 this.reactionProgress = 0 this.addDataPoint() }共享常量解决的是“参数有没有送到”,而不是替项目宣称定量化学精度。当前酸碱引擎属于教学动画模型,文章不把它包装成实验室级计算工具;如果以后引入真实计算公式,应单独说明假设、单位和误差范围。
五、引擎只负责步进,页面只负责调度
项目中的ChemistryEngine把运行控制与具体实验拆开:
export abstract class ChemistryEngine { protected isRunning: boolean = false protected time: number = 0 protected dt: number = 0.01 protected speed: number = 1.0 protected dataPoints: SimDataPoint[] = [] abstract init(params: Map<string, number>): void abstract step(): boolean abstract getDisplayValues(): Map<string, string> abstract getConclusion(): string }这个边界是整个页面可维护的关键。页面不知道酸碱中和怎样算 pH,也不知道沉淀实验怎样累计沉淀量;它只调用统一的step(),然后读取展示快照。
酸碱引擎以固定dt推进,并按倍速增加每帧步数:
step(): boolean { if (!this.isRunning) return false const steps = Math.round(this.speed * 2) for (let i = 0; i < steps; i++) { this.time += this.dt this.reactionProgress = Math.min(1, this.time / 3) this.ph = 1 + (this.targetPh - 1) * this.reactionProgress if (this.reactionProgress >= 1) { this.ph = this.targetPh this.addDataPoint() this.isRunning = false return false } } return true }返回值表达“下一帧是否继续”,比页面读取多个内部字段更清楚。页面只需要在false时进入完成阶段并停止定时器。
六、16ms 定时器必须和页面生命周期一起关闭
页面以 16ms 周期调度引擎:
private startTimer(): void { this.stopTimer() this.timerId = setInterval(() => { if (!this.engine) return const running = this.engine.step() this.updateDisplay() this.progress = this.getProgress() this.drawCanvas() if (!running) { this.runState = ExperimentRunState.FINISHED this.stopTimer() this.persistLearningTime() DataStore.incrementExperimentCount() this.persistRecord() } }, 16) }调用前先stopTimer()很重要,它防止用户连续点击开始后生成多个定时器。离开页面时也必须清理:
aboutToDisappear(): void { this.stopTimer() this.persistLearningTime() } private stopTimer(): void { if (this.timerId !== -1) { clearInterval(this.timerId) this.timerId = -1 } }如果这里只暂停引擎但不清理定时器,回到上一页后回调仍会持续触发,带来功耗、重复写入和页面实例无法及时释放的问题。
倍速也应当只影响引擎步数,不能改变 UI 定时器数量。当前0.5x → 1.0x → 1.5x → 2.0x的循环方式符合这个边界。
七、参数滑块为什么在运行中禁用
滑块的实际处理代码做了三件事:更新值、复制数组触发 ArkUI 刷新、在非运行状态重置引擎。
.onChange((value: number) => { this.paramValues[index] = Math.round(value * 100) / 100 this.paramValues = [...this.paramValues] if (this.runState !== ExperimentRunState.RUNNING) { this.resetExperiment() } }) .enabled(this.runState !== ExperimentRunState.RUNNING)运行中禁用不是视觉限制,而是在保护一次实验的输入不变。如果允许用户在第 60% 进度修改浓度,就必须定义“从头重算”还是“从当前时间继续”,否则同一条数据曲线会混合两组条件。
对于教学模拟,最容易解释的规则是:
- 运行中禁止修改。
- 暂停后若修改参数,自动回到 IDLE 并清空旧进度。
- 完成后修改参数,隐藏旧结果入口并建立新引擎快照。
这三条规则应该同时体现在按钮、滑块和结果页,而不是只在 UI 上变灰。
八、Canvas 只读取快照,不修改业务状态
实验页的模块关系如下:
当前页面先从引擎读取progress、time和展示值,再调用 Canvas 绘制烧杯、气泡、沉淀或火焰。这个方向是正确的:绘制函数不应反过来修改引擎。
private updateDisplay(): void { if (!this.engine) return const values = this.engine.getDisplayValues() const next: string[][] = [] values.forEach((value: string, key: string) => { next.push([key, value]) }) this.displayValues = next }displayValues使用新数组赋值,能让 ArkUI 感知变化。Canvas 则读取同一帧的progress,使“进度条、数据指标、实验现象”来自同一个快照。
在平板和 PC/2in1 上,需要继续关注 Canvas 尺寸变化。可将onAreaChange获取的宽高保存下来,尺寸改变后重绘一次;不要把固定手机宽度写入器皿坐标。
九、结果页必须使用引擎真实数据
当前currentResultTable()无论运行哪个实验,都会生成时间、pH 和百分比三列:
private currentResultTable(): number[][] { const data: number[][] = [] const time = this.engine ? this.engine.getTime() : 3 const maxTime = Math.max(time, 3) for (let i = 0; i <= 6; i++) { const ratio = i / 6 const t = maxTime * ratio const ph = Math.max(0.5, 7 - ratio * 6.5) data.push([t, ph, ratio * 100]) } return data }这对酸碱实验尚可作为占位曲线,但金属与酸、沉淀、电解水、焰色反应都不应该展示同一组 pH 数据。引擎抽象已经提供getTableData(),页面应直接读取真实采样:
private currentResultTable(): number[][] { if (!this.engine) return [] return this.engine.getTableData().map((row: number[]) => { return row.map((value: number) => Number.isFinite(value) ? Number(value.toFixed(2)) : 0) }) }结果载荷可继续使用项目已有的ExperimentResultPayload强类型,并按需要扩展列定义:
export interface ExperimentResultPayload { expId: string title: string category: string paramSummary: string conclusion: string tableData: number[][] }如果不同实验的列含义不同,还应增加列定义,例如columns: ResultColumn[],由引擎同时返回“字段名、单位、精度”。结果页不应该根据expId再猜一次表格结构。
十、完成实验后再写记录与学习时长
当前页面只在引擎返回完成时增加实验次数并写入记录,这个时机比“点击开始就计数”更可信:
private persistRecord(): void { const record = buildRecord( this.expId, this.title, this.currentCategory(), this.currentParamSummary() ) DataStore.appendRecord<StoredExperimentRecord>(record) }学习时长通过learningStartedAt记录区间,暂停和离开页面时结算。需要注意两点:
- 每次持久化后更新起始时间,避免下一次重复累计同一段时间。
- 页面消失时一定停止计时;仅切换弹层不应被当成离开实验。
记录层只保存实验标识、标题、分类和参数摘要,不保存 Canvas 位图,能保持本地数据体积稳定。
十一、按状态转移做验证,而不是只看“能不能点”
这类页面适合用状态转移表回归。以下路径覆盖了主流程和容易遗漏的边界:
| 编号 | 操作 | 预期状态 | 关键断言 |
|---|---|---|---|
| S01 | 首次进入酸碱实验 | IDLE | 进度为 0,参数可调,结果入口隐藏 |
| S02 | 点击开始 | RUNNING | 定时器只有一个,参数禁用,Canvas 更新 |
| S03 | 运行中点击暂停 | PAUSED | 时间停止增长,结果入口仍隐藏 |
| S04 | 暂停后继续 | RUNNING | 从原进度继续,不新增第二个定时器 |
| S05 | 非运行时修改浓度 | IDLE | 引擎重置,进度和旧数据清空 |
| S06 | 运行到 100% | FINISHED | 定时器清理,记录只增加一次 |
| S07 | 点击查看结果 | FINISHED | 结果 expId、参数摘要和表格来自本次引擎 |
| S08 | 运行中返回上一页 | 页面销毁 | 定时器关闭,学习时长结算一次 |
| S09 | 传入未知 expId | IDLE | 回退到默认实验且标题一致 |
| S10 | 将碱浓度从 0.5 调到 2.0 | IDLE | 引擎读取baseConc,不是固定默认值 |
对参数契约可以再增加一条小型单元测试:初始化后暴露只读调试快照,断言两项参数都等于页面输入。这样键名拼错会在测试阶段直接失败,不会被默认值掩盖。
十二、常见问题与排查顺序
| 现象 | 优先检查 | 常见原因 | 修复方向 |
|---|---|---|---|
| 调整滑块后结果不变 | buildEngineParams()与init() | 参数键名不一致,读取后回退默认值 | 共享参数键常量并增加断言 |
| 暂停后出现完成结果 | step()返回值与页面状态 | 用false同时表达暂停和结束 | 页面主动区分 PAUSED 与 FINISHED |
| 返回后仍在耗电 | aboutToDisappear() | 定时器没有清理 | 生命周期统一调用stopTimer() |
| 结果表与实验不符 | currentResultTable() | 页面生成了通用占位数据 | 使用engine.getTableData() |
| 倍速越高动画越卡 | 每帧计算和 Canvas 重绘 | 一帧内步进过多、绘制过重 | 限制步数并减少无效重绘 |
| 平板上器皿错位 | Canvas 宽高与坐标 | 使用固定手机尺寸 | 根据真实区域计算相对坐标 |
| 完成次数重复增加 | 完成分支与重复回调 | 多个定时器同时运行 | 开始前先清理旧定时器 |
排查时先看数据契约,再看状态转移,最后看绘制。Canvas 画错有时只是上游进度或参数错误,直接调整坐标会掩盖根因。
十三、提交前检查清单
- [ ] 路由参数经过校验,未知实验不会造成标题与引擎错配。
- [ ] 运行阶段只有 IDLE、RUNNING、PAUSED、FINISHED 四种合法值。
- [ ] 开始、暂停、重置、完成和页面退出都能清理定时器。
- [ ] 页面与引擎共用参数键,调整每个滑块都能影响对应引擎值。
- [ ] 运行中参数不可修改,修改后旧进度和结果会清空。
- [ ] 指标、Canvas 与结果页读取同一引擎快照。
- [ ] 结果表来自
engine.getTableData(),不再套用通用 pH 表。 - [ ] 完成记录只写一次,学习时长不会重复累计。
- [ ] 手机、平板和 PC/2in1 改变宽度后,Canvas 与控制区仍可操作。
- [ ] 页面退出、快速连点和错误路由均有回归记录。
十四、源码事实与改造建议的边界
为了便于复核,最后把当前源码与本文建议分开列出。阅读时不要把“建议代码”误认为已经合入工程。
| 位置 | 当前源码事实 | 本文建议 | 验证方式 |
|---|---|---|---|
| 运行状态 | isRunning与isFinished两个布尔值 | 收敛为四态枚举 | 连续执行开始、暂停、完成、重置 |
| 酸碱参数 | 页面写baseConc,引擎读baseVolume | 共用AcidBaseParamKey | 修改第二个滑块并读取引擎快照 |
| 结果表 | 页面统一构造时间、pH、百分比 | 调用engine.getTableData() | 对比酸碱、沉淀、电解水三类结果列 |
| Canvas | 页面按实验类型分派绘制函数 | 继续保持只读快照边界 | 检查绘制函数不写引擎状态 |
| 生命周期 | aboutToDisappear()已停止定时器 | 保留并加入重复进入回归 | 反复进入退出,确认无重复回调 |
总结
实验模拟页的可信度不取决于动画有多花哨,而取决于同一组参数能否贯穿页面、引擎、Canvas 和结果页。
这次拆解得到三条可复用的方法:用单一状态枚举约束交互阶段,用共享参数键保证页面和引擎遵守同一契约,用引擎真实采样生成结果载荷。再配合定时器生命周期、强类型路由和可执行验证表,复杂的 ArkUI 实验页面就能从“看起来会动”变成“每一步都能解释和复查”。