【化学实验室|04】HarmonyOS ArkTS 场景选择器实战:让 13 个离线实验的入口、筛选和返回路径保持一致

实验目录页、实验模拟页和场景选择页单独看都不复杂:目录页展示卡片,模拟页根据 expId 创建引擎,场景页让用户在标准、加热、低温、高压和自定义环境中选择一个选项。真正容易出问题的是三页之间的契约。

在 化学实验室 1.0.5 的实际 ArkTS 源码中,Experiment.ets 已经提供 13 个实验,Scene.ets 提供 5 个环境,LabPage.ets 也能把实验 ID 传给模拟页。但继续检查 SceneSelectorPage.ets 会发现三个断点:默认值 basic_lab 不存在于场景数据中;"确认选择"按钮没有点击处理;模拟页的路由参数也没有 sceneId。结果就是场景卡片可以高亮,选择却不会影响任何一次实验。

本文唯一复核标记:CHEM-LAB-ONE12-SCENE-CONTRACT-20260729

这类问题不能靠给按钮补一行跳转彻底解决。更稳的目标是让 场景选择不是一个孤立页面,而是实验运行上下文的一部分:目录负责选择实验,模拟页拥有本次运行,场景页只编辑环境,返回后仍能确认这是同一个实验、同一个场景和同一组环境参数。

本文会完成以下工程拆解:

  • 核对 13 个实验与 5 个场景的真实数据源,避免 UI 写出第二份目录。
  • 设计默认场景 ID、确认回传、自定义环境和系统返回四类状态断点的修复方案。
  • 用强类型 ExperimentRunContext 连接目录、模拟页、场景页与引擎。
  • 给实验卡片、分类筛选、收藏入口和场景返回建立同一套参数校验。
  • 提供可在真机或模拟器上执行的路径矩阵与排障表,不把未执行的测试写成已通过结果。

一、先把真实页面链路画清楚

当前工程中,与实验入口有关的文件并不只是一张列表页。

责任 真实文件 当前输入 当前输出
实验目录 model/Experiment.ets 无 13 个 Experiment
分类与入口 pages/LabPage.ets 分类索引、收藏 ID expId、expName
实验运行 ExperimentSimPage.ets expId、expName 结果载荷
场景目录 model/Scene.ets 无 5 个 Scene
场景选择 SceneSelectorPage.ets 当前没有路由参数 当前没有确认结果
收藏入口 FavoritesPage.ets 收藏项 ID 补齐后的实验路由参数
页面注册 main_pages.json 页面路径 可路由页面集合

从目录进入实验的代码已经具备明确参数:

ts 复制代码
router.pushUrl({
  url: 'views/experiment/ExperimentSimPage',
  params: { expId: exp.id, expName: exp.name }
})

模拟页也会在 aboutToAppear() 中读取这两个字段,再调用 initEngine()。这条主链是可工作的。场景选择应该挂在这条链路上,而不是另起一套互不认识的状态。

二、实验目录必须是唯一事实源

getAllExperiments() 已经为每个实验定义了 ID、名称、类别、难度、图标和参数。目录页、收藏页、搜索页或推荐区都应该查询这份数据,不要各自维护名称到路由的映射。

ts 复制代码
export interface Experiment {
  id: string
  name: string
  description: string
  category: string
  level: string
  icon: Resource
  isFavorite: boolean
  params: ExperimentParam[]
}

export function getExperimentById(id: string): Experiment | undefined {
  return getAllExperiments().find((item: Experiment) => item.id === id)
}

这层模型有两个直接价值。第一,13 个实验的入口身份都由 id 决定,不依赖页面标题;第二,模拟页收到未知 ID 时可以在进入引擎分支前完成校验,而不是静默落到酸碱中和实验。

建议再补一个只读目录门面,让调用方不必每次重新扫描数组:

ts 复制代码
export class ExperimentCatalog {
  private static readonly items: Experiment[] = getAllExperiments()

  static all(): Experiment[] {
    return this.items.slice()
  }

  static find(expId: string): Experiment | undefined {
    return this.items.find((item: Experiment) => item.id === expId)
  }

