实验报告页看起来很像普通详情页:标题、目的、步骤、现象、结论和数据摘要依次排开,再提供一个分享按钮。但如果报告内容只是进入页面时临时拼出来的一段文字,它就很难回答三个关键问题:这份报告对应哪一次实验?结论引用的是哪组数据?以后重新打开时,能否得到相同结果?

本文基于 化学实验室 1.0.5 的真实 ArkTS 源码,拆解 ExperimentReport.ets 与 ExperimentReportPage.ets。现有实现已经完成模型、构建函数、页面区块和系统分享的最小闭环,但静态复核也发现四个需要优先处理的工程问题:

本文唯一复核标记:CHEM-LAB-ONE12-REPORT-TRACEABLE-20260729

  • 报告生成函数直接读取最后一行的 [1]、[2] 并调用 toFixed(),没有验证行宽和有限数。
  • createdAt 表示“打开报告页的时间”,并不一定是实验完成时间,时间语义混在一起。
  • 报告步骤是所有实验共用的固定四步,无法证明用户实际执行过哪些过程。
  • 数据摘要固定写“末次 pH”,如果结果载荷来自其他实验类型,报告字段和指标会失配。

本文的目标不是把报告写得更长,而是建立一条可以追溯的转换链:实验完成产生不可变结果快照,Report Builder 从快照派生结构化报告,页面和分享只负责不同媒介的展示。

实验报告结构化生成封面

一、工程范围与现有能力

模块文件当前职责
结果载荷ExperimentResultPayload.ets提供实验名称、参数、结论与表格数据
报告模型ExperimentReport.ets定义报告字段并生成报告对象
报告页面ExperimentReportPage.ets渲染区块、处理分享状态
分享工具ShareUtils调用系统分享面板
路由router.getParams()从结果页传递结果载荷
UIScroll + @Builder展示长报告并复用内容区块

现有报告模型如下:

export interface ExperimentReport {
  title: string
  purpose: string
  steps: string[]
  phenomenon: string
  conclusion: string
  dataSummary: string
  createdAt: string
}

它比直接在页面中拼字符串更好,因为页面可以围绕稳定字段渲染,分享函数也能复用同一对象。但这份模型只描述“报告里有什么文字”,还没有保存“这些文字从哪里来”。

二、报告链路应该从事实源开始

实验报告生成流程

当前链路是:结果页把 ExperimentResultPayload 通过路由传给报告页,报告页在 aboutToAppear() 中调用构建函数。

aboutToAppear(): void {
  const params = router.getParams() as ExperimentResultPayload | undefined
  if (params && params.title) {
    this.result = params
    this.report = buildExperimentReport(params)
  }
}

这段代码短小,但把三个动作挤在一起:接收外部输入、判断输入有效、生成报告。更清楚的职责划分应该是:

  1. 路由边界把参数规范化成可信结果快照。
  2. Report Builder 只接收可信快照,派生报告。
  3. 页面根据 loading/content/error 状态渲染。
  4. 分享适配器把同一报告转换为纯文本。

当报告出现错误时,就能快速判断是输入不合法、构建规则错误,还是页面展示问题,而不是在 aboutToAppear() 中猜测。

三、先修最后一行数据的运行时风险

现有构建函数取最后一行并直接格式化:

const rows = result.tableData.length
const last = rows > 0 ? result.tableData[rows - 1] : [0, 0, 0]

phenomenon: `最终反应进度约为${last[2].toFixed(2)}%。`,
dataSummary: `末次 pH 值为${last[1].toFixed(2)},反应进度为${last[2].toFixed(2)}%。`

空数组已有默认行兜底,但以下输入仍然会失败:

const rows: number[][] = [
  [0, 7, 0],
  [1, 5.5]
]

最后一行只有两列,last[2] 是 undefined,调用 toFixed() 会在报告生成阶段抛出异常。由于报告对象在 @State 初始化时也会构建,错误甚至可能阻断页面首次渲染。

