【天体运行模拟|04】HarmonyOS ArkTS 模拟结果实战:展示周期、速度和轨道状态

唯一核验标记:CSDN-SERIES:ALL-163200247

**证据边界:**本文“当前实现”来自本轮对 D:/huawei/one13-9 中 ExperimentResultPage.ets、模拟页路由、记录模型和分享入口的静态复核。本轮未执行构建、真机、轨迹采样、轨道分类或分享回归;真实周期计算、采样序列与结果仓库均是建议实现。

模拟结果工程封面

模拟结果页最容易出现一种“看起来很专业”的错误:折线图、稳定性分数和轨道结论都展示出来了,但读者无法判断哪些来自本次运行、哪些只是场景说明,哪些又是预置示例。结果页越漂亮,数据来源越需要明确,否则固定趋势可能被误解为实时采样,启发式分数可能被误解为科学测量,场景文案也可能越过模拟内核真正支持的能力。

“天体运行模拟”的 ExperimentResultPage.ets 已经实现完整的 ArkUI 结果界面:从路由接收天体数量、稳定性、最高速度、总质量与时间倍率;按 expId 选择分析卡片和趋势曲线;支持保存、复制与系统分享。与此同时,源码中的 chartValues 和大部分轨道结论是按场景预置的叙事数据,并不是从模拟过程逐帧采集;页面也没有计算真实轨道周期。

本文以这条真实边界为起点,拆解如何建立“运行值、派生值、场景说明”三类结果契约,让周期趋势、速度与轨道状态能够被正确展示和验证,而不是把预置文案包装成测量结果。

一、结果页首先要回答:这个数字从哪里来

当前页面存在三类数据源:

数据类型例子源码来源
本次运行值天体数量、最高速度、总质量、倍率模拟页通过路由传入
运行派生值稳定性分数模拟页按天体数量与最高速度计算后传入
场景预置值趋势曲线、轨道类型、风险提示结果页 buildAnalysis() 按 expId 返回

这个分类决定页面文案应使用什么语气。本次运行值可以写“本次最高速度 1.48 v”;场景预置值更适合写“该场景通常表现为近圆轨道”,不能写成“系统已测得近圆轨道”,除非模拟内核真的做了轨道分类。

目前页面将两类内容放在同一张分析列表中,但第一项由 withRuntimeSummary() 动态插入,因此至少保留了运行摘要入口。后续增强的重点,是把来源标签展示得更清楚。

二、用类型明确路由契约,而不是直接信任任意参数

页面定义了结果路由参数:

interface ResultRouterParams {
  expId?: string
  expName?: string
  bodyCount?: number
  stabilityScore?: number
  fastestSpeed?: number
  totalMass?: number
  speedScale?: number
}

模拟页点击“查看结果分析”时真实传入:

router.pushUrl({
  url: 'views/experiment/ExperimentResultPage',
  params: {
    expId: this.expId,
    expName: this.title,
    bodyCount: this.bodies.length,
    stabilityScore: Math.round(
      this.getStabilityScore()
    ),
    fastestSpeed: Number(
      this.getFastestSpeed().toFixed(2)
    ),
    totalMass: Math.round(
      this.getTotalMass()
    ),
    speedScale: Number(
      this.speed.toFixed(1)
    )
  }
})

这条契约没有传输整个 Body[],避免把运行模型复制到结果页。结果页只接收展示所需的摘要,页面职责更清晰,也降低了路由对象体积。

不过,ResultRouterParams 的字段全部可选。页面必须处理直接打开、路由恢复失败或旧版本参数缺失的情况,不能把 undefined 直接格式化。

模拟结果生成与展示流程

三、aboutToAppear() 完成参数落地与场景装配

页面出现时读取参数:

aboutToAppear(): void {
  const params =
    router.getParams() as ResultRouterParams | undefined
  if (params?.expId) {
    this.expId = params.expId
  }
  if (params?.expName) {
    this.expName = params.expName
  }
  this.currentBodyCount = params?.bodyCount ?? 0
  this.currentStabilityScore =
    params?.stabilityScore ?? 0
  this.currentFastestSpeed =
    params?.fastestSpeed ?? 0
  this.currentTotalMass =
    params?.totalMass ?? 0
  this.currentSpeedScale =
    params?.speedScale ?? 1
  const data = this.buildAnalysis(
    this.expId,
    this.currentBodyCount
  )
  this.chartTitle = data.chartTitle
  this.chartValues = data.chartValues
  this.crashHint = data.crashHint
  this.tableData =
    this.withRuntimeSummary(data.table)
  this.drawChart()
}