  static contains(expId: string): boolean {
    return this.find(expId) !== undefined
  }
}

slice() 防止页面直接修改目录数组;contains() 则把路由参数校验放回目录层。它不是为了增加抽象数量,而是为了阻止 13 份入口规则在不同页面逐渐分叉。

三、先修复默认场景 ID 的确定性错误

SceneSelectorPage 当前的默认值是:

ts 复制代码
@State selectedId: string = 'basic_lab'

但 getDefaultScenes() 中真实存在的 ID 是:

text 复制代码
standard_lab
heating_lab
low_temp_lab
high_pressure_lab
custom

因此首次打开页面时,没有任何卡片满足 this.selectedId === scene.id。用户会看到"可选择"的列表,却看不到默认选择,确认逻辑以后即使补上,也可能回传一个不存在的 ID。

最小修复不是把两个字符串碰巧改成一样,而是从数据源产生默认值:

ts 复制代码
const DEFAULT_SCENE_ID: string = 'standard_lab'

export function getDefaultScene(): Scene {
  const scenes = getDefaultScenes()
  const standard = scenes.find((scene: Scene) => scene.id === DEFAULT_SCENE_ID)
  return standard ?? scenes[0]
}

页面初始化时引用同一常量:

ts 复制代码
@State scenes: Scene[] = getDefaultScenes()
@State selectedId: string = getDefaultScene().id

这样即使以后调整场景顺序,默认状态仍由模型层明确表达。启动检查也可以断言默认 ID 必须存在,避免问题等到用户点击时才暴露。

四、把一次实验运行定义成强类型上下文

仅传 expId 可以启动引擎,却无法描述用户选择的环境。一个稳定的运行上下文至少要包含实验身份、场景身份和环境快照。

ts 复制代码
export interface EnvironmentSnapshot {
  temperature: number
  pressure: number
}

export interface ExperimentRunContext {
  runId: string
  expId: string
  sceneId: string
  environment: EnvironmentSnapshot
  updatedAt: number
}

这里保存的是数值快照,而不是只保存 sceneId。如果后续版本把"加热实验室"从 100℃ 调整为 120℃,已经开始的实验不应该在返回页面后悄悄改变。runId 则用于区分同一个实验的两次运行,防止旧的场景选择覆盖新会话。

上下文创建函数负责做第一层校验:

ts 复制代码
export function createRunContext(expId: string, scene: Scene): ExperimentRunContext {
  const now = Date.now()
  return {
    runId: `${expId}_${now}`,
    expId,
    sceneId: scene.id,
    environment: {
      temperature: scene.temperature,
      pressure: scene.pressure
    },
    updatedAt: now
  }
}

页面不再临时拼装温度和气压,后续的报告、记录和分享也能引用同一份运行事实。

五、路由参数进入页面前要先归一化

模拟页目前直接把 router.getParams() 断言成接口类型。类型断言只影响编译器,不会让运行时输入自动变得可信。收藏入口、历史记录、深链或旧版本页面都可能只带部分字段。

ts 复制代码
interface SimRouterParams {
  expId?: string
  expName?: string
  runId?: string
}

interface NormalizedSimParams {
  expId: string
  expName: string
  runId: string
}

function normalizeSimParams(value: SimRouterParams | undefined): NormalizedSimParams {
  const requestedId = value?.expId ?? ''
  const experiment = ExperimentCatalog.find(requestedId)
    ?? ExperimentCatalog.find('acid_base')!
  return {
    expId: experiment.id,
    expName: experiment.name,
    runId: value?.runId ?? `${experiment.id}_${Date.now()}`
  }
}

归一化后,expId 与 expName 来自同一个 Experiment,不会出现 ID 指向电解水、标题却显示酸碱中和的错配。默认回退也只发生一次,后续引擎、收藏状态和记录都使用归一化结果。

六、场景页只编辑环境,不拥有实验生命周期

场景选择页面不应该启动引擎、保存实验记录或修改学习时长。它的责任只有三件事:展示可选场景、编辑自定义环境、把确认结果交回本次运行。