最低成本的修复是集中解析末行:

interface LastDataSummary {
  phValue: number
  progressPercent: number
  valid: boolean
}

function readLastData(rows: number[][]): LastDataSummary {
  if (rows.length === 0) {
    return { phValue: 0, progressPercent: 0, valid: false }
  }
  const last = rows[rows.length - 1]
  if (last.length < 3 || !Number.isFinite(last[1]) || !Number.isFinite(last[2])) {
    return { phValue: 0, progressPercent: 0, valid: false }
  }
  return {
    phValue: last[1],
    progressPercent: last[2],
    valid: true
  }
}

更完整的方案是采用上一篇文章中的命名数据点 ExperimentDataPoint[]。报告层就不再接触二维数组,也不会重复解释列下标。

四、把“实验完成时间”和“报告生成时间”分开

当前 nowText() 在构建报告时读取系统时间:

export function buildExperimentReport(result: ExperimentResultPayload): ExperimentReport {
  return {
    // ...
    createdAt: nowText()
  }
}

如果用户上午完成实验,晚上才打开报告页,报告会显示晚上时间。字段名 createdAt 本身没有错,但页面可能把它理解为实验发生时间。

建议报告模型明确保存两个时间:

export interface ExperimentReportV2 {
  reportId: string
  schemaVersion: number
  experimentId: string
  experimentCompletedAt: string
  reportGeneratedAt: string
  title: string
  purpose: string
  steps: ExperimentStepRecord[]
  phenomenon: string
  conclusion: string
  dataSummary: ReportDataSummary
}

两者含义不同:

  • experimentCompletedAt 来自模拟引擎完成事件,是事实时间。
  • reportGeneratedAt 来自 Report Builder,是文档生成时间。

时间格式也不应该在模型底层过早变成人类可读字符串。更稳的做法是保存时间戳或标准格式,在 UI 层按地区显示;分享文本可以再调用格式化函数。

五、报告步骤不能永久写成固定模板

现有四步适合作为最小报告骨架:

steps: [
  '确认实验名称、实验分类和参数设置。',
  '启动模拟实验,观察关键实验现象。',
  '记录数据表格与曲线变化。',
  '根据实验现象和数据变化整理实验结论。'
]

问题是这些文字表达“建议怎么做”,不是“用户实际做了什么”。如果每份报告步骤都完全相同,报告无法支持复盘,也不能解释某次结果为何与另一次不同。

可以在实验过程中记录关键事件:

export interface ExperimentStepRecord {
  sequence: number
  action: string
  parameterSnapshot: string
  occurredAtMs: number
  resultNote: string
}

模拟页不需要记录每一帧,只记录业务节点:初始化参数、开始、暂停、恢复、关键阈值、完成。Report Builder 再把这些记录转换为可读步骤。

function buildStepText(step: ExperimentStepRecord): string {
  const parameterText = step.parameterSnapshot.length > 0
    ? `,参数:${step.parameterSnapshot}`
    : ''
  const resultText = step.resultNote.length > 0
    ? `;结果:${step.resultNote}`
    : ''
  return `${step.sequence}. ${step.action}${parameterText}${resultText}`
}

如果旧结果没有过程记录,可以回退到固定模板,但要标记 stepSource: 'template';新结果使用 stepSource: 'event_log'。这样读者知道报告中的步骤是实际记录还是通用说明。

六、数据摘要必须与实验指标一致

当前摘要固定包含 pH:

dataSummary: `本次实验共记录${rows}组数据,末次 pH 值为${last[1].toFixed(2)}...`

对于酸碱中和实验这很合理,但化学实验室可能还有溶解、气体生成、反应速率等场景。若统一结果载荷的第二列不再是 pH,报告仍会输出“末次 pH”,产生事实错误。

建议把指标定义写入结果载荷:

export interface ResultMetricDefinition {
  key: string
  label: string
  unit: string
  decimals: number
}

export interface ResultMetricValue {
  key: string
  value: number
}

酸碱实验可以定义:

