部分内容由AI辅助生成。本文面向 HarmonyOS 5.0 及以上版本,基于 细胞工坊 项目真实源码展开,源码根目录为 D:\huawei\one14-9。本文重点复核 entry/src/main/ets/views/experiment/ExperimentResultPage.ets,并结合 ExperimentFeedback.ets 的表头和结论模型,不虚构源码里不存在的数据上报、云端分析或 AI 诊断能力。

实验模拟类应用的结果页不能只显示一个“完成”按钮。用户完成显微观察、DNA 提取、PCR 扩增或无菌操作后,真正关心的是三个问题:这次实验关键指标是什么,为什么得到这个结论,能不能带着这次反馈回去重新调整参数。结果页如果只做成静态总结卡片,就会把实验过程和下一轮操作断开。

细胞工坊ExperimentResultPage 做的是一个前端闭环:从路由读取完成时传入的结果快照,构建表格数据,绘制反馈曲线,调用反馈模型生成结论,支持系统分享,并通过返回按钮回到实验页重新操作。它没有在结果页重新运行实验,也没有把结果上传到服务器;所有展示都来自本地快照和页面内计算,这符合一个离线教学实验应用的边界。

封面:实验结果页工程闭环

本文会解决四个具体问题:

  • 如何用路由参数把实验完成时的关键指标传入结果页。
  • 如何把 successRateactivitycontamination 等最终值转成可读表格和曲线。
  • 如何让结果页结论、分享文本和表格表头使用同一套反馈模型。
  • 如何通过返回实验按钮形成“查看结果 -> 调整参数 -> 重新实验”的闭环。

流程图:实验完成到结果页操作闭环

结构图:实验结果页职责分层

一、结果页只消费完成快照,不重新计算实验来源

实际项目里,结果页最容易犯的错误是“再算一次”。模拟页已经根据实验参数和进度算出了成功率、活性、污染指数和温度,结果页如果又根据自己的逻辑重新计算,页面、分享内容和历史记录就可能出现不一致。

ExperimentResultPage 的入口参数很明确:

interface ResultRouterParams {
  expId?: string
  expName?: string
  category?: string
  progress?: number
  successRate?: number
  activity?: number
  contamination?: number
  temperature?: number
  stage?: string
  paramSummary?: string
}

这些字段来自上一页完成实验时构造的结果快照。结果页并不关心模拟页内部用了什么公式,也不反向读取实验页状态。它只需要知道这次实验是什么、完成到哪个阶段、关键指标是多少、参数摘要是什么。

这个设计能防止两类问题:

问题 如果结果页重算 当前快照方案
指标来源不一致 页面显示和分享文本可能不一致 所有展示读取同一组状态
回退后参数变化 结果页可能跟着旧组件状态变化 结果页持有完成时快照
新实验扩展 每页都要知道实验公式 结果页只处理通用展示

结果页不是实验引擎,它是完成结果的展示层。这个边界越清楚,后续扩展越稳。

二、aboutToAppear() 是快照落地的第一道边界

结果页在 aboutToAppear() 中读取路由参数,并把合法字段写入本页状态:

aboutToAppear(): void {
  const params = router.getParams() as ResultRouterParams | undefined
  if (params?.expId) this.expId = params.expId
  if (params?.expName) this.expName = params.expName
  if (params?.category) this.category = params.category
  if (typeof params?.progress === 'number') this.progress = params.progress
  if (typeof params?.successRate === 'number') this.successRate = params.successRate
  if (typeof params?.activity === 'number') this.activity = params.activity
  if (typeof params?.contamination === 'number') this.contamination = params.contamination
  if (typeof params?.temperature === 'number') this.temperature = params.temperature
  if (params?.stage) this.stage = params.stage
  if (params?.paramSummary) this.paramSummary = params.paramSummary
  this.tableData = this.buildTableData()
}

这里有两个细节值得保留。

第一,数值字段使用 typeof params?.xxx === 'number' 判断。这样可以避免字符串、空值或未传字段覆盖默认值。HarmonyOS 页面路由在实际项目中经常会遇到参数缺失,结果页不应该因为某个字段缺失就直接白屏。

第二,状态落地后立即调用 buildTableData()。这意味着表格和曲线基于同一组页面状态生成,而不是等到渲染时临时拼凑。后续如果要增加加载态或异常态,也能围绕这一处做守卫。

三、关键指标用页面状态保存,便于表格、曲线和分享复用

源码中结果页为关键指标提供了默认值:

@State expId: string = 'microscope_observation'
@State expName: string = '显微镜观察洋葱表皮'
@State category: string = '基础'
@State progress: number = 100
@State successRate: number = 96
@State activity: number = 94
@State contamination: number = 20
@State temperature: number = 37
@State stage: string = '实验完成'
@State paramSummary: string = '默认实验条件'

这些字段不是随便展示的文案,而是结果页的核心数据源:

字段 页面用途
expId 选择表头、结论模板和曲线形状
expName 页面标题、图表标题、分享标题
category 分享文本中的实验分类
progress 表格进度和结论进度
successRate 最终评分、结论判断
activity 曲线第一指标和实验表现
contamination 曲线第二指标和风险判断
temperature 结论和分享中的环境/实验条件
paramSummary 解释这次结果对应的参数组合

结果页的核心不是卡片数量,而是这些字段能否被一致地复用。页面标题、表格、曲线、结论和分享文本都读取这些状态,用户看到的结果就不会分裂。

四、表格数据不是直接写死,而是根据最终值生成过程点

结果页默认表格有 6 行,从 0% 到 100%:

@State tableData: number[][] = [
  [0, 62, 8, 74],
  [20, 70, 10, 80],
  [40, 78, 12, 86],
  [60, 84, 15, 89],
  [80, 90, 18, 93],
  [100, 94, 20, 96]
]

但进入页面后,tableData 会被 buildTableData() 重新构建。这个函数读取最终 activitycontaminationsuccessRate,按进度点生成过程数据:

private buildTableData(): number[][] {
  const result: number[][] = []
  const a = this.activity
  const c = this.contamination
  const s = this.successRate
  for (let i = 0; i <= 5; i++) {
    const progress = i * 20
    const t = progress / 100
    let v1 = a * (0.58 + t * 0.42)
    let v2 = c * (0.36 + t * 0.64)
    let v3 = s * (0.74 + t * 0.26)
    result.push([
      progress,
      Math.round(this.clamp(v1, 0, 100)),
      Math.round(this.clamp(v2, 0, 100)),
      Math.round(this.clamp(v3, 0, 100))
    ])
  }
  return result
}

这段默认逻辑的意义是:即使某个实验没有专属曲线分支,结果页也能展示一条基本可读的指标走势。它不是科学实验真实采样点,而是教学模拟中的过程展示。

更重要的是,源码为不同 expId 加了曲线分支。显微观察、细菌培养、无菌操作、PCR、DNA 分层、细胞周期等实验有不同曲线形状,这让结果页不会把所有实验都画成同一种线。

五、曲线形状表达实验过程,不只是装饰图

结果页用 shape() 把 0-1 的进度映射成不同曲线:

private shape(kind: string, t: number): number {
  if (t <= 0) return 0
  if (t >= 1) return 1
  if (kind === 'linear') return t
  if (kind === 'sqrt') return Math.sqrt(t)
  if (kind === 'square') return t * t
  if (kind === 'cube') return t * t * t
  if (kind === 's') return 1 / (1 + Math.exp(-(t - 0.5) * 8))
  if (kind === 'peak') return Math.sin(t * Math.PI)
  if (kind === 'lateRise') return t < 0.3 ? t * 0.4 : 0.12 + (t - 0.3) / 0.7 * 0.88
  if (kind === 'platL') return 1 - Math.exp(-t * 3)
  if (kind === 'invSquare') return 1 - (1 - t) * (1 - t)
  return t
}

这段逻辑让结果页的曲线具备业务含义。例如:

曲线类型 代码含义 典型实验
s S 型增长 细菌培养
cube 后期快速上升 PCR 扩增、污染后期暴涨
peak 中段峰值 有丝分裂、细胞周期
lateRise 滞后后拉升 叶绿素层析
platL 快升后平台 发酵、DNA 分层离心
invSquare 收敛下降 混浊、误识别、杂光下降

结果页并没有保存真实过程采样数组,所以曲线不能被描述为真实实验数据。更准确的说法是:它根据最终指标和实验类型生成教学模拟曲线。这个边界必须说清楚,否则文章会夸大源码能力。

六、Canvas 曲线绘制读取同一份 tableData

反馈曲线用 Canvas 绘制,核心输入仍然是 tableData

private drawChart(): void {
  if (this.chartW === 0) return
  const ctx = this.chartCtx
  const w = this.chartW
  const h = this.chartH
  const padL = 46
  const padR = 20
  const padT = 34
  const padB = 38
  const plotW = w - padL - padR
  const plotH = h - padT - padB

  ctx.clearRect(0, 0, w, h)
  ctx.fillStyle = '#111827'
  ctx.fillRect(0, 0, w, h)
}

