在互动实验页面里,最难处理的通常不是画出一个烧杯,而是让“参数、引擎、动画、结果”始终说同一件事。

以 化学实验室 1.0.5 的实验模拟页为例,页面同时维护开始、暂停、完成、倍速、参数、进度、实时指标、Canvas 和结果页参数。如果这些状态各自变化,用户很容易遇到几类问题:暂停后被误判为完成、改变浓度后画面没有重置、结果页展示了上一轮数据,或者页面参数名与引擎参数名不一致,导致某个滑块看似可用,实际没有进入计算。

本文基于项目中的真实 ArkTS 文件拆解这条链路,并针对两个可复现问题给出可落地的改造方案。需要先说明:下列问题在本文核对的源码快照中仍然存在,文中的状态枚举、共享参数键和真实结果载荷属于建议实现,不能当作当前版本已经具备的能力。

  • 页面向酸碱引擎传入 baseConc,引擎却读取 baseVolume,第二个参数没有按预期生效。
  • currentResultTable() 为所有实验生成同一组 pH 数据,结果页可能显示与当前实验无关的表格。

最终目标不是增加更多布尔变量,而是建立一份可以检查的运行契约:页面只控制交互,引擎只推进过程,Canvas 只读取快照,结果页只接收真实载荷。

唯一复核标记:CHEM-LAB-ONE12-SIM-STATE-20260729

化学实验室实验模拟页状态机封面

一、项目环境与源码范围

这次分析对应以下工程环境:

项目当前值说明
应用名称化学实验室离线化学实验模拟与学习工具
应用版本1.0.5 / versionCode 1000005AppScope/app.json5 当前声明
HarmonyOS SDK6.0.2(22)target 与 compatible 版本一致
模型Stage ModelEntryAbility 作为主入口
设备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% 进度修改浓度,就必须定义“从头重算”还是“从当前时间继续”,否则同一条数据曲线会混合两组条件。

对于教学模拟,最容易解释的规则是:

  1. 运行中禁止修改。
  2. 暂停后若修改参数,自动回到 IDLE 并清空旧进度。
  3. 完成后修改参数,隐藏旧结果入口并建立新引擎快照。

这三条规则应该同时体现在按钮、滑块和结果页,而不是只在 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传入未知 expIdIDLE回退到默认实验且标题一致
S10将碱浓度从 0.5 调到 2.0IDLE引擎读取 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 实验页面就能从“看起来会动”变成“每一步都能解释和复查”。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