
场景选择页很容易被写成一组“点亮后看起来选中了”的卡片但真正进入工程后至少有四件事必须同时成立列表数据能描述业务场景选中状态只有一个可信来源确认动作能把选择带到下游页面图标与辅助参数能让用户在进入模拟前理解差异。“天体运行模拟”的源码里已经有SceneSelectorPage.ets与Scene.ets但当前默认数据仍是“教学楼、月球表面、高塔、自定义场景”更接近自由落体教学模板页面底部的“确认选择”按钮也尚未绑定跳转或回传动作。与此同时真正的模拟页已经支持恒星、行星、卫星、小行星、黑洞以及稳定双体、三体扰动、双星、星系碰撞等天文实验。这正好提供了一个真实的工程问题如何保留现有 ArkUI 列表与选择交互把模型从通用重力场景升级为天文入口并且不把“视觉选中”误当成“业务已生效”。本文会先复核现有代码再给出可迁移的类型、路由和验证方案。所有增强代码都会明确标注避免把规划中的能力写成已经上线的事实。唯一复核标记SCENE-ONE13-SELECT-CONTRACT-20260726场景数据定义入口selectedId 定义当前选择确认动作必须显式传递场景标识。一、先看真实现状页面能选中但还没有完成业务闭环页面状态非常简洁State scenes: Scene[] getDefaultScenes() State selectedId: string building列表通过ForEach渲染点击某个卡片时更新selectedId.onClick(() { this.selectedId scene.id })选中项会改变背景、边框并显示对勾if (this.selectedId scene.id) { Text( ✓) .fontSize(16) .fontColor(AppColors.ACCENT_GREEN) }这些代码证明“本地视觉选择”已经存在。但底部按钮只有样式Button(确认选择) .fontSize(15) .fontColor(AppColors.TEXT_WHITE) .backgroundColor(AppColors.PRIMARY) .borderRadius(24) .height(48) .width(60%) .margin({ bottom: 24, top: 12 })它没有.onClick()没有router.pushUrl()也没有返回参数。因此当前源码不能被描述为“选中后已经切换天文模拟场景”。准确说法是页面实现了列表、单选视觉状态和确认按钮外观业务提交尚未接通。二、当前Scene模型混合了身份、展示、物理参数与状态真实模型如下export interface Scene { id: string name: string description: string height: number gravity: number icon: Resource isSelected: boolean isCustom: boolean }可以把字段分成四类类型字段当前职责身份id列表判断与未来路由参数展示name、description、icon卡片标题、说明和图片模拟参数height、gravity自由落体类场景参数UI 状态isSelected、isCustom默认选择与自定义标识问题不在字段多而在“谁是真正状态源”不够明确。页面使用selectedId判断选中状态却没有读取或更新scene.isSelected。默认数据中building.isSelected为true恰好与页面默认值building一致所以暂时没有冲突如果将默认选中项改成月球只改模型或只改页面都会造成状态分叉。更稳的原则是场景数据描述“它是什么”。页面状态描述“用户现在选了谁”。不在每个列表项里保存可推导的isSelected。如果必须从数据层指定默认项可以新增isDefault页面首次加载时只读取一次然后仍以selectedId作为运行期唯一状态。三、默认数据与天文产品定位存在明确错位getDefaultScenes()当前返回四项{ id: building, name: 教学楼, description: 标准场景适合基础模拟, height: 10, gravity: 9.8, icon: $r(app.media.ic_exp_freefall), isSelected: true, isCustom: false }{ id: moon, name: 月球表面, description: 低重力环境, height: 10, gravity: 1.62, icon: $r(app.media.ic_exp_freefall), isSelected: false, isCustom: false }另外两项是“高塔”和“自定义场景”。除了月球外它们并不是行星、卫星或天文现象入口四项还共用同一个ic_exp_freefall图标。这不是可以靠改标题掩盖的小问题。若产品页面标题是“天体运行模拟”场景卡片却出现教学楼和高塔用户会怀疑自己是否进入了错误模块。发布前应在两条路线中明确选择如果此页服务于自由落体实验保留当前数据但调整页面归属、命名和跳转目标。如果此页服务于天体模拟重构场景模型和默认数据改为与模拟页已有expId一致的入口。本文后续采用第二条作为演进方案但不会声称当前源码已经完成重构。四、场景入口应围绕模拟页已经支持的expId真实模拟页根据expId选择场景expId模拟页场景stable_orbit稳定双体系统black_hole黑洞吞噬行星three_body三体扰动实验binary_star自定义双星系统galaxy_collision星系碰撞预演elliptic_escape椭圆与逃逸轨道sandbox自由宇宙因此新的场景入口不需要发明另一套 scene code。最重要的契约是选择页输出的 ID 必须能被ExperimentSimPage.resetSystem()识别。可以定义面向天文场景的类型export type CelestialSceneId | stable_orbit | black_hole | three_body | binary_star | galaxy_collision | elliptic_escape | sandbox export type SceneCategory | 轨道 | 多体 | 极端天体 | 星系 | 自由创建这段是建议的重构代码。它把字符串集合收紧为联合类型能够在 ArkTS 编译阶段发现拼写错误避免选择页传stable-oribt后模拟页悄悄落回默认分支。五、重新设计场景模型保留展示信息移除自由落体专属字段面向当前产品更贴切的模型可以是export interface CelestialScene { id: CelestialSceneId name: string description: string category: SceneCategory icon: Resource bodyTypes: string[] difficulty: 入门 | 进阶 | 挑战 isCustom: boolean }这里没有height和固定gravity因为 N 体模拟中的引力来自天体质量与实时距离不是场景级常量。新增字段各有明确用途category用于按轨道、多体、极端天体等分类。bodyTypes提示场景中会出现恒星、行星、黑洞等对象。difficulty帮助学习者选择合适入口。isCustom区分预置场景与自由宇宙。默认数据可以复用模拟页已存在的能力export function getCelestialScenes(): CelestialScene[] { return [ { id: stable_orbit, name: 稳定双体系统, description: 观察恒星与行星的近圆轨道, category: 轨道, icon: $r(app.media.ic_exp_stable_orbit), bodyTypes: [恒星, 行星], difficulty: 入门, isCustom: false }, { id: three_body, name: 三体扰动实验, description: 观察初值扰动如何改变多体轨迹, category: 多体, icon: $r(app.media.ic_exp_three_body), bodyTypes: [恒星, 行星], difficulty: 挑战, isCustom: false }, { id: sandbox, name: 自由宇宙, description: 自行放置恒星、行星与卫星, category: 自由创建, icon: $r(app.media.ic_exp_sandbox), bodyTypes: [恒星, 行星, 卫星], difficulty: 进阶, isCustom: true } ] }资源名只是建议必须在实际资源目录存在后才能引用。当前源码所有旧场景共用ic_exp_freefall不能直接声称已经有这些独立图标。六、单选状态只保留selectedId页面现在已经使用这个模式State selectedId: string building升级后可以把类型收紧并让默认值与第一项天文场景一致State selectedId: CelestialSceneId stable_orbit点击逻辑仍然简单.onClick(() { this.selectedId scene.id })所有视觉状态都从同一个表达式派生const selected this.selectedId scene.idArkUI 的声明式渲染适合这种“一个状态多处消费”的结构。背景、边框、对勾、辅助文案都不需要分别维护布尔值。如果把isSelected留在每个数据项里每次点击就要遍历数组、清除旧项、设置新项再创建新数组触发刷新。对于单选列表这种复杂度没有收益。七、确认按钮必须完成路由契约当前按钮没有行为这是闭环中最需要补齐的一步。若确认后直接进入模拟页可以这样组织private confirmScene(): void { const selected this.scenes.find( (scene: CelestialScene) scene.id this.selectedId ) if (!selected) { return } router.pushUrl({ url: views/experiment/ExperimentSimPage, params: { expId: selected.id, expName: selected.name } }) }按钮绑定Button(确认选择) .onClick(() { this.confirmScene() })模拟页已经真实读取这两个参数const params router.getParams() as SimRouterParams | undefined if (params?.expId) { this.expId params.expId } if (params?.expName) { this.title params.expName }这说明路由契约具备现成接收端。增强重点不在模拟页而在选择页必须传出与其分支一致的expId。八、确认前要重新查找对象不能只信任字符串即使selectedId有类型约束确认时仍建议从当前列表查找一次。原因包括列表可能经过分类过滤或远期配置迁移。默认值可能指向已删除场景。恢复的历史选择可能不再受支持。页面初始化和数据加载顺序可能发生变化。找不到时不应直接跳转到默认模拟。否则用户选择失效却没有提示排查起来会像模拟页错误。可以维护一个页面错误状态State selectionError: string private selectedScene(): CelestialScene | undefined { return this.scenes.find( (scene: CelestialScene) scene.id this.selectedId ) }const selected this.selectedScene() if (!selected) { this.selectionError 当前场景不可用请重新选择 return }这是增强建议当前页面没有错误提示状态。它体现了一个重要原则路由参数是跨页面协议不应仅靠视觉选中保证正确。九、卡片展示应回答“进入后会看到什么”当前卡片显示名称、描述、高度和重力加速度if (scene.height 0) { Text(高度${scene.height} m) } if (scene.gravity ! 9.8) { Text(重力加速度${scene.gravity} m/s²) }对天文模拟这些字段不再合适。更有价值的是天体构成恒星 行星、双星 外侧行星、黑洞 行星。观察目标稳定轨道、逃逸、扰动、碰撞合并。难度入门、进阶、挑战。可编辑性预置场景或自由创建。ArkUI 卡片可以继续保持现有结构只替换辅助行Row({ space: 8 }) { Text(scene.category) Text(scene.difficulty) Text(scene.bodyTypes.join( / )) }长文本要设置maxLines和textOverflow尤其是 phone 小窗口Text(scene.description) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis })当前源码描述文本没有设置最大行数。默认数据很短因此未必溢出升级为更完整的天文说明后这个约束会变得必要。十、分类入口不要把页面变成无限长列表当场景从 4 个增加到 7 个以上仍然全部平铺并非不能用但用户很难快速区分“轨道”和“极端天体”。可以像公式速查页一样增加横向分类State selectedCategory: SceneCategory | 全部 全部 private visibleScenes(): CelestialScene[] { if (this.selectedCategory 全部) { return this.scenes } return this.scenes.filter( (scene: CelestialScene) scene.category this.selectedCategory ) }这里有一个容易忽略的状态问题切换分类后当前选中场景可能不在可见列表中。产品需要明确策略策略行为适用场景保留选择分类切换不改变selectedId用户可能只是浏览清空选择当前项不可见时要求重选确认必须针对可见项自动选首项分类切换后选中第一项快速操作但可能误触对于教学入口保留选择并在确认区显示当前场景通常最稳避免用户只是查看别的分类就丢失选择。十一、自定义场景应与预置场景走不同动作当前模型有isCustom卡片右侧显示齿轮if (scene.isCustom) { Text(⚙) .fontSize(20) .fontColor(AppColors.TEXT_SECONDARY) }但点击自定义项仍然只是选中没有打开配置页。天文版本可以定义两条路径预置场景确认后直接进入ExperimentSimPage。自由宇宙确认后进入模拟页的sandbox分支再由页面中的天体库和参数 Slider 完成创建。真实模拟页已经支持sandbox初始bodies为空并提供恒星、行星、卫星、小行星、黑洞的放置入口。因此这里不一定要新增“自定义配置页”用现有自由宇宙就能形成最小闭环。齿轮图标若没有独立操作应避免让用户误以为可以在卡片内配置。可以改为“自由创建”文本徽标或真正绑定一个编辑动作。十二、图标资源必须与每个场景一一对应当前四个默认场景都引用icon: $r(app.media.ic_exp_freefall)这在功能模板阶段可以占位但正式的天文选择页至少应让稳定轨道、黑洞、三体、双星和星系碰撞具有可区分图标。否则用户主要依赖文字列表扫描效率很低。图标准备需要遵守三个边界资源必须真实存在ArkTS$r()名称与文件一致。图标只表达场景不伪造实际模拟画面。亮色、深色背景和选中背景下都保持足够对比度。若暂时没有独立资源宁可使用统一的类型图标加清晰文字也不要在代码里引用不存在的媒体名。十三、列表布局已经具备基础自适应但还需补足文本约束现有页面使用List({ space: 12 }) { ForEach(this.scenes, (scene: Scene) { ListItem() { Row() { // icon information custom marker } } }) } .width(100%) .layoutWeight(1)信息列通过.layoutWeight(1)获取剩余空间图标固定为 64×64。这个结构在 phone 上很常见在 tablet 和 2in1 上也能拉伸。但多设备验证要关注超长场景名是否挤压对勾与自定义标识。两行描述是否把卡片高度撑得不一致。2in1 宽窗口是否需要双列布局而不是单列无限拉宽。底部按钮与系统导航区域是否保留安全距离。横屏小窗口中最后一个列表项能否滚动到按钮上方。当前按钮底部 margin 为 24而全局页面还使用bottomBarHeight。在手势导航设备上应验证两者组合后不被系统区域遮挡。十四、路由方式要区分“进入”与“返回选择结果”如果选择页由首页打开并希望进入新模拟页router.pushUrl()合适。如果选择页是从模拟页的“切换场景”进入则继续 push 可能形成模拟页 A - 选择页 - 模拟页 B用户按返回会回到旧模拟页 A体验不一定符合预期。此时可以考虑返回参数给上一页由上一页重置当前场景。使用替换式路由避免保留旧模拟页。在进入选择页前明确退出当前模拟。当前源码没有确认动作也没有定义这项导航策略。实现前应先确定页面入口。路由 API 的选择是用户返回路径的一部分不是按钮点击后的随意细节。十五、选择结果如果需要持久化存 ID 而不是整份对象若产品希望下次进入时恢复上次场景建议只存interface ScenePreference { selectedSceneId: CelestialSceneId }不要把名称、描述、图标资源和难度整份序列化。应用升级后文案或图标可能变化恢复旧对象会造成展示与当前版本不一致。读取 ID 后再从当前getCelestialScenes()查找找不到就回退到stable_orbit。当前源文件没有场景持久化逻辑本文不声称它会记住选择。这是未来接入 Preferences 时应遵守的数据边界。十六、场景模型与模拟分支必须做一致性检查最容易发生的回归是选择页新增了一个场景 ID模拟页没有对应分支最终落到默认稳定双体。可以写一个轻量测试或构建期检查const supportedIds: CelestialSceneId[] [ stable_orbit, black_hole, three_body, binary_star, galaxy_collision, elliptic_escape, sandbox ] const invalid getCelestialScenes().filter( (scene: CelestialScene) !supportedIds.includes(scene.id) )更理想的是把“场景 ID 初始化函数”集中到一个注册表选择页与模拟页共同读取而不是分别维护字符串。但当前代码规模较小先用联合类型和检查数组就能显著降低风险不必立即引入复杂框架。十七、最小验收用例默认进入打开场景选择页。确认默认场景有清晰边框和对勾。确认只有一个项目选中。切换选择依次点击三个场景。每次仅最新项显示选中状态。列表滚动后选中状态仍保持。确认契约选中three_body。点击确认。回读模拟页标题与expId。确认创建的是两颗恒星与一颗行星而不是默认双体。自由宇宙选择sandbox。进入后确认初始画布没有预置天体。使用天体库放置恒星与卫星。返回路径从模拟页进入选择页并切换场景。新场景启动后按系统返回。确认不会意外回到仍在运行的旧模拟。多设备phone 竖屏、横屏分别检查文字截断。tablet 检查卡片宽度与内容密度。2in1 检查鼠标选中、键盘焦点和窗口缩放。十八、常见问题与修复方向现象原因修复卡片有对勾确认后没反应按钮未绑定.onClick()建立显式确认方法选中月球但仍进入默认场景ID 未传递或模拟页不识别对齐expId联合类型模型写已选中页面却显示另一项isSelected与selectedId双状态保留唯一selectedId页面出现教学楼和高塔仍使用自由落体默认数据重构为天文场景模型所有卡片图标相同共用占位资源准备真实且可区分的图标自由场景齿轮不能点击图标仅装饰改徽标或绑定真实动作描述变长后卡片错位没有行数与溢出约束设置maxLines切换分类后确认旧场景选择与可见列表策略不明确显示当前选择或要求重选返回后看到旧模拟仍运行路由栈保留旧页面明确替换或回传策略十九、发布前检查清单页面显示的场景与产品天文定位一致。Scene模型不再保留无关的自由落体字段。场景 ID 与模拟页分支完全一致。选中状态只有一个可信来源。确认按钮有真实动作和失败兜底。自定义入口与预置入口行为明确。图标资源真实存在且一一对应。长标题和描述在小窗口不溢出。phone、tablet、2in1 的滚动与点击可用。返回路径不会留下后台运行的旧模拟。若持久化只保存稳定 ID并处理版本迁移。不把尚未接通的确认动作描述为已实现功能。二十、总结场景页的交付物不是卡片而是一个可靠入口协议现有SceneSelectorPage已经提供了可复用的 ArkUI 骨架顶部返回、List 列表、图标信息卡、selectedId单选状态和底部确认按钮。真正需要修正的是业务契约默认数据仍属于自由落体模板isSelected与页面状态重复确认动作尚未传递选择结果。把它升级为天文入口时最稳的做法是以模拟页真实支持的expId为核心建立收紧类型的场景模型用selectedId管理唯一选择确认时查找当前对象并显式传递expId、expName再根据轨道、多体、极端天体、星系和自由创建组织卡片。这样行星、卫星和天文现象不只是列表上的名词而会成为能够被路由验证、被模拟页识别、被返回路径正确管理的 HarmonyOS 功能入口。