实验目录页、实验模拟页和场景选择页单独看都不复杂:目录页展示卡片,模拟页根据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 | 页面路径 | 可路由页面集合 |
从目录进入实验的代码已经具备明确参数:
router.pushUrl({ url: 'views/experiment/ExperimentSimPage', params: { expId: exp.id, expName: exp.name } })模拟页也会在aboutToAppear()中读取这两个字段,再调用initEngine()。这条主链是可工作的。场景选择应该挂在这条链路上,而不是另起一套互不认识的状态。
二、实验目录必须是唯一事实源
getAllExperiments()已经为每个实验定义了 ID、名称、类别、难度、图标和参数。目录页、收藏页、搜索页或推荐区都应该查询这份数据,不要各自维护名称到路由的映射。
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 时可以在进入引擎分支前完成校验,而不是静默落到酸碱中和实验。
建议再补一个只读目录门面,让调用方不必每次重新扫描数组:
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当前的默认值是:
@State selectedId: string = 'basic_lab'但getDefaultScenes()中真实存在的 ID 是:
standard_lab heating_lab low_temp_lab high_pressure_lab custom因此首次打开页面时,没有任何卡片满足this.selectedId === scene.id。用户会看到“可选择”的列表,却看不到默认选择,确认逻辑以后即使补上,也可能回传一个不存在的 ID。
最小修复不是把两个字符串碰巧改成一样,而是从数据源产生默认值:
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] }页面初始化时引用同一常量:
@State scenes: Scene[] = getDefaultScenes() @State selectedId: string = getDefaultScene().id这样即使以后调整场景顺序,默认状态仍由模型层明确表达。启动检查也可以断言默认 ID 必须存在,避免问题等到用户点击时才暴露。
四、把一次实验运行定义成强类型上下文
仅传expId可以启动引擎,却无法描述用户选择的环境。一个稳定的运行上下文至少要包含实验身份、场景身份和环境快照。
export interface EnvironmentSnapshot { temperature: number pressure: number } export interface ExperimentRunContext { runId: string expId: string sceneId: string environment: EnvironmentSnapshot updatedAt: number }这里保存的是数值快照,而不是只保存sceneId。如果后续版本把“加热实验室”从 100℃ 调整为 120℃,已经开始的实验不应该在返回页面后悄悄改变。runId则用于区分同一个实验的两次运行,防止旧的场景选择覆盖新会话。
上下文创建函数负责做第一层校验:
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()断言成接口类型。类型断言只影响编译器,不会让运行时输入自动变得可信。收藏入口、历史记录、深链或旧版本页面都可能只带部分字段。
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 指向电解水、标题却显示酸碱中和的错配。默认回退也只发生一次,后续引擎、收藏状态和记录都使用归一化结果。
六、场景页只编辑环境,不拥有实验生命周期
场景选择页面不应该启动引擎、保存实验记录或修改学习时长。它的责任只有三件事:展示可选场景、编辑自定义环境、把确认结果交回本次运行。
可以用一个会话级存储连接两个页面:
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,因此重点是参数完整性。
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 和重复点击。
@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() }按钮状态与处理状态绑定:
Button(this.isSaving ? '正在应用' : '确认选择') .enabled(!this.isSaving) .onClick(() => this.confirmSelection())写回成功后使用系统返回,而不是再pushUrl一个新的模拟页。否则页面栈会变成“模拟页 A → 场景页 → 模拟页 B”,用户按返回键又会回到旧运行,参数看起来像被撤销。
九、取消与系统返回不能污染原值
用户点击顶部返回或系统返回时,应保留进入场景页之前的环境。实现方式是把页面里的编辑值当作草稿,只在确认时写回RunContextStore。
@State selectedId: string = DEFAULT_SCENE_ID @State customTemperature: number = 25 @State customPressure: number = 101.325 private cancel(): void { router.back() }不要在卡片.onClick()中直接修改共享上下文。卡片点击只改selectedId,这样“试着点了高压场景又返回”不会影响正在暂停的实验。
十、自定义环境必须有可编辑边界
现有页面对自定义场景只显示了一个齿轮符号,但没有温度和气压控件。要让这个入口可用,至少要给数值范围、步长和错误反馈。
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()中读取最新上下文,并在环境变化时重置实验。
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写入反应物参数。温度和气压可以作为公共参数先写入,再由具体引擎选择是否使用。
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,思路是正确的。需要保证分类索引越界时仍能回到“全部”。
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):
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相同的运行创建函数。
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 都能创建引擎,再抽取受环境影响最明显的实验做场景组合测试。
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 个引擎风险很高,可以按下面的顺序收敛:
- 修正默认场景 ID,并给确认按钮补齐回传闭环。
- 新建
ExperimentRunContext与RunContextStore,让模拟页拥有会话。 - 把目录、收藏和其他入口统一到
ExperimentNavigator.open()。 - 在
onPageShow()恢复场景,确认后重置未运行实验。 - 给自定义环境增加范围校验和错误状态。
- 把温度、气压作为公共参数传入引擎适配层。
- 先为气体生成、氧气制取、干冰实验验证环境影响,再扩展其他实验。
- 通过路径矩阵检查确认、取消、系统返回、分类和收藏入口。
每一步都能独立验证,也不会要求所有引擎在同一次提交里改变行为。
十九、源码事实与建议实现的边界
场景选择器最容易产生的误解,是把 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 源码为依据,并由作者复核后发布。