这里的顺序是正确的:先落地运行参数,再按 expId 构建场景分析,最后把运行摘要插入表格。若先构建摘要再写状态,结果会使用默认 0。

?? 可以处理缺失值,但不能拦截 NaN、Infinity、负天体数量或异常倍率。若结果页可能从更多入口打开,建议增加数值归一化函数。

四、运行摘要把速度、质量、倍率和状态集中到一张卡

withRuntimeSummary() 根据当前状态创建首项:

private withRuntimeSummary(
  items: AnalysisItem[]
): AnalysisItem[] {
  const speedText =
    `${this.currentFastestSpeed.toFixed(2)} v`
  const massText =
    `${this.currentTotalMass} M`
  const scoreText =
    this.currentStabilityScore > 0
      ? `${this.currentStabilityScore} 分`
      : '未启动'
  const bodyText =
    `${this.currentBodyCount} 个天体`
  const runtimeItem: AnalysisItem = {
    label: '本次运行',
    value: `${bodyText} / ${scoreText}`,
    note:
      `关卡:${this.expName};` +
      `速度倍率 ${this.currentSpeedScale.toFixed(1)}x;` +
      `总质量 ${massText};` +
      `最高速度 ${speedText}`
  }
  return [runtimeItem, ...items]
}

这个摘要有两个优点。第一,用户无需切换标签就能确认自己刚才运行的是哪个场景;第二,分享和保存逻辑都使用 tableData,因此运行摘要会自然进入导出结果。

需要注意,“最高速度”来自模拟结束时对当前 bodies 的统计,并不是全程历史最大值。源码中的 getFastestSpeed() 只遍历当前天体状态。因此更精确的文案应是“当前最高速度”,除非模拟页额外维护运行期间峰值。

五、稳定性分数是启发式指标,不是轨道稳定性证明

模拟页真实公式为:

private getStabilityScore(): number {
  return Math.max(
    8,
    Math.min(
      98,
      100 -
      Math.abs(this.bodies.length - 3) * 8 -
      this.getFastestSpeed() * 9
    )
  )
}

它根据天体数量偏离 3 的程度和当前最高速度扣分,再限制到 8~98。这个分数适合给学习者快速反馈,但没有分析轨道闭合、能量漂移、角动量误差或长期逃逸率。

结果页应该把它标为“演示稳定性”或“当前状态评分”,避免让“88 分”看起来像经过专业轨道判据计算。

如果要真正评估周期和稳定性,至少需要记录:

  • 每一采样点的时间。
  • 目标天体与中心天体的距离。
  • 速度或相对速度。
  • 总能量近似值。
  • 是否发生碰撞、合并或越界。

当前路由并未传入这些时间序列,结果页自然无法计算真实周期。

六、buildAnalysis() 是场景说明注册表

页面按 expId 返回不同分析:

private buildAnalysis(
  expId: string,
  bodyCount: number
): ExperimentAnalysis {
  if (expId === 'three_body') {
    return {
      title: '三体扰动实验',
      chartTitle: '三体能量与最近距离波动',
      chartValues: [80, 65, 88, 32, 70, 24, 58],
      crashHint:
        '三体系统对初值极度敏感...',
      table: [
        {
          label: '系统稳定性',
          value: '38 分',
          note: '轨道呈现典型混沌特征...'
        }
      ]
    }
  }
  // 其他场景...
}

这不是实时分析引擎,而是一个场景说明注册表。chartValues 是固定数组,表格值也是固定或只引用 bodyCount 的说明。

这种实现并非没有价值。它可以在第一版中提供针对不同实验的学习提示,让用户知道应该观察什么。但必须满足两个条件:

  1. UI 明确标注“场景参考”或“典型趋势”。
  2. 不使用“已测得”“本次发生”等只有实时采样才能支持的断言。

源码里部分文案使用了“已脱离”“已穿越”等完成态描述,容易被理解为本次运行事件。若内核没有事件记录,发布前应改为条件式文案,例如“速度足够高时可能脱离”“近距掠过可能导致快速合并”。

