news 2026/10/16 6:05:44

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
【化学实验室|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分类索引、收藏 IDexpId、expName
实验运行ExperimentSimPage.etsexpId、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 个引擎风险很高,可以按下面的顺序收敛:

  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 源码为依据,并由作者复核后发布。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 6:05:36

具身智能基础设施新突破:RPent开源加速大模型落地

GPT-6 Astra 这名字第一次出现在我订阅列表里的时候&#xff0c;我脑子里蹦出来的其实是另一个问题&#xff1a;大模型卷到第六代&#xff0c;参数和 token 能力已经不再是瓶颈了&#xff0c;那么让模型从“屏幕里”走进“物理世界”的那双脚&#xff0c;到底该由谁来造&#x…

作者头像 李华
网站建设 2026/10/16 6:05:15

两串锂电池主动均衡方案:MP2642GR-Z与PIC18F57K42实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 6:04:41

自建微信投票系统:从源码选型到防封部署的完整实践指南

做网络评选这行三年多&#xff0c;我经手过十几个投票类项目&#xff0c;从校园十佳评选到企业年度评优、从门店人气榜到社区才艺大赛。前两年每次有新需求&#xff0c;第一反应是租第三方投票平台&#xff0c;按人次付费&#xff0c;一个活动跑下来&#xff0c;账单经常让人肉…

作者头像 李华
网站建设 2026/10/16 6:02:41

PHP代码审计实战:老建站系统SQL注入与安全加固

简介&#xff1a;ZZCMS 8.3 是一套广泛使用的内容管理系统&#xff0c;其开源源码为代码审计与 PHP 架构学习提供了极佳的实战样本&#xff0c;既适合网站开发者、安全测试人员&#xff0c;也适合想深入理解 CMS 底层机制以及具备一定 PHP 基础、期望从实例中掌握安全最佳实践的…

作者头像 李华