可以用一个会话级存储连接两个页面:

ts 复制代码
export class RunContextStore {
  private static contexts: Map<string, ExperimentRunContext> = new Map()

  static put(context: ExperimentRunContext): void {
    this.contexts.set(context.runId, context)
  }

  static get(runId: string): ExperimentRunContext | undefined {
    return this.contexts.get(runId)
  }

  static remove(runId: string): void {
    this.contexts.delete(runId)
  }
}

这份存储只覆盖当前进程的页面会话,不写入 Preferences。场景选择是一次运行参数,不等同于长期用户设置。如果产品确实要记住"上次使用的场景",应另建偏好字段,并明确它只作为下次运行的默认值。

七、从模拟页打开场景选择器

模拟页拥有 runId,打开场景页时只需要传递会话身份和当前场景。页面注册已经包含 views/experiment/SceneSelectorPage,因此重点是参数完整性。

ts 复制代码
private openSceneSelector(): void {
  const context = RunContextStore.get(this.runId)
  if (!context) return
  router.pushUrl({
    url: 'views/experiment/SceneSelectorPage',
    params: {
      runId: context.runId,
      expId: context.expId,
      selectedSceneId: context.sceneId
    }
  })
}

场景页收到参数后,先检查 runId 对应的上下文是否存在,再检查 selectedSceneId 是否在场景目录中。任何一个条件不满足,都回退到 standard_lab,但不要生成第二个实验会话。

八、确认按钮要完成"校验、写回、返回"闭环

当前 Button('确认选择') 没有 .onClick(),这是选择无法生效的直接原因。完整处理需要同时防止无效 ID 和重复点击。

ts 复制代码
@State isSaving: boolean = false

private confirmSelection(): void {
  if (this.isSaving) return
  const scene = this.scenes.find((item: Scene) => item.id === this.selectedId)
  const current = RunContextStore.get(this.runId)
  if (!scene || !current) return

  this.isSaving = true
  RunContextStore.put({
    ...current,
    sceneId: scene.id,
    environment: {
      temperature: scene.temperature,
      pressure: scene.pressure
    },
    updatedAt: Date.now()
  })
  router.back()
}

按钮状态与处理状态绑定:

ts 复制代码
Button(this.isSaving ? '正在应用' : '确认选择')
  .enabled(!this.isSaving)
  .onClick(() => this.confirmSelection())

写回成功后使用系统返回,而不是再 pushUrl 一个新的模拟页。否则页面栈会变成"模拟页 A → 场景页 → 模拟页 B",用户按返回键又会回到旧运行,参数看起来像被撤销。

九、取消与系统返回不能污染原值

用户点击顶部返回或系统返回时,应保留进入场景页之前的环境。实现方式是把页面里的编辑值当作草稿,只在确认时写回 RunContextStore。

ts 复制代码
@State selectedId: string = DEFAULT_SCENE_ID
@State customTemperature: number = 25
@State customPressure: number = 101.325

private cancel(): void {
  router.back()
}

不要在卡片 .onClick() 中直接修改共享上下文。卡片点击只改 selectedId,这样"试着点了高压场景又返回"不会影响正在暂停的实验。

十、自定义环境必须有可编辑边界

现有页面对自定义场景只显示了一个齿轮符号,但没有温度和气压控件。要让这个入口可用,至少要给数值范围、步长和错误反馈。

ts 复制代码
const MIN_TEMPERATURE: number = -50
const MAX_TEMPERATURE: number = 500
const MIN_PRESSURE: number = 20
const MAX_PRESSURE: number = 1000

private validCustomEnvironment(): boolean {
  return Number.isFinite(this.customTemperature)
    && Number.isFinite(this.customPressure)
    && this.customTemperature >= MIN_TEMPERATURE
    && this.customTemperature <= MAX_TEMPERATURE
    && this.customPressure >= MIN_PRESSURE
    && this.customPressure <= MAX_PRESSURE
}