七、周期展示要从“曲线像周期”升级为可复核测量

标题要求展示周期、速度与轨道状态,但当前源码只展示趋势曲线,没有字段表示周期长度。双星场景中的固定曲线呈周期性起伏,也只是典型趋势。

若要增加真实周期,可以在模拟页记录目标天体相对中心的角度:

interface OrbitSample {
  elapsedMs: number
  distance: number
  speed: number
  angleRad: number
}

每隔固定模拟步采样:

private collectOrbitSample(
  center: Body,
  target: Body,
  elapsedMs: number
): void {
  const dx = target.x - center.x
  const dy = target.y - center.y
  const sample: OrbitSample = {
    elapsedMs: elapsedMs,
    distance: Math.sqrt(dx * dx + dy * dy),
    speed: Math.sqrt(
      target.vx * target.vx +
      target.vy * target.vy
    ),
    angleRad: Math.atan2(dy, dx)
  }
  this.orbitSamples.push(sample)
}

再通过角度累计跨越 2π 或检测同方向过零估算一周。需要处理角度从 π 跳到 -π 的展开,否则会误判。

这些代码是增强方案,当前工程没有 OrbitSample。在真实周期计算完成前,结果页只能说“趋势”而不能展示具体周期数值。

八、速度展示要区分当前值、峰值和采样序列

当前路由只有:

fastestSpeed?: number

它表示结果生成时所有存活天体中的最高速度。如果要绘制速度趋势,需要传递或读取采样序列:

interface ResultPayload {
  currentFastestSpeed: number
  peakSpeed: number
  speedSeries: number[]
}

三种值的含义完全不同:

字段含义UI 文案
currentFastestSpeed点击结果时的当前最高速度当前最高速度
peakSpeed整个运行阶段出现过的峰值运行峰值
speedSeries按时间采样的速度序列速度趋势

不能用固定 chartValues 同时代替三者。结果页如果暂时没有序列,可以只展示当前值,并把趋势页标记为“场景参考曲线”。

九、轨道状态也应带来源与置信度

当前预置表格会显示“近圆轨道”“椭圆 + 抛物线”“混沌多体”等轨道类型。这些结论按 expId 固定,而不是根据本次参数推断。

更严谨的数据结构可以增加来源:

type ResultSource =
  | 'runtime'
  | 'derived'
  | 'preset'
interface AnalysisItem {
  label: string
  value: string
  note: string
  source: ResultSource
}

UI 显示:

  • runtime:本次运行。
  • derived:根据本次状态估算。
  • preset:场景参考。

如果以后实现轨道分类,还可以增加置信度和判定依据:

interface OrbitClassification {
  type: '近圆' | '椭圆' | '逃逸' | '不确定'
  confidence: number
  evidence: string[]
}

这比只返回一个字符串更容易测试,也能在数据不足时明确显示“不确定”。

十、路由参数需要数值归一化

结果页当前使用默认值,但没有有限值与范围检查。可以增加:

private finiteOr(
  value: number | undefined,
  fallback: number
): number {
  if (
    value === undefined ||
    !Number.isFinite(value)
  ) {
    return fallback
  }
  return value
}

再针对业务范围处理:

this.currentBodyCount = Math.max(
  0,
  Math.floor(
    this.finiteOr(params?.bodyCount, 0)
  )
)
this.currentStabilityScore = Math.max(
  0,
  Math.min(
    100,
    this.finiteOr(
      params?.stabilityScore,
      0
    )
  )
)

这是增强建议。它防止 NaN.toFixed() 产生异常文本,也防止负天体数量、超过 100 的分数进入分享记录。

十一、Canvas 折线图只消费 0~100 的固定数组

绘图代码把每个值按百分比映射到纵轴:

const y =
  pad + plotH -
  (values[i] / 100) * plotH

横轴则按数组索引均匀分布:

const x =
  pad +
  i * plotW / (values.length - 1)

因此当前图表隐含两个条件:

  1. 所有值都在 0~100。
  2. 数组至少有两个元素。

如果 values.length === 1,分母为零;如果值超出范围,点会画出绘图区。预置数组满足条件,但未来接入实时数据时必须归一化:

private normalizedChartValues(
  values: number[]
): number[] {
  return values
    .filter((value: number) =>
      Number.isFinite(value)
    )
    .map((value: number) =>
      Math.max(0, Math.min(100, value))
    )
}

若不足两个点,显示空状态或“采样不足”,不要强行画线。

十二、图表标题要说明指标和横轴

源码标题包括“稳定性 / 能量波动趋势”“行星距黑洞距离衰减”等,但图中没有横轴单位、纵轴单位或图例。用户只能把它理解为相对趋势。

如果仍使用预置数据,可以把标题改成:

场景参考趋势(归一化示意)

如果接入真实采样,则至少明确:

  • 横轴:模拟时间或采样序号。
  • 纵轴:距离、速度、稳定性或归一化能量。
  • 倍率:时间轴是否已经乘以 speedScale。
  • 单位:px、v、M 或无量纲。

周期只有在时间轴明确时才可计算。仅按七个等距点画一条起伏曲线,不能推出“周期为多少”。

十三、标签切换应保证图表在首次显示时完成绘制

页面只有在 activeTab === 1 时创建 Canvas:

if (this.activeTab === 1) {
  Canvas(this.chartCtx)
    .onReady(() => {
      this.chartW = this.chartCtx.width
      this.chartH = this.chartCtx.height
      this.drawChart()
    })
}

aboutToAppear() 中提前调用 drawChart() 时,chartW 仍为 0,函数会直接返回:

if (this.chartW === 0) return

切换到趋势标签后,Canvas onReady 获取尺寸并再次绘制,因此首次展示能够正常完成。这条“先尝试、尺寸就绪后补画”的路径是可复核的。

若后续在趋势标签中更新数据,还要在数据变化后显式调用 drawChart();Canvas 不会像普通 ArkUI 文本那样自动根据数组重绘。

十四、保存记录使用结构化摘要,而不是保存截图

保存逻辑构建:

interface SavedAnalysisRecord {
  type: string
  title: string
  summary: string
  timestamp: number
}
private async saveAnalysis(): Promise<void> {
  const record: SavedAnalysisRecord = {
    type: 'analysis',
    title: '结果分析记录',
    summary: this.tableData
      .map((item: AnalysisItem) =>
        `${item.label}:${item.value}`
      )
      .join(','),
    timestamp: Date.now()
  }
  try {
    await DataStore.appendRecord(record)
    this.showToast('数据已保存到模拟记录')
  } catch (_) {
    this.showToast('保存失败,请稍后重试')
  }
}

结构化文本比截图更适合本地检索和长期兼容。但当前记录只保存 label/value,没有 note、expId、倍率和来源字段。虽然第一项摘要包含部分运行信息,未来做筛选或迁移时仍不够稳定。

建议保存明确字段:

interface SavedResultRecord {
  expId: string
  expName: string
  bodyCount: number
  stabilityScore: number
  fastestSpeed: number
  totalMass: number
  speedScale: number
  createdAt: number
}

展示文案可以随版本变化,基础数值则能重新格式化。

十五、分享前先建立纯文本结果

页面通过 buildSummaryText() 生成可复制文本:

private buildSummaryText(): string {
  let text = '【天体运行模拟 · 结果分析】\n'
  for (const item of this.tableData) {
    text +=
      `· ${item.label}:${item.value}` +
      `(${item.note})\n`
  }
  text += '\n— 来自「天体运行模拟」App'
  return text
}

然后让用户选择复制或系统分享:

AlertDialog.show({
  title: '分享结果',
  message: text,
  primaryButton: {
    value: '复制内容',
    action: () => {
      this.copyToClipboard(text)
    }
  },
  secondaryButton: {
    value: '系统分享',
    action: () => {
      this.systemShare(text)
    }
  }
})

这条链路的优点是分享内容可预览,用户明确触发,不会后台自动发送。分享文本同样需要来源标识,否则固定场景结论会被当成本次实测。可以在预置项前加“参考:”,在运行项前加“本次:”。

十六、错误反馈已有基本闭环

保存、复制和分享都使用 try/catch 与 Toast:

private showToast(message: string): void {
  try {
    promptAction.showToast({
      message: message,
      duration: 1800
    })
  } catch (_) {
  }
}
try {
  await pasteboard
    .getSystemPasteboard()
    .setData(data)
  this.showToast('结果已复制到剪贴板')
} catch (_) {
  this.showToast('复制失败,请稍后重试')
}