随后它绘制坐标轴、横向网格、折线和节点:

ctx.strokeStyle = AppColors.ACCENT_GREEN
ctx.lineWidth = 3
ctx.beginPath()
for (let i = 0; i < this.tableData.length; i++) {
  const row = this.tableData[i]
  const x = padL + (row[0] / 100) * plotW
  const y = padT + plotH - (row[1] / 100) * plotH
  if (i === 0) ctx.moveTo(x, y)
  else ctx.lineTo(x, y)
}
ctx.stroke()

当前源码只绘制了 row[1] 这一条主曲线,也就是第一业务指标。表格里仍然保留 row[2]row[3],用于展示风险指标和最终评分。

这有一个现实取舍:单条曲线更清晰,不会在手机屏幕上把三条线挤在一起;但如果后续要做专业数据分析,可以增加图例,并把 row[2]row[3] 也画出来。

七、数据表格和反馈曲线用 Tab 分流,减少一屏信息压力

结果页通过 activeTab 在“数据表格”和“反馈曲线”之间切换:

@State activeTab: number = 1

UI 里两个文本按钮分别设置 activeTab

Text('数据表格')
  .fontColor(this.activeTab === 0 ? '#07111F' : AppColors.PRIMARY)
  .backgroundColor(this.activeTab === 0 ? AppColors.ACCENT_GREEN : AppColors.CARD_BG)
  .onClick(() => { this.activeTab = 0 })

Text('反馈曲线')
  .fontColor(this.activeTab === 1 ? '#07111F' : AppColors.PRIMARY)
  .backgroundColor(this.activeTab === 1 ? AppColors.ACCENT_GREEN : AppColors.CARD_BG)
  .onClick(() => { this.activeTab = 1 })

这是一个适合移动端的折中。表格适合复查具体数字,曲线适合观察趋势。如果强行把表格、曲线、结论、分享、返回按钮都塞在同一个首屏,结果页会变成信息堆叠。

对 HarmonyOS 多设备适配来说,后续可以在平板或 2in1 上把表格和曲线并排显示,但手机端保持 Tab 更稳。

八、表头来自反馈模型,避免结果页写死列名

结果页没有固定写“进度、活性、污染、评分”,而是调用反馈模型:

private currentSchema(): FeedbackSchema {
  return getFeedbackSchema(this.expId)
}

private formatCell(value: number, unit: string): string {
  return `${value.toFixed(0)}${unit}`
}

表格头部渲染:

ForEach(this.currentSchema().headers, (h: string) => {
  Text(h)
    .fontSize(12)
    .fontWeight(AppFonts.WEIGHT_MEDIUM)
    .fontColor(AppColors.TEXT_SECONDARY)
    .layoutWeight(1)
    .textAlign(TextAlign.Center)
})

这让结果页可以复用到不同实验。显微观察显示“视野、清晰度、杂光、识别率”,DNA 分层显示“转速段、分层度、混浊、纯度”,PCR 显示“循环、扩增量、非特异、纯度”。

如果新实验接入后表头不对,问题通常不在结果页,而在 ExperimentFeedback.ets 没有为该 expId 配置 schema。这就是职责分离的价值。

九、实验结论复用反馈模型,分享文本也复用同一结论

结果页生成结论时,会构造 ConclusionContext

private getConclusion(): string {
  const ctx: ConclusionContext = {
    expName: this.expName,
    stage: this.stage,
    paramSummary: this.paramSummary,
    progress: this.progress,
    successRate: this.successRate,
    activity: this.activity,
    contamination: this.contamination,
    temperature: this.temperature
  }
  return getConclusion(this.expId, ctx)
}

分享文本也调用同一个 getConclusion()

private buildShareText(): string {
  return [
    `细胞工坊 - ${this.expName}`,
    `实验分类:${this.category}`,
    `实验参数:${this.paramSummary}`,
    `流程进度:${this.progress}%`,
    `成功率:${this.successRate}%`,
    `样本活性:${this.activity}%`,
    `污染指数:${this.contamination}%`,
    `温度:${this.temperature}℃`,
    `结果分析:${this.getConclusion()}`
  ].join('\n')
}

这点很重要。页面结论、分享描述和分享正文使用同一套结论来源,用户不会看到“页面提示污染偏高,分享文本却说实验稳定”的冲突。

结果类页面经常被用户截图、分享或复制,因此所有出口都应复用同一份结果解释。

十、系统分享失败必须有用户提示和日志

结果页使用 HarmonyOS ShareKit