UI 可以在选中 custom 后展开两个 Slider 或数字输入组件。确认前再做一次有限数和范围校验,避免 NaN 或极端值进入引擎。边界值应来自业务模型,不要散落在组件链中。

字段 建议范围 无效时处理
温度 -50℃ ~ 500℃ 禁用确认并显示范围提示
气压 20kPa ~ 1000kPa 保留输入,不写回上下文
场景 ID 场景目录内 回退标准实验室
会话 ID 当前存储中存在 返回模拟页并保留原环境

十一、返回模拟页后在 onPageShow 恢复环境

aboutToAppear() 适合创建初始运行;从场景页返回时,页面实例可能只触发可见性回调。模拟页应在 onPageShow() 中读取最新上下文,并在环境变化时重置实验。

ts 复制代码
onPageShow(): void {
  const context = RunContextStore.get(this.runId)
  if (!context) return
  const changed = this.sceneId !== context.sceneId
    || this.temperature !== context.environment.temperature
    || this.pressure !== context.environment.pressure

  this.sceneId = context.sceneId
  this.temperature = context.environment.temperature
  this.pressure = context.environment.pressure
  if (changed && !this.isRunning) {
    this.resetExperiment()
  }
}

运行中是否允许切换场景,需要产品明确决定。对实验模拟应用,更可预测的规则是:运行中禁用场景入口;暂停或完成后允许更换,更换后重置进度和结果,避免一条曲线混入两套环境。

十二、环境参数要通过适配层进入 13 个引擎

当前 buildEngineParams() 根据 expId 写入反应物参数。温度和气压可以作为公共参数先写入,再由具体引擎选择是否使用。

ts 复制代码
private buildEngineParams(params: Map<string, number>): void {
  params.set('environment.temperature', this.temperature)
  params.set('environment.pressure', this.pressure)

  switch (this.expId) {
    case 'gas_generation':
      params.set('cacMass', this.paramValues[0] ?? 5)
      params.set('acidConc', this.paramValues[1] ?? 1)
      break
    case 'oxygen_prep':
      params.set('kmnMass', this.paramValues[0] ?? 5)
      params.set('heatTemp', this.paramValues[1] ?? 300)
      break
    default:
      this.appendExperimentSpecificParams(params)
  }
}

不要为了"接入场景"而让所有引擎立刻改变计算公式。第一阶段先完成参数可达性和记录可追溯性;第二阶段再为确实受环境影响的气体、加热、干冰等实验增加修正模型。无关实验可以保留环境字段但不参与计算,并在报告中说明。

十三、分类筛选只筛实验,不重建入口规则

LabPage.filteredExperiments() 当前根据 Constants.CATEGORIES 过滤 category,思路是正确的。需要保证分类索引越界时仍能回到"全部"。

ts 复制代码
private filteredExperiments(): Experiment[] {
  const category = Constants.CATEGORIES[this.selectedCategory]
    ?? Constants.CATEGORY_ALL
  if (category === Constants.CATEGORY_ALL) {
    return this.experiments
  }
  return this.experiments.filter((item: Experiment) => item.category === category)
}

筛选结果中的每张卡片仍调用同一个 openExperiment(exp):

ts 复制代码
private openExperiment(exp: Experiment): void {
  const defaultScene = getDefaultScene()
  const context = createRunContext(exp.id, defaultScene)
  RunContextStore.put(context)
  router.pushUrl({
    url: 'views/experiment/ExperimentSimPage',
    params: { expId: exp.id, expName: exp.name, runId: context.runId }
  })
}

这保证"全部""初中化学""高中化学""趣味实验"看到的只是不同集合,而不是不同跳转实现。

十四、收藏入口也必须进入同一条运行链

FavoritesPage.openFavorite() 已经根据收藏项 ID 查询实验,并补齐 expId、expName。这是正确方向。下一步不是推翻它,而是让它调用与 LabPage 相同的运行创建函数。

ts 复制代码
private openFavorite(item: FavoriteDisplayItem): void {
  if (item.type !== 'experiment') {
    this.openNonExperimentFavorite(item)
    return
  }
  const experiment = ExperimentCatalog.find(item.id)
  if (!experiment) return
  ExperimentNavigator.open(experiment)
}