用户能看到成功或失败,不会点击后毫无响应。showToast() 自身吞掉异常,是为了避免提示失败再次破坏主流程;但调试构建中仍应记录可诊断信息,发布构建则避免泄露隐私或内部路径。

十七、结果页的多设备布局风险

页面支持 phone、tablet 与 2in1,根布局使用全宽全高,标题设置单行省略,卡片列表使用 layoutWeight(1)。但底部同时存在提示卡和三个并排按钮:

Row({ space: 10 }) {
  Button('保存数据').layoutWeight(1)
  Button('分享结果').layoutWeight(1)
  Button('继续模拟').layoutWeight(1)
}

在窄屏、大字体或多语言下,三个文字按钮可能拥挤。需要验证:

  • phone 小窗口中按钮文字是否完整。
  • 系统字体放大后是否换行或截断。
  • 趋势 Canvas 的固定 260 高度在横屏是否占用过多空间。
  • 提示卡较长时,底部操作是否仍可触达。
  • 2in1 窗口放大后,列表是否需要双栏或限制内容宽度。

如果三个按钮无法稳定容纳,可以把“保存、分享”改为图标按钮并提供 tooltip,把“继续模拟”保留为主操作。

十八、最小验证矩阵

路由完整

  1. 在模拟页运行稳定双体。
  2. 进入结果页。
  3. 回读场景名、天体数量、倍率、总质量和当前最高速度。
  4. 确认与点击结果前一致。

缺失参数

  1. 直接打开结果页或构造空参数。
  2. 确认页面不崩溃。
  3. 分数显示“未启动”,数值使用明确默认值。

场景分支

依次验证 stable_orbit、three_body、binary_star、black_hole、galaxy_collision、elliptic_escape 与 sandbox,确认标题、参考曲线和提示没有串场。

趋势图

  1. 首次进入停留在分析卡片。
  2. 切换到趋势曲线。
  3. 确认 Canvas 非空、标题可见、七个点位于绘图区。
  4. 返回卡片再切换,确认能重复显示。

保存与分享

  1. 保存记录并回到模拟记录页核对内容。
  2. 复制结果,检查剪贴板文本。
  3. 触发系统分享,不支持时确认出现失败提示。

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

现象根因修复
曲线每次都一样chartValues 是场景预置标记为参考趋势或接入采样
页面展示“周期”却没有数值没有时间序列和过零检测采集角度/距离并计算周期
最高速度与用户预期不同统计的是当前快照改文案或维护运行峰值
稳定性分数被误解为科学结论启发式公式未标注显示“演示评分”
单点曲线出现异常横轴分母为零少于两点显示空状态
切换标签后 Canvas 空白尺寸未就绪或数据更新未重画在 onReady 和更新后绘制
分享文本混合预置与实测没有来源字段增加 runtime/preset 标签
保存后难以筛选场景只存拼接字符串保存结构化结果字段
小屏三个按钮拥挤横向空间不足图标化次操作或调整布局

二十、发布前结果可信度清单

  • 所有路由数值经过缺失与非有限值处理。
  • “当前最高速度”与“运行峰值”不混用。
  • 稳定性分数标注为启发式或演示指标。
  • 预置曲线明确标注为场景参考。
  • 未采集时间序列前不展示具体轨道周期。
  • 轨道类型若为预设,使用条件式或参考式文案。
  • Canvas 数据至少两个点且位于预期范围。
  • 保存记录包含稳定字段,不只保存展示字符串。
  • 分享文本区分本次运行与场景说明。
  • 所有场景分支与 expId 一一对应。
  • phone、tablet、2in1 验证图表和底部操作。
  • 不把教学模拟结果描述为科研或工程决策依据。

二十一、结果字段需要携带来源和单位

单纯的 number 无法说明它来自路由、公式推导还是场景预置。建议结果模型把每个指标写成 value、unit、source、method 和可选的置信说明。页面可以据此显示“本次运行”“派生计算”或“场景说明”标签,分享文本也能保持同样口径,不会把预置趋势误写成实时采样。

