【天体运行模拟|03】HarmonyOS ArkTS 场景选择实战:组织行星、卫星与天文现象入口
2026/8/30 15:10:07 网站建设 项目流程

场景选择页很容易被写成一组“点亮后看起来选中了”的卡片,但真正进入工程后,至少有四件事必须同时成立:列表数据能描述业务场景,选中状态只有一个可信来源,确认动作能把选择带到下游页面,图标与辅助参数能让用户在进入模拟前理解差异。

“天体运行模拟”的源码里已经有SceneSelectorPage.etsScene.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列表判断与未来路由参数
展示namedescriptionicon卡片标题、说明和图片
模拟参数heightgravity自由落体类场景参数
UI 状态isSelectedisCustom默认选择与自定义标识

问题不在字段多,而在“谁是真正状态源”不够明确。页面使用selectedId判断选中状态,却没有读取或更新scene.isSelected。默认数据中building.isSelectedtrue,恰好与页面默认值'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图标。

这不是可以靠改标题掩盖的小问题。若产品页面标题是“天体运行模拟”,场景卡片却出现教学楼和高塔,用户会怀疑自己是否进入了错误模块。发布前应在两条路线中明确选择:

  1. 如果此页服务于自由落体实验,保留当前数据,但调整页面归属、命名和跳转目标。
  2. 如果此页服务于天体模拟,重构场景模型和默认数据,改为与模拟页已有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.id

ArkUI 的声明式渲染适合这种“一个状态,多处消费”的结构。背景、边框、对勾、辅助文案都不需要分别维护布尔值。

如果把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(' / ')) }

长文本要设置maxLinestextOverflow,尤其是 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) }

但点击自定义项仍然只是选中,没有打开配置页。天文版本可以定义两条路径:

  1. 预置场景:确认后直接进入ExperimentSimPage
  2. 自由宇宙:确认后进入模拟页的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 + 初始化函数”集中到一个注册表,选择页与模拟页共同读取,而不是分别维护字符串。但当前代码规模较小,先用联合类型和检查数组就能显著降低风险,不必立即引入复杂框架。

十七、最小验收用例

默认进入

  1. 打开场景选择页。
  2. 确认默认场景有清晰边框和对勾。
  3. 确认只有一个项目选中。

切换选择

  1. 依次点击三个场景。
  2. 每次仅最新项显示选中状态。
  3. 列表滚动后,选中状态仍保持。

确认契约

  1. 选中three_body
  2. 点击确认。
  3. 回读模拟页标题与expId
  4. 确认创建的是两颗恒星与一颗行星,而不是默认双体。

自由宇宙

  1. 选择sandbox
  2. 进入后确认初始画布没有预置天体。
  3. 使用天体库放置恒星与卫星。

返回路径

  1. 从模拟页进入选择页并切换场景。
  2. 新场景启动后按系统返回。
  3. 确认不会意外回到仍在运行的旧模拟。

多设备

  1. phone 竖屏、横屏分别检查文字截断。
  2. tablet 检查卡片宽度与内容密度。
  3. 2in1 检查鼠标选中、键盘焦点和窗口缩放。

十八、常见问题与修复方向

现象原因修复
卡片有对勾,确认后没反应按钮未绑定.onClick()建立显式确认方法
选中月球但仍进入默认场景ID 未传递或模拟页不识别对齐expId联合类型
模型写已选中,页面却显示另一项isSelectedselectedId双状态保留唯一selectedId
页面出现教学楼和高塔仍使用自由落体默认数据重构为天文场景模型
所有卡片图标相同共用占位资源准备真实且可区分的图标
自由场景齿轮不能点击图标仅装饰改徽标或绑定真实动作
描述变长后卡片错位没有行数与溢出约束设置maxLines
切换分类后确认旧场景选择与可见列表策略不明确显示当前选择或要求重选
返回后看到旧模拟仍运行路由栈保留旧页面明确替换或回传策略

十九、发布前检查清单

  • 页面显示的场景与产品天文定位一致。
  • Scene模型不再保留无关的自由落体字段。
  • 场景 ID 与模拟页分支完全一致。
  • 选中状态只有一个可信来源。
  • 确认按钮有真实动作和失败兜底。
  • 自定义入口与预置入口行为明确。
  • 图标资源真实存在且一一对应。
  • 长标题和描述在小窗口不溢出。
  • phone、tablet、2in1 的滚动与点击可用。
  • 返回路径不会留下后台运行的旧模拟。
  • 若持久化,只保存稳定 ID,并处理版本迁移。
  • 不把尚未接通的确认动作描述为已实现功能。

二十、总结:场景页的交付物不是卡片,而是一个可靠入口协议

现有SceneSelectorPage已经提供了可复用的 ArkUI 骨架:顶部返回、List 列表、图标信息卡、selectedId单选状态和底部确认按钮。真正需要修正的是业务契约:默认数据仍属于自由落体模板,isSelected与页面状态重复,确认动作尚未传递选择结果。

把它升级为天文入口时,最稳的做法是以模拟页真实支持的expId为核心,建立收紧类型的场景模型;用selectedId管理唯一选择;确认时查找当前对象并显式传递expIdexpName;再根据轨道、多体、极端天体、星系和自由创建组织卡片。

这样,行星、卫星和天文现象不只是列表上的名词,而会成为能够被路由验证、被模拟页识别、被返回路径正确管理的 HarmonyOS 功能入口。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询