ExperimentNavigator.open() 内部创建默认场景上下文并执行跳转。这样从目录、收藏、历史记录或搜索打开同一个实验时,默认场景、参数校验和运行 ID 规则完全一致。

十五、页面 UI 状态要与业务状态一致

场景选择器还需要补齐几个用户能感知的状态:

状态 页面表现 可执行动作
正常 一项明确选中 切换、确认、返回
自定义编辑 展开温度和气压 校验后确认
参数无效 字段提示错误 允许修改,禁用确认
会话丢失 显示"本次实验已结束" 返回实验目录
正在保存 按钮显示处理中 禁止重复确认
小窗口 列表内部滚动 底部按钮始终可达

现有根节点已经使用 statusBarHeight 和 bottomBarHeight。底部确认区还应把系统避让区和固定间距叠加考虑,尤其是在手势导航、横屏和小窗下,不能只依赖 .margin({ bottom: 24 })。

十六、验证矩阵:13 个实验不必手工重复 13 套代码

入口一致性适合做数据驱动检查。先验证目录中的每个 ID 都能创建引擎,再抽取受环境影响最明显的实验做场景组合测试。

ts 复制代码
const experiments = ExperimentCatalog.all()
for (const experiment of experiments) {
  const scene = getDefaultScene()
  const context = createRunContext(experiment.id, scene)
  const normalized = normalizeSimParams({
    expId: experiment.id,
    expName: experiment.name,
    runId: context.runId
  })
  console.info(`${normalized.expId}:${normalized.runId}`)
}

建议的人工验证表如下:

路径 操作 预期结果
目录 → 模拟页 打开 13 个实验 标题、引擎、参数定义与 ID 一致
模拟页 → 场景页 → 确认 选择高压环境 返回原模拟页,显示 500kPa
模拟页 → 场景页 → 返回 选择后不确认 原场景不变
自定义场景 输入边界内数值 保存快照并重置未运行实验
自定义场景 输入越界数值 明确提示,不能确认
收藏 → 模拟页 打开任意实验收藏 不回退到酸碱中和
分类切换 多次切换后打开卡片 仍使用统一导航函数
系统返回 连续返回 页面栈不出现重复模拟页

十七、常见故障与排查顺序

现象 优先检查 典型原因 修复方向
首次没有选中项 selectedId 与场景目录 默认 ID 不存在 从 getDefaultScene() 初始化
点确认无反应 按钮事件与会话 没有 .onClick() 或会话丢失 校验后写回并 router.back()
返回后还是旧场景 onPageShow() 只在 aboutToAppear() 读取 可见时恢复上下文
场景改变但曲线不变 引擎参数 Map 环境没有进入适配层 先传公共参数,再按实验消费
收藏总进默认实验 收藏跳转参数 只保存 route,没有 ID 查询目录后补齐 expId
返回键出现两个模拟页 页面栈 确认时再次 pushUrl 写回后返回原页面
自定义数值导致异常 输入校验 NaN、无限值或越界 有限数与范围双重检查

排查时先确认运行上下文中的 runId、expId、sceneId 是否一致,再看 UI 高亮,最后才进入具体引擎。这样能快速区分"选错实验""回传失败"和"公式暂未消费环境参数"三种问题。

十八、建议的落地顺序

一次性重写 13 个引擎风险很高,可以按下面的顺序收敛:

  1. 修正默认场景 ID,并给确认按钮补齐回传闭环。
  2. 新建 ExperimentRunContext 与 RunContextStore,让模拟页拥有会话。
  3. 把目录、收藏和其他入口统一到 ExperimentNavigator.open()。
  4. 在 onPageShow() 恢复场景,确认后重置未运行实验。
  5. 给自定义环境增加范围校验和错误状态。
  6. 把温度、气压作为公共参数传入引擎适配层。
  7. 先为气体生成、氧气制取、干冰实验验证环境影响,再扩展其他实验。
  8. 通过路径矩阵检查确认、取消、系统返回、分类和收藏入口。