const record: systemShare.SharedRecord = {
  utd: uniformTypeDescriptor.UniformDataType.PLAIN_TEXT,
  title: `${this.expName}结果分析`,
  description: this.getConclusion(),
  content: this.buildShareText()
}
const data = new systemShare.SharedData(record)
const controller = new systemShare.ShareController(data)

如果宿主上下文为空,或系统分享面板失败,页面不会静默吞掉错误:

private showShareFailureToast(): void {
  try {
    this.getUIContext().getPromptAction().showToast({ message: '系统分享暂不可用,请稍后再试' })
  } catch (err) {
    const message = err instanceof Error ? err.message : JSON.stringify(err)
    hilog.error(LOG_DOMAIN, LOG_TAG, 'Show share failure toast failed: %{public}s', message)
  }
}

这符合结果页的用户体验要求:分享不是核心实验流程,但失败时也要让用户知道发生了什么。日志使用 hilog.error,不会把错误直接暴露给用户。

十一、重新实验靠返回链路,不在结果页复制实验逻辑

结果页底部有两个按钮:

Button('保存记录')
  .fontSize(14)
  .fontColor(AppColors.PRIMARY)
  .backgroundColor(AppColors.CARD_BG)

Button('返回实验')
  .fontSize(14)
  .fontColor('#07111F')
  .backgroundColor(AppColors.ACCENT_GREEN)
  .onClick(() => {
    router.back()
  })

返回实验 的实现非常克制:只调用 router.back()。它不在结果页重新创建实验,不复制参数调整 UI,也不试图重置模拟页。结果页的职责是告诉用户本次结果;如果用户要重新实验,就回到实验页重新操作。

这能避免两类重复:

  • 结果页不需要知道实验页的按钮、滑块、Canvas 和计时器状态。
  • 实验页仍然是唯一的参数调整和运行入口。

需要注意的是,源码里的 保存记录 按钮目前只有样式,没有绑定保存动作。不能在文章中声称它已经保存结果;当前真实可复核的是“按钮存在”,保存记录逻辑实际在模拟页完成时已经通过 persistRecord() 触发。

十二、结果页适配与可读性的检查点

结果页由标题栏、Tab、Canvas 或 List、结论卡片、底部按钮组成。上线前至少检查这些点:

检查项 操作 期望结果
参数缺失 直接进入结果页或缺字段路由 使用默认值,不白屏
表格 Tab 点击数据表格 四列表头与当前实验语义一致
曲线 Tab 点击反馈曲线 Canvas 成功绘制主指标折线
长实验名称 使用较长 expName 标题居中且不遮挡分享按钮
长结论 污染偏高或 PCR 非特异场景 结论卡片文字可读,不压底部按钮
分享失败 模拟 host context 为空 toast 显示“系统分享暂不可用,请稍后再试”
返回实验 点击返回实验 返回上一页,不在结果页重建实验

如果只做一次快速验证,优先覆盖“完成实验 -> 查看结果 -> 切换表格/曲线 -> 分享 -> 返回实验”。这条路径覆盖了结果页的主要职责。

十三、常见问题与修复建议

现象 首查位置 原因 修复
结果页指标是默认值 路由传参 模拟页没有传入对应字段 检查 buildResultParams()ResultRouterParams 字段一致性
表格列名不符合实验 getFeedbackSchema() 新实验没有专属 schema expId 配置表头和单位
曲线没有显示 chartWonReady() Canvas 未 ready 或尺寸为 0 保留 if (this.chartW === 0) return,检查容器高度
分享点击没反应 shareResult() host context 为空或系统分享失败 保留 toast 和 hilog.error
返回后状态异常 路由栈 结果页不是从实验页进入 确认入口路径,必要时提供显式重开实验路由
保存记录按钮无效果 当前按钮未绑定 源码只有 UI 样式 不在结果页声明已保存;如要支持,补具体事件

排查时先确认结果快照。快照正确,表格和结论才有意义;快照错误,页面样式再好也只是展示了错误结果。

总结

细胞工坊 的实验结果页实现了一条清晰的 HarmonyOS ArkTS 前端链路:从路由读取完成快照,把关键指标落到状态,生成表格数据,使用 Canvas 绘制反馈曲线,通过反馈模型输出结论,支持 ShareKit 分享,并通过 router.back() 返回实验页重新操作。

这套实现的重点不是复杂,而是边界清楚。结果页不重新运行实验,不夸大数据来源,不把“保存记录”按钮说成已经实现的业务动作。它只负责把已经完成的实验结果展示清楚,并提供下一轮操作入口。对教学实验类 HarmonyOS 应用来说,这比堆一个静态总结页更可靠,也更容易维护。

Logo

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

更多推荐