场景选择页很容易被写成一组"点亮后看起来选中了"的卡片,但真正进入工程后,至少有四件事必须同时成立:列表数据能描述业务场景,选中状态只有一个可信来源,确认动作能把选择带到下游页面,图标与辅助参数能让用户在进入模拟前理解差异。
"天体运行模拟"的源码里已经有 SceneSelectorPage.ets 与 Scene.ets,但当前默认数据仍是"教学楼、月球表面、高塔、自定义场景",更接近自由落体教学模板;页面底部的"确认选择"按钮也尚未绑定跳转或回传动作。与此同时,真正的模拟页已经支持恒星、行星、卫星、小行星、黑洞,以及稳定双体、三体扰动、双星、星系碰撞等天文实验。
这正好提供了一个真实的工程问题:如何保留现有 ArkUI 列表与选择交互,把模型从通用重力场景升级为天文入口,并且不把"视觉选中"误当成"业务已生效"。本文会先复核现有代码,再给出可迁移的类型、路由和验证方案。所有增强代码都会明确标注,避免把规划中的能力写成已经上线的事实。

唯一复核标记:SCENE-ONE13-SELECT-CONTRACT-20260726:场景数据定义入口,selectedId 定义当前选择,确认动作必须显式传递场景标识。
一、先看真实现状:页面能选中,但还没有完成业务闭环
页面状态非常简洁:
ts
@State scenes: Scene[] = getDefaultScenes()
@State selectedId: string = 'building'
列表通过 ForEach 渲染,点击某个卡片时更新 selectedId:
ts
.onClick(() => {
this.selectedId = scene.id
})
选中项会改变背景、边框并显示对勾:
ts
if (this.selectedId === scene.id) {
Text(' ✓')
.fontSize(16)
.fontColor(AppColors.ACCENT_GREEN)
}
这些代码证明"本地视觉选择"已经存在。但底部按钮只有样式:
ts
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 模型混合了身份、展示、物理参数与状态
真实模型如下:
ts
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() 当前返回四项:
ts
{
id: 'building',
name: '教学楼',
description: '标准场景,适合基础模拟',
height: 10,
gravity: 9.8,
icon: $r('app.media.ic_exp_freefall'),
isSelected: true,
isCustom: false
}
ts
{
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() 识别。
可以定义面向天文场景的类型:
ts
export type CelestialSceneId =
| 'stable_orbit'
| 'black_hole'
| 'three_body'
| 'binary_star'
| 'galaxy_collision'
| 'elliptic_escape'
| 'sandbox'
export type SceneCategory =
| '轨道'
| '多体'
| '极端天体'
| '星系'
| '自由创建'
这段是建议的重构代码。它把字符串集合收紧为联合类型,能够在 ArkTS 编译阶段发现拼写错误,避免选择页传 stable-oribt 后模拟页悄悄落回默认分支。
五、重新设计场景模型:保留展示信息,移除自由落体专属字段
面向当前产品,更贴切的模型可以是:
ts
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区分预置场景与自由宇宙。
默认数据可以复用模拟页已存在的能力:
ts
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
页面现在已经使用这个模式:
ts
@State selectedId: string = 'building'
升级后可以把类型收紧,并让默认值与第一项天文场景一致:
ts
@State selectedId: CelestialSceneId = 'stable_orbit'
点击逻辑仍然简单:
ts
.onClick(() => {
this.selectedId = scene.id
})
所有视觉状态都从同一个表达式派生:
ts
const selected = this.selectedId === scene.id
ArkUI 的声明式渲染适合这种"一个状态,多处消费"的结构。背景、边框、对勾、辅助文案都不需要分别维护布尔值。
如果把 isSelected 留在每个数据项里,每次点击就要遍历数组、清除旧项、设置新项,再创建新数组触发刷新。对于单选列表,这种复杂度没有收益。
七、确认按钮必须完成路由契约
当前按钮没有行为,这是闭环中最需要补齐的一步。若确认后直接进入模拟页,可以这样组织:
ts
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
}
})
}
按钮绑定:
ts
Button('确认选择')
.onClick(() => {
this.confirmScene()
})
模拟页已经真实读取这两个参数:
ts
const params =
router.getParams() as SimRouterParams | undefined
if (params?.expId) {
this.expId = params.expId
}
if (params?.expName) {
this.title = params.expName
}
这说明路由契约具备现成接收端。增强重点不在模拟页,而在选择页必须传出与其分支一致的 expId。
八、确认前要重新查找对象,不能只信任字符串
即使 selectedId 有类型约束,确认时仍建议从当前列表查找一次。原因包括:
- 列表可能经过分类过滤或远期配置迁移。
- 默认值可能指向已删除场景。
- 恢复的历史选择可能不再受支持。
- 页面初始化和数据加载顺序可能发生变化。
找不到时不应直接跳转到默认模拟。否则用户选择失效却没有提示,排查起来会像模拟页错误。可以维护一个页面错误状态:
ts
@State selectionError: string = ''
private selectedScene(): CelestialScene | undefined {
return this.scenes.find(
(scene: CelestialScene) =>
scene.id === this.selectedId
)
}
ts
const selected = this.selectedScene()
if (!selected) {
this.selectionError = '当前场景不可用,请重新选择'
return
}
这是增强建议,当前页面没有错误提示状态。它体现了一个重要原则:路由参数是跨页面协议,不应仅靠视觉选中保证正确。
九、卡片展示应回答"进入后会看到什么"
当前卡片显示名称、描述、高度和重力加速度:
ts
if (scene.height > 0) {
Text(`高度:${scene.height} m`)
}
if (scene.gravity !== 9.8) {
Text(`重力加速度:${scene.gravity} m/s²`)
}
对天文模拟,这些字段不再合适。更有价值的是:
- 天体构成:恒星 + 行星、双星 + 外侧行星、黑洞 + 行星。
- 观察目标:稳定轨道、逃逸、扰动、碰撞合并。
- 难度:入门、进阶、挑战。
- 可编辑性:预置场景或自由创建。
ArkUI 卡片可以继续保持现有结构,只替换辅助行:
ts
Row({ space: 8 }) {
Text(scene.category)
Text(scene.difficulty)
Text(scene.bodyTypes.join(' / '))
}
长文本要设置 maxLines 和 textOverflow,尤其是 phone 小窗口:
ts
Text(scene.description)
.maxLines(2)
.textOverflow({
overflow: TextOverflow.Ellipsis
})
当前源码描述文本没有设置最大行数。默认数据很短,因此未必溢出;升级为更完整的天文说明后,这个约束会变得必要。
十、分类入口不要把页面变成无限长列表
当场景从 4 个增加到 7 个以上,仍然全部平铺并非不能用,但用户很难快速区分"轨道"和"极端天体"。可以像公式速查页一样增加横向分类:
ts
@State selectedCategory: SceneCategory | '全部' = '全部'
private visibleScenes(): CelestialScene[] {
if (this.selectedCategory === '全部') {
return this.scenes
}
return this.scenes.filter(
(scene: CelestialScene) =>
scene.category === this.selectedCategory
)
}
这里有一个容易忽略的状态问题:切换分类后,当前选中场景可能不在可见列表中。产品需要明确策略:
| 策略 | 行为 | 适用场景 |
|---|---|---|
| 保留选择 | 分类切换不改变 selectedId |
用户可能只是浏览 |
| 清空选择 | 当前项不可见时要求重选 | 确认必须针对可见项 |
| 自动选首项 | 分类切换后选中第一项 | 快速操作但可能误触 |
对于教学入口,保留选择并在确认区显示当前场景通常最稳,避免用户只是查看别的分类就丢失选择。
十一、自定义场景应与预置场景走不同动作
当前模型有 isCustom,卡片右侧显示齿轮:
ts
if (scene.isCustom) {
Text('⚙')
.fontSize(20)
.fontColor(AppColors.TEXT_SECONDARY)
}
但点击自定义项仍然只是选中,没有打开配置页。天文版本可以定义两条路径:
- 预置场景:确认后直接进入
ExperimentSimPage。 - 自由宇宙:确认后进入模拟页的
sandbox分支,再由页面中的天体库和参数 Slider 完成创建。
真实模拟页已经支持 sandbox,初始 bodies 为空,并提供恒星、行星、卫星、小行星、黑洞的放置入口。因此这里不一定要新增"自定义配置页";用现有自由宇宙就能形成最小闭环。
齿轮图标若没有独立操作,应避免让用户误以为可以在卡片内配置。可以改为"自由创建"文本徽标,或真正绑定一个编辑动作。
十二、图标资源必须与每个场景一一对应
当前四个默认场景都引用:
ts
icon: $r('app.media.ic_exp_freefall')
这在功能模板阶段可以占位,但正式的天文选择页至少应让稳定轨道、黑洞、三体、双星和星系碰撞具有可区分图标。否则用户主要依赖文字,列表扫描效率很低。
图标准备需要遵守三个边界:
- 资源必须真实存在,ArkTS
$r()名称与文件一致。 - 图标只表达场景,不伪造实际模拟画面。
- 亮色、深色背景和选中背景下都保持足够对比度。
若暂时没有独立资源,宁可使用统一的类型图标加清晰文字,也不要在代码里引用不存在的媒体名。
十三、列表布局已经具备基础自适应,但还需补足文本约束
现有页面使用:
ts
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 而不是整份对象
若产品希望下次进入时恢复上次场景,建议只存:
ts
interface ScenePreference {
selectedSceneId: CelestialSceneId
}
不要把名称、描述、图标资源和难度整份序列化。应用升级后文案或图标可能变化,恢复旧对象会造成展示与当前版本不一致。读取 ID 后再从当前 getCelestialScenes() 查找,找不到就回退到 stable_orbit。
当前源文件没有场景持久化逻辑,本文不声称它会记住选择。这是未来接入 Preferences 时应遵守的数据边界。
十六、场景模型与模拟分支必须做一致性检查
最容易发生的回归是:选择页新增了一个场景 ID,模拟页没有对应分支,最终落到默认稳定双体。可以写一个轻量测试或构建期检查:
ts
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 功能入口。