每一步都能独立验证,也不会要求所有引擎在同一次提交里改变行为。

十九、源码事实与建议实现的边界

场景选择器最容易产生的误解,是把 UI 上"能选择"当成业务上"已经生效"。下面按真实源码和建议方案分开归档,所有当前事实都可以从文首列出的文件直接复查。

类别 当前源码可直接确认 本文建议但未声称已合入
实验目录 getAllExperiments() 返回 13 个离线实验 通过统一 Navigator 收口所有实验入口
场景目录 getDefaultScenes() 返回 5 个场景 为自定义场景增加输入表单、范围校验和草稿状态
默认选择 selectedId 初始化为不存在的 basic_lab 改成 standard_lab 或从合法运行上下文恢复
卡片交互 点击卡片只更新 selectedId 和高亮样式 将选择写入独立草稿,取消时不污染原值
确认按钮 当前按钮没有 onClick 校验后写回运行上下文并 router.back()
模拟页参数 当前只读取 expId、expName 增加 runId、sceneId 或窄化的场景快照
收藏入口 已根据收藏 ID 查询目录并补齐实验名称 与普通目录入口复用同一参数规范化函数
场景回传 当前源码没有从场景页向模拟页传递选择结果 使用页面栈恢复或窄化状态存储完成回传
引擎影响 当前场景温度、气压未进入 13 个实验引擎 通过适配层决定不同实验如何消费环境参数
路由注册 main_pages.json 已注册 SceneSelectorPage 注册并不等于页面已接入完整业务入口

本文没有声称这些建议已经通过工程构建、模拟器或真机验证。实施时应按第十八节逐步修改,并分别检查确认、取消、系统返回、收藏入口以及 13 个实验的兼容性。

总结

一个可靠的场景选择器,不是"列表中多一个勾选状态",而是一条完整的运行契约:实验目录提供唯一 expId,运行上下文绑定 runId 与环境快照,场景页只编辑草稿并在确认时写回,模拟页在恢复可见时读取新环境,引擎适配层决定哪些公式真正消费温度和气压。

把这条链路写清以后,13 个离线实验就不再依赖 13 套跳转细节。目录筛选、收藏入口、场景确认和系统返回都能回到同一个可预测会话,后续增加实验或环境时,也只需要扩展目录与适配器,而不必重新修补页面之间的隐式约定。

AI 辅助声明:本文由 AI 辅助完成源码梳理、结构校对与配图设计,技术结论均以文中列出的本地 ArkTS 源码为依据,并由作者复核后发布。

相关推荐
特立独行的猫a1 小时前
仓颉语言GUI开发实战:用仓颉写串口/网络二合一调试助手
网络·ui·gui·harmonyos·tauri·仓颉
hqzing11 小时前
把 glibc 移植到鸿蒙 PC 上:它能用来做什么
华为·harmonyos
OH_TPC17 小时前
HarmonyOS APP开发---“习惯猫“习惯打卡App,需要用到这个库
华为·harmonyos·鸿蒙
熊猫钓鱼>_>19 小时前
开源鸿蒙平台 KMP 三方库 kotlin-csv 适配全流程:从编码假设到沙箱真机互通验证
开发语言·人工智能·华为·kotlin·harmonyos·鸿蒙
MicrosoftReactor20 小时前
技术速递|当聊天框不再是合适的交互界面
ui·ai·copilot·canvas
马剑威(威哥爱编程)21 小时前
【共创稿事节】HarmonyOS 7 Agent 开发实战 09:Agent 测试评测集
华为·harmonyos
威哥爱编程1 天前
HarmonyOS 7 Agent 开发实战 04:云端 RAG 搭企业私有知识库问答
agent·harmonyos
威哥爱编程1 天前
HarmonyOS 7 Agent 开发实战 05:流式输出做打字机对话
agent·harmonyos
donk13141 天前
React Native for OpenHarmony 实战:三方库 react-native-circular-action-menu 的鸿蒙化适配指南
javascript·分布式·react native·react.js·wpf·harmonyos