【细胞工坊|05】HarmonyOS ArkTS 实验结果页实战:展示关键指标并支持重新实验
部分内容由AI辅助生成。本文面向 HarmonyOS 5.0 及以上版本,基于
细胞工坊项目真实源码展开,源码根目录为D:\huawei\one14-9。本文重点复核entry/src/main/ets/views/experiment/ExperimentResultPage.ets,并结合ExperimentFeedback.ets的表头和结论模型,不虚构源码里不存在的数据上报、云端分析或 AI 诊断能力。
实验模拟类应用的结果页不能只显示一个“完成”按钮。用户完成显微观察、DNA 提取、PCR 扩增或无菌操作后,真正关心的是三个问题:这次实验关键指标是什么,为什么得到这个结论,能不能带着这次反馈回去重新调整参数。结果页如果只做成静态总结卡片,就会把实验过程和下一轮操作断开。
细胞工坊的ExperimentResultPage做的是一个前端闭环:从路由读取完成时传入的结果快照,构建表格数据,绘制反馈曲线,调用反馈模型生成结论,支持系统分享,并通过返回按钮回到实验页重新操作。它没有在结果页重新运行实验,也没有把结果上传到服务器;所有展示都来自本地快照和页面内计算,这符合一个离线教学实验应用的边界。
本文会解决四个具体问题:
- 如何用路由参数把实验完成时的关键指标传入结果页。
- 如何把
successRate、activity、contamination等最终值转成可读表格和曲线。 - 如何让结果页结论、分享文本和表格表头使用同一套反馈模型。
- 如何通过返回实验按钮形成“查看结果 -> 调整参数 -> 重新实验”的闭环。
一、结果页只消费完成快照,不重新计算实验来源
实际项目里,结果页最容易犯的错误是“再算一次”。模拟页已经根据实验参数和进度算出了成功率、活性、污染指数和温度,结果页如果又根据自己的逻辑重新计算,页面、分享内容和历史记录就可能出现不一致。
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()重新构建。这个函数读取最终activity、contamination、successRate,按进度点生成过程数据:
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 = 1UI 里两个文本按钮分别设置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') }这点很重要。页面结论、分享描述和分享正文使用同一套结论来源,用户不会看到“页面提示污染偏高,分享文本却说实验稳定”的冲突。
结果类页面经常被用户截图、分享或复制,因此所有出口都应复用同一份结果解释。
十、系统分享失败必须有用户提示和日志
结果页使用 HarmonyOSShareKit:
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配置表头和单位 |
| 曲线没有显示 | chartW、onReady() | Canvas 未 ready 或尺寸为 0 | 保留if (this.chartW === 0) return,检查容器高度 |
| 分享点击没反应 | shareResult() | host context 为空或系统分享失败 | 保留 toast 和hilog.error |
| 返回后状态异常 | 路由栈 | 结果页不是从实验页进入 | 确认入口路径,必要时提供显式重开实验路由 |
| 保存记录按钮无效果 | 当前按钮未绑定 | 源码只有 UI 样式 | 不在结果页声明已保存;如要支持,补具体事件 |
排查时先确认结果快照。快照正确,表格和结论才有意义;快照错误,页面样式再好也只是展示了错误结果。
总结
细胞工坊的实验结果页实现了一条清晰的 HarmonyOS ArkTS 前端链路:从路由读取完成快照,把关键指标落到状态,生成表格数据,使用 Canvas 绘制反馈曲线,通过反馈模型输出结论,支持 ShareKit 分享,并通过router.back()返回实验页重新操作。
这套实现的重点不是复杂,而是边界清楚。结果页不重新运行实验,不夸大数据来源,不把“保存记录”按钮说成已经实现的业务动作。它只负责把已经完成的实验结果展示清楚,并提供下一轮操作入口。对教学实验类 HarmonyOS 应用来说,这比堆一个静态总结页更可靠,也更容易维护。