const metrics: ResultMetricDefinition[] = [
  { key: 'ph', label: 'pH 值', unit: '', decimals: 2 },
  { key: 'progress', label: '反应进度', unit: '%', decimals: 2 }
]

Report Builder 根据定义生成摘要,而不是在通用模型里硬编码指标名。这样新增实验时,编写指标定义即可复用报告页面。

七、用结构化数据摘要代替提前拼好的字符串

现有 dataSummary: string 很容易渲染,但失去了进一步计算和多端展示能力。手机页面可能需要一句话,平板需要统计表,分享文本又需要换行格式。如果模型只保存最终字符串,每个端都只能照搬。

可以改成结构化摘要:

export interface ReportMetricSummary {
  label: string
  valueText: string
  source: string
}

export interface ReportDataSummary {
  sampleCount: number
  metrics: ReportMetricSummary[]
  warningText: string
}

页面用卡片或列表展示 metrics,纯文本格式化器把它们连接为句子,未来导出 PDF 时也能重用。模型保留事实,展示层决定排版。

八、构建函数应保持纯函数特征

buildExperimentReport() 当前大部分逻辑是纯的,但内部调用 nowText(),同一输入在不同时间会得到不同输出,测试需要处理时间依赖。

可以把时间作为显式参数:

export interface ReportBuildOptions {
  generatedAt: string
  reportId: string
}

export function buildExperimentReport(
  result: ValidatedExperimentResult,
  options: ReportBuildOptions
): ExperimentReportV2 {
  return {
    reportId: options.reportId,
    schemaVersion: 2,
    experimentId: result.expId,
    experimentCompletedAt: result.completedAt,
    reportGeneratedAt: options.generatedAt,
    title: `${result.title}实验报告`,
    purpose: buildPurpose(result),
    steps: buildSteps(result),
    phenomenon: buildPhenomenon(result),
    conclusion: result.conclusion,
    dataSummary: buildDataSummary(result)
  }
}

这样测试可以传入固定时间与 ID,得到稳定快照;页面或 Service 负责生成当前时间,而模型函数只负责转换。

九、报告页面只消费构建结果

实验报告模型结构

推荐的数据流是:

ExperimentResultSnapshot
  -> ResultPayloadNormalizer
  -> ExperimentReportBuilder
  -> ExperimentReportPage
  -> ReportTextFormatter / ShareUtils

页面状态可以收口成:

enum ReportPageState {
  LOADING = 'loading',
  CONTENT = 'content',
  ERROR = 'error'
}

@State pageState: ReportPageState = ReportPageState.LOADING
@State report: ExperimentReportV2 | undefined = undefined
@State errorMessage: string = ''

ArkTS 与当前 ArkUI 版本对联合类型和状态声明的具体约束需要以工程构建为准;如果项目不允许相应可空写法,可用默认模型加状态枚举实现同等效果。关键是页面不要在输入异常时静默展示默认报告。

十、ReportBlock 的复用方向是正确的

页面用两个 @Builder 渲染普通区块和步骤区块:

@Builder
ReportBlock(title: string, body: string) {
  Column() {
    Text(title)
    Text(body)
  }
}

这是值得保留的做法。它让目的、现象、结论和数据记录保持相同视觉规则,页面主体也容易阅读。

下一步可以补齐三种状态,而不是继续增加更多区块组件:

  • 内容过长时允许自然换行,不使用过小字号。
  • 结论为空时显示“本次实验暂未生成结论”,而不是空白卡片。
  • 有校验 warning 时增加语义明确的提示区,而不是和正常结论混排。

页面已经使用 Scroll(),这保证小屏幕和长内容可达;底部按钮还应结合系统导航避让区,确保在手势导航和横屏下仍可点击。

十一、分享必须使用同一报告对象

当前分享链路是正确方向:

await ShareUtils.shareText(
  context,
  this.report.title,
  formatExperimentReport(this.report)
)

