天体模拟的模型层不应该只是“给列表页准备几段文字”。一个实验对象至少要同时服务首页卡片、分类筛选、收藏页、模拟入口、参数面板和实验记录。如果同一个id在不同页面含义不同,参数顺序靠猜,分类和难度随手写字符串,模型很快就会变成一堆无法验证的配置。
“天体运行模拟”的Experiment.ets已经建立了两个核心接口:ExperimentParam描述参数范围,Experiment描述实验身份、展示信息、分类、图标、收藏标记与参数数组;getAllExperiments()则注册了稳定双体、椭圆与逃逸、三体扰动、双星、黑洞、星系碰撞六个真实入口。
本文从这份真实源码出发,拆解强类型模型如何串起参数和初始条件,同时指出三个必须如实处理的边界:当前id/category/level仍是宽泛字符串,目录中的参数数组尚未真正驱动模拟页通用 Slider,isFavorite的静态默认值也不是持久化收藏状态。目标不是把接口写得更复杂,而是让每个字段都能被编译、运行和页面回读共同验证。
唯一复核标记:MODEL-ONE13-EXPERIMENT-CONTRACT-20260726:实验目录定义身份和参数约束,运行初值必须显式映射,用户状态不能混进静态模型。
验证基线:HarmonyOS 6.0.1(21) 兼容目标
本文面向 HarmonyOS 5.0 及以上版本,实际源码工程给出的构建基线更具体:应用版本为1.0.0,targetSdkVersion为6.0.2(22),compatibleSdkVersion为6.0.1(21),运行系统为 HarmonyOS;入口模块声明支持phone、tablet、2in1。这些值分别来自AppScope/app.json5、根目录build-profile.json5和entry/src/main/module.json5,不是根据界面截图推测出的版本。
本次验证对象固定为以下源码快照:
| 验证项 | 实际值 | 复核位置 |
|---|---|---|
| 应用版本 | 1.0.0/versionCode 1000000 | AppScope/app.json5 |
| 目标 SDK | 6.0.2(22) | build-profile.json5 |
| 最低兼容 SDK | 6.0.1(21) | build-profile.json5 |
| 设备类型 | 3 类 | entry/src/main/module.json5 |
| 实验目录 | 6 项 | Experiment.ets#getAllExperiments() |
| 参数定义 | 12 项 | 每个实验 2 项 |
对Experiment.ets做逐项静态复核后得到:6 个实验 ID 无重复,12 个参数都满足min <= defaultValue <= max,12 个step均大于 0,12 个参数单位均为非空字符串。这里的“通过”只代表模型目录的静态约束成立,不代表目录参数已经驱动模拟页;后文会专门说明这条尚未接通的运行链路。
为了让复核可以落到代码,建议把以下断言放进模型测试或启动期开发校验中:
function validateCatalog(experiments: Experiment[]): string[] { const errors: string[] = [] const ids = new Set<string>() experiments.forEach((experiment: Experiment) => { if (ids.has(experiment.id)) { errors.push(`duplicate experiment id: ${experiment.id}`) } ids.add(experiment.id) experiment.params.forEach((param: ExperimentParam) => { if (param.min > param.defaultValue || param.defaultValue > param.max) { errors.push(`${experiment.id}/${param.name}: default out of range`) } if (param.step <= 0) { errors.push(`${experiment.id}/${param.name}: invalid step`) } if (param.unit.trim().length === 0) { errors.push(`${experiment.id}/${param.name}: empty unit`) } }) }) return errors }将当前getAllExperiments()的返回值传入后,预期结果为:
experimentCount = 6 parameterCount = 12 duplicateIdCount = 0 invalidDefaultCount = 0 invalidStepCount = 0 emptyUnitCount = 0一、现有模型解决了什么
参数接口为:
export interface ExperimentParam { name: string unit: string min: number max: number defaultValue: number step: number }实验接口为:
export interface Experiment { id: string name: string description: string category: string level: string icon: Resource isFavorite: boolean params: ExperimentParam[] }这两个接口已经比散落常量稳得多。列表页只需读取Experiment[],参数展示可以统一遍历params,资源通过 HarmonyOSResource类型引用,调用方不用为每个实验写一套卡片结构。
字段可以按职责划分:
| 职责 | 字段 |
|---|---|
| 稳定身份 | id |
| 用户展示 | name、description、icon |
| 内容组织 | category、level |
| 参数约束 | params |
| 用户状态 | isFavorite |
最后一项值得警惕:收藏不是实验定义,而是用户状态。当前默认值全部为false,真实收藏页会通过DataStore加载 ID 后重新映射。因此,后续应把它从静态目录分离。
二、id是跨页面协议,不只是数组里的键
真实目录包含:
id: 'stable_orbit' id: 'elliptic_escape' id: 'three_body' id: 'binary_star' id: 'black_hole' id: 'galaxy_collision'这些字符串会进入多个位置:
- 首页与实验室列表的点击参数。
- 模拟页
resetSystem()的场景分支。 - 结果页
buildAnalysis()的分析分支。 - 收藏数据中的实验 ID。
- 实验记录的
experimentId。
因此,一个拼写错误可能同时影响路由、收藏、记录和结果分析。当前类型是string,编译器不能发现stable_oribt。
更稳的方式是收紧为联合类型:
export type ExperimentId = | 'stable_orbit' | 'elliptic_escape' | 'three_body' | 'binary_star' | 'black_hole' | 'galaxy_collision' | 'sandbox'export interface Experiment { id: ExperimentId // other fields }sandbox当前由模拟页支持,但不在getAllExperiments()目录中。是否加入正式实验列表应由产品决定;即使不展示,也可以保留在运行场景 ID 类型中,再把“目录实验 ID”和“运行场景 ID”拆成两个类型。
三、分类与难度不应继续使用任意字符串
当前分类包括:
基础认知 轨道探索 多体系统 高级实验难度包括:
初级任务 中级挑战 高级实验“高级实验”同时出现在分类和难度中,语义容易混淆。列表页按category过滤,而卡片同时显示category和level;如果运营文案稍有变化,筛选条件可能失效。
可以分别定义:
export type ExperimentCategory = | '基础认知' | '轨道探索' | '多体系统' | '极端天体' export type ExperimentLevel = | '初级' | '中级' | '高级'再让 UI 自己组合“初级任务”“中级挑战”等展示词。这样,分类用于导航,难度用于学习顺序,两者不会共享同一个模糊词。
这属于增强方案。当前真实数据中,黑洞与星系碰撞的category和level都是“高级实验”,文章不能声称已经完成语义拆分。
四、参数定义同时描述范围、默认值和交互粒度
稳定双体系统的真实参数:
params: [ { name: '恒星质量', unit: 'M', min: 400, max: 1800, defaultValue: 980, step: 20 }, { name: '行星初速度', unit: 'v', min: 0.4, max: 3.2, defaultValue: 1.25, step: 0.05 } ]这些字段不是普通表单元数据。它们共同构成参数契约:
min/max决定可接受区间。defaultValue决定实验初始状态。step决定用户能否稳定复现某个参数点。unit决定展示和解释方式。
可以在目录初始化时验证:
function isValidParam( param: ExperimentParam ): boolean { return ( Number.isFinite(param.min) && Number.isFinite(param.max) && Number.isFinite(param.defaultValue) && Number.isFinite(param.step) && param.min <= param.defaultValue && param.defaultValue <= param.max && param.step > 0 ) }这个函数是建议,不是当前源码已有实现。它能阻止默认值落在范围外、步长为零或非有限数。
五、参数名称不适合作为稳定业务键
当前ExperimentParam只有name,例如“恒星质量”“行星初速度”。如果运行页靠名称判断参数含义,一旦文案改成“中心恒星质量”,映射就会失效。
建议增加机器可读键:
export type ExperimentParamKey = | 'primaryMass' | 'orbitalRadius' | 'tangentialSpeed' | 'disturbance' | 'timeScale' | 'binaryDistance' | 'massRatio' | 'approachSpeed' export interface ExperimentParam { key: ExperimentParamKey name: string unit: string min: number max: number defaultValue: number step: number }机器逻辑读取key,UI 展示name。文案、国际化和产品调整不会破坏参数映射。
六、六组实验真实表达了不同初始条件
目录中的六个实验不是同一参数模板换标题,而是各有目标:
| 实验 | 参数 1 | 参数 2 | 观察目标 |
|---|---|---|---|
| 稳定双体 | 恒星质量 | 行星初速度 | 圆轨道与椭圆轨道 |
| 椭圆与逃逸 | 轨道半径 | 切向速度 | 束缚与逃逸 |
| 三体扰动 | 扰动强度 | 时间倍率 | 初值敏感性 |
| 双星系统 | 双星间距 | 互绕速度 | 共同质心与稳定区 |
| 黑洞吞噬 | 黑洞质量 | 行星速度 | 强引力与坠落 |
| 星系碰撞 | 星系质量比 | 接近速度 | 潮汐拉伸与合并 |
这说明params已经具备“初始条件目录”的雏形。问题是运行页当前仍定义一套通用参数:
@State paramDefs: ExperimentParam[] = [ { name: '质量', ... }, { name: '半径', ... }, { name: '初速度', ... }, { name: '方向', ... }, { name: '轨道倾角', ... } ]它没有在aboutToAppear()中读取当前实验的params。因此,目录参数目前主要是描述数据,并未真正驱动模拟页控制器。文章必须明确这个事实。
七、目录参数要通过显式映射进入运行模型
不能简单写:
this.paramDefs = experiment.params因为模拟页的addSelectedBody()依赖固定数组索引:
this.paramValues[0] // mass this.paramValues[1] // radius this.paramValues[2] // speed this.paramValues[3] // direction this.paramValues[4] // tilt而目录里的每个实验只有两个参数,含义也不同。若直接替换,paramValues[2]可能为空,双星间距也无法自动映射到mass。
更稳的方式是为每种实验建立强类型初值:
interface StableOrbitInitialState { primaryMass: number planetSpeed: number } interface BinaryStarInitialState { starDistance: number orbitSpeed: number } interface GalaxyCollisionInitialState { massRatio: number approachSpeed: number }再由场景初始化函数消费对应结构,而不是让一个通用数组承担所有含义。
八、使用可判别联合描述不同实验初始条件
ArkTS 中可以用kind建立可判别联合:
interface StableOrbitConfig { kind: 'stable_orbit' primaryMass: number planetSpeed: number } interface ThreeBodyConfig { kind: 'three_body' disturbancePercent: number timeScale: number } interface BlackHoleConfig { kind: 'black_hole' blackHoleMass: number planetSpeed: number } type ExperimentInitialConfig = | StableOrbitConfig | ThreeBodyConfig | BlackHoleConfig处理时根据kind收窄:
function buildBodies( config: ExperimentInitialConfig ): BodySeed[] { if (config.kind === 'stable_orbit') { return buildStableOrbitBodies(config) } if (config.kind === 'three_body') { return buildThreeBodyBodies(config) } return buildBlackHoleBodies(config) }这种结构能让编译器知道三体配置没有blackHoleMass,比Record<string, number>或number[]更可靠。
这些是演进代码,当前Experiment.ets尚未定义可判别联合。
九、显示元数据与运行配置要分层
目录实验既有名称、描述、图标,又有参数。随着场景复杂度增加,可以拆成:
export interface ExperimentMeta { id: ExperimentId name: string description: string category: ExperimentCategory level: ExperimentLevel icon: Resource } export interface ExperimentDefinition { meta: ExperimentMeta params: ExperimentParam[] }首页只读取meta,参数面板读取params,模拟服务再把参数解析成ExperimentInitialConfig。这不是为了增加层级,而是避免首页组件依赖运行细节。
当前工程规模只有六项,单个接口仍可维护。只有当参数解析、版本迁移或设备差异开始增长时,拆分才真正有价值。
十、Resource类型让图标引用接受编译期检查
每个实验使用:
icon: $r('app.media.ic_orbit_stable')接口字段为:
icon: Resource这比把图片路径保存为字符串更符合 HarmonyOS 资源体系。调用方可以直接:
Image(exp.icon)资源名、限定目录与深浅色资源仍需在构建和设备上验证,但模型层至少不会把本地文件路径或网络 URL 混进图标字段。
图标还承担实验辨识:稳定轨道、逃逸、三体、双星、黑洞与星系各有独立资源名,符合“一项实验一个视觉入口”的结构。
十一、收藏状态不能固定在实验目录
真实目录中每项都有:
isFavorite: false但模拟页会:
const ids = await DataStore.loadFavorites() this.isFavorite = ids.includes(this.expId)收藏页也会加载 ID,再从getAllExperiments()查找实验。这说明真正可信源是本地存储中的 ID 集合,而不是目录的isFavorite。
更清晰的模型是删除静态字段:
export interface Experiment { id: ExperimentId name: string description: string category: ExperimentCategory level: ExperimentLevel icon: Resource params: ExperimentParam[] }页面组合:
interface ExperimentViewState { experiment: Experiment isFavorite: boolean }这样,实验目录保持不可变,用户状态由 ViewModel 或页面状态拥有。
十二、getAllExperiments()每次都会创建新数组
函数当前直接返回字面量数组。每次调用都会创建新的对象和参数数组:
export function getAllExperiments(): Experiment[] { return [ // definitions ] }这有一个意外优点:调用方修改isFavorite不会污染全局单例。但代价是首页、收藏、记录与模拟页每次查找都会重新创建目录。
更稳的做法取决于模型是否可变:
- 若目录保持只读,可定义模块级常量并返回只读引用。
- 若调用方可能改对象,可返回深拷贝或只读类型。
例如:
const EXPERIMENTS: Experiment[] = [ // definitions ] export function getAllExperiments(): Experiment[] { return EXPERIMENTS.map( (item: Experiment) => ({ ...item, params: [...item.params] }) ) }当前只有六项,性能压力很小;重点是明确可变性契约,而不是为了少创建对象过早优化。
十三、通过查找函数集中处理不存在的 ID
工程多处写:
getAllExperiments().find( (experiment: Experiment) => experiment.id === this.expId )可以集中为:
export function findExperiment( id: ExperimentId ): Experiment | undefined { return getAllExperiments().find( (experiment: Experiment) => experiment.id === id ) }如果某些调用必须有结果,可以提供带回退的函数:
export function getExperimentOrDefault( id: ExperimentId ): Experiment { return ( findExperiment(id) ?? getAllExperiments()[0] ) }路由入口更适合显式处理undefined,不要静默回退;记录展示可以回退为“已下线实验”。不同调用场景不应共享同一种失败策略。
十四、目录自身需要启动时一致性检查
可以验证:
export function validateExperiments( experiments: Experiment[] ): string[] { const errors: string[] = [] const ids = new Set<string>() for (const experiment of experiments) { if (ids.has(experiment.id)) { errors.push(`重复实验 ID: ${experiment.id}`) } ids.add(experiment.id) for (const param of experiment.params) { if (!isValidParam(param)) { errors.push( `${experiment.id} 参数无效: ${param.name}` ) } } } return errors }检查项至少包括:
- ID 唯一。
- 名称与描述非空。
- 参数默认值在范围内。
- 步长为正。
- 同一实验参数 key 不重复。
- 图标资源构建可解析。
发布构建不一定要把错误直接展示给用户,但开发与测试阶段应让目录问题尽早暴露。
十五、版本迁移要保护收藏和历史记录
收藏和记录都保存实验 ID。重命名stable_orbit会让旧数据找不到对应实验。模型演进应遵守:
- 展示名称可以改,稳定 ID 不轻易改。
- 必须改 ID 时提供迁移表。
- 已删除实验的历史记录仍可显示基础快照。
迁移表示例:
const EXPERIMENT_ID_MIGRATION: Record<string, ExperimentId> = { orbit_basic: 'stable_orbit', escape_demo: 'elliptic_escape' }当前版本没有显示迁移需求,但一旦应用已经发布,稳定 ID 就成为数据兼容协议。
十六、把参数值与参数定义分开保存
ExperimentParam是定义,不是用户当前值。不要向其中动态添加value并把同一对象在多个页面共享。
可以定义:
export interface ExperimentParamValue { key: ExperimentParamKey value: number } export interface ExperimentRunDraft { experimentId: ExperimentId values: ExperimentParamValue[] }定义回答“允许什么”,草稿回答“用户这次选择什么”。恢复草稿时,再用当前定义校验旧值。
这种分离尤其适合多设备:phone 上调好的参数可以在 tablet 界面重新渲染,而不需要持久化 UI 控件状态。
十七、参数映射应返回明确错误
若目录参数无法转换为运行初值,不应悄悄使用默认值。可以定义:
interface ConfigBuildResult { ok: boolean config?: ExperimentInitialConfig message?: string }function buildInitialConfig( experiment: Experiment, values: ExperimentParamValue[] ): ConfigBuildResult { if (experiment.id === 'stable_orbit') { // validate and build } return { ok: false, message: '当前实验暂不支持参数映射' } }页面可显示错误状态,而不是进入一个与用户参数无关的默认场景。当前源码尚未接入这层映射,所以文章只把它作为实现边界。
十八、多设备模型应保持与布局无关
phone、tablet 和 2in1 可以采用不同布局,但应消费同一个Experiment:
- phone:单列卡片、底部主导航。
- tablet:双列卡片、侧栏分类。
- 2in1:列表与参数详情并排。
模型中不要加入cardWidth、columns或fontSize。布局策略由页面根据窗口决定,实验定义只描述业务。
Resource图标可以在不同资源限定目录提供适配版本,而无需更改模型中的资源名。
十九、最小验证用例
目录完整性
- 调用
getAllExperiments()。 - 确认返回 6 个真实实验。
- 确认所有 ID 唯一。
- 确认每个实验至少有一个参数。
参数边界
对每个ExperimentParam验证:
min <= defaultValue <= max step > 0 unit 非空路由一致
- 从列表点击每个实验。
- 回读模拟页
expId。 - 确认模拟页有对应初始化分支。
- 进入结果页确认分析分支一致。
收藏一致
- 收藏某实验。
- 重新调用
getAllExperiments()。 - 确认收藏状态来自 DataStore,而不是目录默认值。
参数生效
当前版本应明确记录为“待补”:目录params尚未驱动模拟页通用参数面板。增强后再验证修改质量、速度等是否真正影响初始化对象。
二十、常见问题与修复方向
| 现象 | 根因 | 修复 |
|---|---|---|
| 路由传错 ID 仍能编译 | id是普通 string | 使用联合类型 |
| 分类筛选漏项 | 分类文案不一致 | 收紧ExperimentCategory |
| 目录参数改了,模拟不变 | 参数尚未映射到运行初值 | 建立显式转换器 |
| 收藏状态总是 false | 读取了静态字段 | 从 DataStore 组合视图状态 |
| 修改参数名称后逻辑失效 | 名称被当作业务键 | 增加稳定key |
| 历史记录找不到实验 | ID 被重命名 | 保持稳定 ID 或迁移 |
| 默认值超出 Slider 范围 | 缺少目录校验 | 启动或测试时验证 |
| 多页面出现不同实验描述 | 各自复制配置 | 统一从目录读取 |
| 大屏需要不同卡片结构 | 模型混入布局字段 | 让页面负责适配 |
二十一、发布前模型清单
- 实验 ID 唯一且跨版本稳定。
- 分类与难度使用独立语义。
- 参数具有稳定 key,不依赖展示名称。
- 每个默认值落在合法范围内。
- 步长大于零且显示精度匹配。
- 目录参数与运行初值存在显式映射。
- 用户收藏状态不存入静态目录。
- 结果页和模拟页识别同一组 ID。
- 图标资源真实存在且与实验匹配。
- 历史记录对未知或下线 ID 有回退。
- 模型不包含 phone/tablet/2in1 布局细节。
- 未实现的参数驱动能力不写成已生效。
二十二、总结:强类型的价值是把错误提前到模型边界
真实Experiment.ets已经完成重要的一步:六个天文实验拥有统一结构,参数范围、默认值、步长和单位集中定义,页面不必散落复制。这份目录正在被首页、实验室、收藏、模拟记录和模拟页查找共同使用。
下一步不是简单增加更多字段,而是收紧真正稳定的协议:把实验 ID、分类、难度和参数 key 变成受约束类型;把静态目录与收藏状态分开;把参数定义与本次参数值分开;最后用显式转换把目录参数映射为各场景的初始天体结构。
做到这些以后,“恒星质量 980 M”“切向速度 1.75 v”才不只是卡片上的配置,而会沿着 ArkTS 类型系统可靠地进入 Canvas 模拟、结果分析、收藏与历史记录,成为可以验证和迁移的 HarmonyOS 业务模型。