周期是最容易混淆的例子。如果模拟引擎没有识别完整轨道并计算周期,页面只能展示场景参考周期或不展示该值;不能根据几帧位置变化就宣称测得周期。最高速度和总质量若由运行页传入,也应携带单位和时间范围,路由回读后先归一化再进入 UI。

二十二、趋势图必须有采样契约

当前固定数组适合演示图表组件,但它不是本次实验的轨迹证据。未来接入真实采样时,需要定义采样指标、时间戳、时间倍率、缺失点、最大点数和降采样方法。图表标题应说明纵轴是什么、横轴是模拟时间还是采样序号;切换标签时重新绘制,但不能改变底层数据含义。

图表数据页面文案必要验证
场景预置序列示例趋势 / 场景参考不写成实测
本次采样序列本次速度 / 距离趋势时间戳、单位、缺失点
派生轨道状态根据指定算法判定方法、阈值和适用范围

二十三、轨道状态要由明确算法产生

“稳定”“近圆”“逃逸”等词必须有可复核判定。建议状态分类器接收规范化轨迹或能量指标,返回状态 ID、解释、方法版本和输入范围。若样本不足、数据损坏或超出算法适用范围,应返回 unknown,而不是选择最接近的场景文案。

分类测试准备已知圆轨道、椭圆轨道、逃逸轨迹、扰动样本和不足样本。阈值需要固定在版本化配置中,不能为得到更好展示结果临时调整。当前源码没有这套分类器,因此本文只将其作为建议实现。

模拟结果当前结构与建议结构

二十四、保存和分享必须复用同一结果快照

用户点击保存或分享时,应从同一个不可变结果快照生成记录与文本,避免页面状态在两个操作之间变化。快照包含实验 ID、运行参数摘要、指标及来源、图表序列版本和生成时间;图片截图只适合展示,不能替代结构化记录。分享失败不应影响本地保存,保存失败也要给出明确重试反馈。

涉及系统分享时,正文应只包含用户主动选择的结果,不附带内部日志、完整本地路径或其他记录。当前页面已有复制与分享入口,但本轮没有执行设备分享回归,不将静态代码等同于运行成功。

二十五、当前事实与建议实现的边界

当前事实可回到源码:路由传入运行摘要,稳定性是派生值,曲线与多数轨道结论按场景预置;Canvas 消费固定数组,页面支持保存、复制和系统分享。没有真实周期计算、逐帧采样或轨道分类器。旧稿不能证明构建、设备或分享测试已经通过。

来源化结果模型、真实采样契约、轨道分类器、不可变结果快照和结果仓库都是建议能力。完成代码落地、算法样本测试、图表回归和设备分享验证后,才能更新为当前实现。

二十六、提交前验证清单

  • 每个指标标明本次运行、派生计算或场景预置来源;
  • 路由参数经过数值、范围和单位归一化;
  • 图表标题、横纵轴、时间倍率和采样方法清晰;
  • 数据不足时轨道状态返回 unknown,不伪造结论;
  • 保存、复制和分享复用同一结果快照;
  • phone、tablet、2in1、小窗口、横屏和大字体下结果可读;
  • 当前事实、历史证据和建议实现保持清晰边界。

二十一、总结:结果页的核心不是“多展示”,而是“标清来源”

ExperimentResultPage 已经具备一个完整结果体验的骨架:类型化路由参数、运行摘要、场景分析注册表、Canvas 趋势图、卡片/曲线切换、本地保存、剪贴板与系统分享。真正决定可信度的,是每个字段能否回答“它从哪里来”。

当前天体数量、总质量、倍率、稳定性和最高速度来自运行时快照;轨道类型、风险文案和趋势曲线主要来自场景预置;真实轨道周期尚未计算。只要页面明确区分这三类来源,现有版本就能作为教学解释页;如果要升级为测量型结果页,则应从模拟内核采集带时间戳的距离、角度和速度序列,再计算周期、峰值与轨道分类。

结果可信以后,折线图、卡片、保存和分享才不是装饰,而会成为一条可验证的 HarmonyOS 数据闭环。

AI 辅助声明

本文使用 AI 辅助整理结构、润色表达并生成配图;源码事实、结果字段、图表数据来源和能力边界均依据文中列出的本地文件人工复核。本文未虚构构建、真机、算法、用户、发布或评分数据。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