页面展示与分享文本都来自 this.report,避免重复读取原始结果。但是 formatExperimentReport() 仍然手工拼接固定五段,未来模型字段变化时容易漏同步。

可以把区块先转换成统一数组:

export interface ReportSection {
  heading: string
  body: string
}

function reportSections(report: ExperimentReportV2): ReportSection[] {
  return [
    { heading: '实验目的', body: report.purpose },
    { heading: '实验步骤', body: formatSteps(report.steps) },
    { heading: '实验现象', body: report.phenomenon },
    { heading: '实验结论', body: report.conclusion },
    { heading: '数据记录', body: formatDataSummary(report.dataSummary) }
  ]
}

页面和纯文本格式化器都基于 ReportSection[],字段顺序与缺省处理集中在一个位置。

十二、分享状态应该用 finally 恢复

当前代码能防止重复点击:

if (this.isSharing) return
this.isSharing = true
try {
  await ShareUtils.shareText(...)
} catch (_) {
  this.showToast('分享报告暂不可用')
}
this.isSharing = false

建议把复位放入 finally:

private async shareReport(): Promise<void> {
  if (this.isSharing || !this.report) return
  this.isSharing = true
  try {
    const context = getContext(this) as common.UIAbilityContext
    const text = formatExperimentReport(this.report)
    await ShareUtils.shareText(context, this.report.title, text)
    this.showToast('已打开系统分享面板')
  } catch (error) {
    this.showToast('分享报告暂不可用')
  } finally {
    this.isSharing = false
  }
}

这样以后在 try 中增加条件分支,也不会让按钮永久停在禁用状态。

十三、报告要不要持久化

当前报告是运行时派生对象,离开页面后不会单独保存。这不一定是缺陷,取决于产品定义。

方案优点风险适用场景
每次从结果快照生成不重复存储,规则更新后可重建新规则可能改变旧报告文字报告只是结果视图
保存完整报告快照历史内容稳定,可审计需要版本迁移与空间管理报告是正式学习记录
保存结果与构建版本可重建又可追溯模型稍复杂推荐的折中方案

对学习类实验应用,可以保存结果快照、Report Builder 版本和报告生成时间。用户打开旧记录时,默认展示当时报告;如果规则升级,可提供“按新规则重新生成”,而不是静默覆盖。

十四、可复查报告需要哪些身份字段

最小身份集合可以是:

export interface ReportIdentity {
  reportId: string
  experimentRunId: string
  experimentId: string
  schemaVersion: number
  builderVersion: string
  generatedAt: number
}

这些字段不必全部显示给普通用户,但能帮助定位问题:

  • experimentRunId 关联一次具体运行。
  • schemaVersion 决定数据如何迁移。
  • builderVersion 说明报告由哪套规则生成。
  • generatedAt 用于排序和审计。

不要把设备标识、账号敏感信息或无关个人数据写进报告。离线应用只需保存完成业务所需的最小字段,并在隐私说明中保持一致。

十五、验证表:让报告内容可以被证明

编号场景输入预期结果
P-01正常酸碱实验7 行完整数据报告显示 7 组、正确末值与结论
P-02空数据[]明确提示无有效记录,不伪装正常报告
P-03末行少一列[1, 5.5]构建函数返回 warning,不调用 undefined.toFixed
P-04末行含 NaN[1, NaN, 50]拒绝异常指标或使用显式降级
P-05报告晚于实验打开两个不同时间分别显示完成时间和生成时间
P-06有过程日志pause/resume/complete步骤来自真实事件,不使用固定模板
P-07旧版结果无 schemaVersion迁移后生成并标记来源
P-08分享失败系统面板不可用Toast 可见,按钮恢复可用
P-09快速连续点击分享连续点击三次只发起一次分享调用
P-10长结论500 字结论Scroll 可达,文字不截断关键内容

本文基于源码静态复核,表中的改造后用例需要在代码合入后通过单元测试、构建和真机交互进一步验证,不能用“代码看起来正确”替代运行证据。

十六、建议的落地顺序

  1. 先为最后一行数据增加行宽和有限数校验,消除 toFixed() 运行时风险。
  2. 将分享状态复位移动到 finally,补充重复点击验证。
  3. 区分实验完成时间与报告生成时间。
  4. 引入结果快照 ID,让报告能关联具体运行。
  5. 将固定步骤升级为事件日志派生步骤,保留旧数据模板降级。
  6. 把固定 pH 文案升级为指标定义驱动。
  7. 决定报告是否持久化,并增加 schema 与 builder 版本。

每一步都能独立验收,不需要一次性改动所有实验页面。先解决崩溃和错误事实,再补追溯能力,最后扩展导出或跨设备同步。

十七、提交前检查清单

  • [ ] 路由参数经过结果载荷规范化后才进入报告构建。
  • [ ] 最后一行缺列、NaN、Infinity 不会触发 toFixed() 异常。
  • [ ] 报告区分实验完成时间和报告生成时间。
  • [ ] 步骤来源是事件记录还是模板,在模型中可识别。
  • [ ] 数据摘要由指标定义驱动,不固定假设第二列为 pH。
  • [ ] 报告保存事实字段,页面和分享分别格式化。
  • [ ] 报告对象具备 runId、schemaVersion 或等价追溯信息。
  • [ ] 分享失败后按钮一定恢复,重复点击不会发起多次调用。
  • [ ] 长文本、空结论、异常数据都有可见状态。
  • [ ] 持久化策略与隐私说明一致,不保存无关个人信息。

十八、源码事实与建议实现的边界

为了让文章可以被复核,下面把当前源码中已经存在的能力与本文提出的改造方案分开列出。读者可以直接检查 ExperimentReport.ets 和 ExperimentReportPage.ets,不需要相信无法验证的运行描述。

类别当前源码可直接确认本文建议但未声称已经合入
报告模型已有 ExperimentReport,包含目的、步骤、现象、结论、摘要和生成时间增加运行 ID、schemaVersion、builderVersion 与完成时间
构建入口buildExperimentReport() 从 ExperimentResultPayload 派生报告先规范化结果快照,再交给纯 Report Builder
末行数据空数组用 [0, 0, 0] 兜底;非空数组直接读取最后一行 [1]、[2]校验行宽、有限数和指标定义,返回 warning 或错误态
实验步骤当前固定生成四条通用步骤从真实过程事件派生,旧数据才使用模板降级
时间字段createdAt 在构建报告时调用 nowText()分离实验完成时间与报告生成时间
数据摘要当前固定输出末次 pH 与反应进度由指标元数据决定名称、单位、精度和聚合方式
页面结构已使用 Scroll、ReportBlock、StepBlock 与底部分享按钮增加 loading、error、fallback 等显式页面状态
分享行为isSharing 阻止重复点击,异常时显示 Toast用 finally 保证所有异常路径恢复按钮状态
持久化当前两个文件没有实现报告快照持久化根据产品需求设计本地快照、版本迁移和删除策略

本文没有声称建议代码已经完成工程构建、真机回归或应用市场验证。落地时应按第十六节逐步修改,每一步都重新执行编译、异常输入测试和页面交互验证。

总结

结构化实验报告的核心不是多几个标题,而是“每一段文字都有来源”。结果快照提供事实,Report Builder 负责派生,报告页面负责可读展示,分享适配器负责媒介转换。只要这四层边界清楚,报告才能在重新打开、版本升级、异常输入和不同设备上保持一致。

化学实验室现有实现已经有了清晰的模型与页面骨架。优先修复末行数组校验、时间语义和固定 pH 文案,再加入运行 ID 与过程事件,便能把一个即时展示页升级为真正可复查、可迁移、可验证的学习报告。

AI 辅助声明:本文由 AI 辅助完成源码梳理、结构校对与配图设计,技术结论均以文中列出的本地 ArkTS 源码为依据,并由作者复核后发布。

Logo

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

更多推荐