【化学实验室|02】HarmonyOS ArkTS 实验结果载荷实战:用强类型模型隔离页面参数与结果展示

一个实验页面把曲线画出来,并不等于结果链路可靠。真正容易出错的地方,往往发生在"模拟结束"到"结果页展示"之间:上一个页面通过 router 传来一个普通对象,结果页直接把它断言成业务类型;表格把每行数据写成 number[],但没有人保证数组一定有三列;Canvas 又根据最后一行数据推导坐标范围,恰好遇到下降曲线时,前面的点就可能被画到可视区域之外。

本文基于 化学实验室 1.0.5 的真实 ArkTS 源码,复核 ExperimentResultPayload.ets 与 ExperimentResultPage.ets。文章不会只给一个"把 any 换成 interface"的表面答案,而是把路由输入、运行时校验、页面状态、表格适配、Canvas 坐标和报告跳转串成一条可验证的结果契约。

本文唯一复核标记:CHEM-LAB-ONE12-RESULT-PAYLOAD-20260729

源码复核发现两个核心风险:

  • tableData: number[][] 只能约束"它是二维数字数组",无法表达每一列分别是时间、pH 和反应进度。
  • drawChart() 使用最后一行的第二列作为纵轴最大值。默认数据的 pH 从 7.00 下降到 0.50,最后值反而最小,前面的 pH 点会超出按 1.2 计算出的纵轴范围。

解决目标很明确:路由参数先当作不可信输入,只有经过规范化的强类型载荷才能进入页面状态;表格和曲线只消费同一组数据点,不再各自猜测列含义。

一、本文对应的工程环境

项目 实际值 本文关注点
应用 化学实验室 离线实验模拟、结果、报告与分享
应用版本 1.0.5 / versionCode 1000005 AppScope/app.json5 当前声明
HarmonyOS SDK 6.0.2(22) ArkTS / ArkUI Stage Model
结果模型 ExperimentResultPayload.ets 路由与页面共同使用的载荷
结果页面 ExperimentResultPage.ets 表格、Canvas、保存、分享与报告
输入来源 router.getParams() 运行时对象,不能只靠类型断言
数据形态 number[][] 列含义不明确、缺少行级校验

本文是对现有源码的静态复核与改造设计,没有把示例代码冒充为已经合入工程的构建结果。文末会把"源码中已存在的行为"和"建议改造后的验证项"分开列出,便于读者复查。

二、先画清楚结果载荷的完整生命周期

结果对象并不只服务于一个页面。当前链路至少经过六个节点:模拟页生成数据、路由传参、结果页接收、表格或曲线展示、生成报告、分享文本。任何一个节点对字段理解不同,都会产生"页面没有崩溃,但结果错了"的隐性问题。

现有结果模型很简洁:

ts 复制代码
export interface ExperimentResultPayload {
  expId: string
  title: string
  category: string
  paramSummary: string
  conclusion: string
  tableData: number[][]
}

这份接口解决了字段命名统一,但还没有解决两个运行时问题。

第一,TypeScript/ArkTS 接口不会自动校验 router.getParams()。下面的断言只告诉编译器"请相信我",不会检查真实对象:

ts 复制代码
const params = router.getParams() as ExperimentResultPayload | undefined

第二,number[][] 没有列契约。[0.5, 5.8, 12] 表示时间、pH、进度,[5.8, 0.5, 12] 在类型系统里同样合法。代码能编译,曲线也能画,但语义已经交换。

因此结果链路需要一个明确规则:类型负责表达结构,Normalizer 负责运行时防线,页面只保存规范化后的值。

三、二维数组为什么是结果模型中的薄弱点

当前页面在三个位置直接使用数组下标:

ts 复制代码
const t = this.tableData[i][0]
const v = this.tableData[i][1]

Text(row[0].toFixed(2))
Text(row[1].toFixed(2))
Text(row[2].toFixed(2))

这意味着"0 是时间、1 是 pH、2 是反应进度"只存在于开发者记忆里。只要上游少传一列,row[2] 就会变成 undefined,随后调用 toFixed() 产生运行时异常;如果上游把列顺序传错,页面甚至不会报错。

更适合这个场景的是命名数据点:

ts 复制代码
export interface ExperimentDataPoint {
  timeMinute: number
  phValue: number
  progressPercent: number
}

export interface ExperimentResultPayloadV2 {
  schemaVersion: number
  expId: string
  title: string
  category: string
  paramSummary: string
  conclusion: string
  points: ExperimentDataPoint[]
}

增加 schemaVersion 不是为了"架构看起来高级",而是给未来迁移留入口。假设后续要加入温度、颜色或电导率,旧结果可以按版本转换,而不是让页面猜当前数组到底有几列。

命名字段还带来三个直接收益:

  • 表格读取 point.phValue,列含义在代码里可见。
  • Canvas 读取同一对象,不再复制下标规则。
  • 测试可以构造缺少字段或越界值,验证规范化策略。

四、路由参数必须从 unknown 开始收敛

结果页当前只检查 title 和非空 tableData:

ts 复制代码
aboutToAppear(): void {
  const params = router.getParams() as ExperimentResultPayload | undefined
  if (params && params.title && params.tableData && params.tableData.length > 0) {
    this.result = params
    this.tableData = params.tableData
  }
}

这段代码无法阻止以下输入进入页面:

ts 复制代码
const malformed = {
  title: '酸碱中和',
  tableData: [[0, 7], [1, Number.NaN, 50]]
}

它有标题、也有非空数组,因此会通过现有判断;但第一行缺少进度,第二行包含 NaN,表格和曲线都会得到不可预测结果。

边界层应该先把参数视为 unknown,再逐层判断。为了保持 ArkTS 严格类型,可以使用窄接口与显式函数,而不是在页面里散落可选链:

ts 复制代码
interface RawResultPayload {
  schemaVersion?: number
  expId?: string
  title?: string
  category?: string
  paramSummary?: string
  conclusion?: string
  tableData?: number[][]
}

function isFiniteNumber(value: number): boolean {
  return Number.isFinite(value)
}

function isLegacyRow(row: number[]): boolean {
  return row.length >= 3
    && isFiniteNumber(row[0])
    && isFiniteNumber(row[1])
    && isFiniteNumber(row[2])
}

在 ArkTS 工程中,router.getParams() 的具体静态类型要以当前 SDK 定义为准。重点不是执着于写出某个复杂通用类型守卫,而是把"外部对象未经校验不能写入 @State"设为边界原则。

五、用 Normalizer 同时承担校验与旧数据迁移

如果直接把现有模型改成 points,模拟页、结果页、报告页和分享工具都要同步调整,风险较大。更稳的方式是先增加一个规范化函数:它接收旧二维数组,输出命名数据点,并对范围做统一处理。

ts 复制代码
export function normalizeLegacyRows(rows: number[][]): ExperimentDataPoint[] {
  const result: ExperimentDataPoint[] = []
  for (let index = 0; index < rows.length; index++) {
    const row = rows[index]
    if (!isLegacyRow(row)) {
      continue
    }
    result.push({
      timeMinute: Math.max(0, row[0]),
      phValue: Math.max(0, Math.min(14, row[1])),
      progressPercent: Math.max(0, Math.min(100, row[2]))
    })
  }
  return result
}

这里的处理策略必须写清楚,不能偷偷修数据:

  • 少于三列、包含 NaN 或无穷值的行直接丢弃。
  • 时间不允许小于 0。
  • pH 限制在常用展示范围 0..14。
  • 反应进度限制在 0..100。

如果所有行都被过滤,就不应该继续展示一张"看似正常"的默认曲线,而应进入明确错误或降级状态:

ts 复制代码
export interface NormalizeResult {
  payload: ExperimentResultPayloadV2
  usedFallback: boolean
  warnings: string[]
}

usedFallback 让页面能够显示"结果参数异常,已展示示例数据",而不是把示例数据伪装成用户刚完成的实验结果。

六、默认结果只能做显式降级,不能掩盖上游错误

项目中的 defaultExperimentResultPayload() 很有价值:它保证结果页在无参数预览、设计调试或异常恢复时仍能打开。但默认值如果静默生效,会带来业务误导。

当前页面初始化时已经写入默认结果:

ts 复制代码
@State result: ExperimentResultPayload = defaultExperimentResultPayload()
@State tableData: number[][] = defaultExperimentResultPayload().tableData

若路由参数不合法,页面什么也不提示,用户看到的仍是"酸碱中和反应"。更稳的页面状态应区分四类:

ts 复制代码
export enum ResultLoadState {
  LOADING = 'loading',
  CONTENT = 'content',
  FALLBACK = 'fallback',
  ERROR = 'error'
}

页面加载流程可以写成:

ts 复制代码
private loadResult(): void {
  const raw = router.getParams()
  const normalized = ResultPayloadNormalizer.normalize(raw)
  this.result = normalized.payload
  this.points = normalized.payload.points
  this.loadState = normalized.usedFallback
    ? ResultLoadState.FALLBACK
    : ResultLoadState.CONTENT
  this.warnings = normalized.warnings
}

当进入 FALLBACK 时,顶部显示一条可关闭但不会自动消失的提示;当进入 ERROR 时,提供返回实验页和重试入口。这样默认数据仍然发挥兜底作用,又不会冒充真实实验。

七、修复曲线纵轴范围取值错误

这是本次源码复核中最值得优先修复的逻辑问题。当前实现如下:

ts 复制代码
const maxT = this.tableData[this.tableData.length - 1][0]
const maxV = this.tableData[this.tableData.length - 1][1]
const tRange = Math.max(maxT, 1) * 1.1
const vRange = Math.max(maxV, 1) * 1.2

默认数据的第一行 pH 是 7.00,最后一行 pH 是 0.50。由此计算得到 vRange = 1.2,第一行的纵坐标是:

text 复制代码
y = padT + plotH - (7.0 / 1.2) * plotH

这个值会远小于 padT,也就是被画到绘图区上方。问题根源不是 Canvas,而是把"最后一个值"误当成"最大值"。

正确做法是扫描全量数据,并同时处理空集、相同值和非有限数:

ts 复制代码
interface ChartRange {
  maxTime: number
  maxValue: number
}

private resolveChartRange(points: ExperimentDataPoint[]): ChartRange {
  let maxTime = 1
  let maxValue = 1
  for (let index = 0; index < points.length; index++) {
    const point = points[index]
    if (point.timeMinute > maxTime) {
      maxTime = point.timeMinute
    }
    if (point.phValue > maxValue) {
      maxValue = point.phValue
    }
  }
  return {
    maxTime: maxTime * 1.1,
    maxValue: maxValue * 1.2
  }
}

对 pH 场景还可以固定纵轴为 0..14,这样不同实验之间具有可比较性;如果同一个结果页未来也展示温度或电导率,就应根据指标类型选择范围策略,而不是统一取第二列。

八、表格与 Canvas 必须消费同一份数据点

当模型改成 ExperimentDataPoint[] 后,表格不再依赖下标:

ts 复制代码
ForEach(this.points, (point: ExperimentDataPoint) => {
  ListItem() {
    Row() {
      Text(point.timeMinute.toFixed(2))
        .layoutWeight(1)
        .textAlign(TextAlign.Center)
      Text(point.phValue.toFixed(2))
        .layoutWeight(1)
        .textAlign(TextAlign.Center)
      Text(point.progressPercent.toFixed(2))
        .layoutWeight(1)
        .textAlign(TextAlign.Center)
    }
  }
})

Canvas 使用同一个 point:

ts 复制代码
for (let index = 0; index < this.points.length; index++) {
  const point = this.points[index]
  const x = padL + (point.timeMinute / range.maxTime) * plotW
  const y = padT + plotH - (point.phValue / range.maxValue) * plotH
  if (index === 0) {
    ctx.moveTo(x, y)
  } else {
    ctx.lineTo(x, y)
  }
}

这不是单纯把 [0] 改成字段名。真正的收益是"表格、曲线、分享、报告"围绕一个领域对象工作。当字段含义变化时,编译错误会把所有受影响位置暴露出来,而不是让错误数据继续运行。

九、页面切换与 Canvas 重绘要有明确触发点

当前页面默认 activeTab = 1,Canvas 在 onReady() 中记录宽高并绘制。如果用户先看数据表格,再切回曲线,组件可能重新构建;但数据变化、窗口尺寸变化或主题变化时,仅依赖一次 onReady() 不够清晰。

可以把切换动作收口到方法:

ts 复制代码
private switchTab(nextTab: number): void {
  if (this.activeTab === nextTab) return
  this.activeTab = nextTab
  if (nextTab === 1) {
    setTimeout(() => {
      this.drawChart()
    }, 0)
  }
}

不过是否允许在当前 ArkTS 版本直接采用该调度方式,应以项目 SDK 和实际构建结果为准。更重要的是定义重绘条件:

  • Canvas 首次 ready 后绘制。
  • points 更新后绘制。
  • 从表格切回曲线后绘制。
  • 窗口尺寸变化后重新计算 chartW 与 chartH。
  • 深浅色切换后使用新主题颜色重绘。

把这些条件写入测试清单,比在多个点击回调里重复调用 drawChart() 更容易维护。

十、保存、报告与分享要保留结果身份

当前 saveResult() 调用 buildRecord() 时只传入实验 ID、标题、分类和参数摘要:

ts 复制代码
const record: StoredExperimentRecord = buildRecord(
  this.result.expId,
  this.result.title,
  this.result.category,
  this.result.paramSummary
)

这适合作为"做过一次实验"的轻量记录,但它不等价于保存完整结果。如果产品文案写"保存数据",用户可能合理期待以后能恢复曲线和结论。

因此需要先明确两类数据:

类型 目的 建议字段
实验历史摘要 列表、统计、最近学习 expId、title、category、paramSummary、createdAt
实验结果快照 恢复表格、曲线与报告 schemaVersion、points、conclusion、参数、createdAt

如果当前只保存摘要,按钮名称可以改成"保存到实验记录";如果要支持恢复,就需要增加结果快照模型、持久化版本和迁移策略。

报告跳转当前直接复用 this.result:

ts 复制代码
router.pushUrl({
  url: 'views/experiment/ExperimentReportPage',
  params: this.result
})

这正是统一载荷的价值:结果页与报告页不需要重新拼装字段。但前提是 this.result 已经经过规范化,而不是未经校验的原始路由对象。

十一、强类型边界的推荐目录结构

不必为了一个模型引入庞大框架,保持三层边界就够了:

text 复制代码
model/
  ExperimentResultPayload.ets   # 领域类型、schemaVersion
  ExperimentDataPoint.ets       # 命名数据点

service/
  ResultPayloadNormalizer.ets   # 路由输入校验、旧格式迁移、告警

views/experiment/
  ExperimentResultPage.ets      # 页面状态、表格和 Canvas
  ExperimentReportPage.ets      # 报告展示

依赖方向是 Page -> Normalizer -> Model。模型不依赖页面,Normalizer 不负责绘制,页面不再解析数组列。这个结构足以支持单元测试,也不会把项目改造成抽象层过多的"架构样板"。

十二、按边界用例验证,不只验证正常数据

建议把验证分为载荷、表格、曲线和交互四组:

编号 输入或操作 预期结果 主要风险
R-01 合法 7 行酸碱数据 表格 7 行,曲线全部在绘图区 基本路径
R-02 第一行 pH 7,最后一行 pH 0.5 纵轴按全量最大值或 14 计算 当前可复现缺陷
R-03 某行只有两列 过滤该行并记录 warning toFixed 异常
R-04 某行包含 NaN 不进入页面数据点 Canvas 非有限坐标
R-05 pH 为 18 规范化到 14 或拒绝载荷 数值越界
R-06 进度为 -5 规范化到 0 或拒绝载荷 业务范围
R-07 路由参数为空 显示明确降级提示 默认数据冒充结果
R-08 快速重复点击保存 只执行一次持久化 重复记录
R-09 分享面板不可用 Toast 提示且按钮恢复 异常状态
R-10 表格切换曲线 Canvas 使用同一 points 重绘 状态不同步

当前源码已经通过 isSaving 与 isSharing 防止重复操作,这是值得保留的实现:

ts 复制代码
if (this.isSaving) return
this.isSaving = true
try {
  // save
} catch (_) {
  // visible failure state
}
this.isSaving = false

进一步可用 finally 保证状态恢复,避免未来在 try 中增加提前返回后漏掉复位:

ts 复制代码
this.isSaving = true
try {
  await saveSnapshot(this.result)
} catch (error) {
  this.showToast('保存失败,请重试')
} finally {
  this.isSaving = false
}

十三、改造顺序与回滚边界

为了控制影响范围,建议按以下顺序实施:

  1. 先修 drawChart() 的最大值扫描,这是低风险且可独立验证的真实缺陷。
  2. 增加 ExperimentDataPoint 与旧数组转换函数,暂时保留原 tableData 字段。
  3. 让结果页内部统一使用 points,表格和 Canvas 不再访问数组下标。
  4. 增加路由参数规范化与 FALLBACK 提示。
  5. 再让模拟页直接生成 V2 载荷。
  6. 最后迁移报告、分享与结果快照存储。

每一步都可以保留旧入口,出现问题时回到上一阶段,不需要一次性修改所有页面。迁移完成后再删除 tableData,避免长期维护双数据源。

十四、提交前复查清单

  • router.getParams() 的结果没有未经校验直接写入 @State。
  • 每个数据点都有命名字段,不再依赖 [0]、[1]、[2]。
  • 时间、pH、进度都检查有限值和业务范围。
  • 曲线范围扫描全量数据,不使用最后一行冒充最大值。
  • 默认数据出现时有明确降级提示。
  • 表格、Canvas、报告和分享消费同一份规范化载荷。
  • 保存按钮文案与真实保存内容一致。
  • 重复点击、分享失败、空数据都有可见反馈。
  • 窗口尺寸与主题变化后曲线能够重绘。
  • 旧载荷迁移有 schemaVersion 或等价版本边界。

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

为了避免把设计建议写成"项目已经完成的能力",这里对全文结论做一次归档。读者可以直接打开文首列出的两个 ArkTS 文件逐项复核。

类别 当前源码可直接确认 本文提出但尚未声称已合入
结果模型 ExperimentResultPayload 使用 number[][] 保存表格数据 ExperimentDataPoint[]、schemaVersion 与 V2 载荷
路由接收 router.getParams() 后通过类型断言和少量字段判断写入状态 以不可信输入为起点的 Normalizer、warning 与错误态
默认值 页面初始化即使用 defaultExperimentResultPayload() FALLBACK 状态和"正在展示示例数据"的显式提示
曲线范围 maxV 读取最后一行第二列;默认 pH 数据由 7.00 降至 0.50 扫描全量最大值,或为 pH 使用固定 0..14 量程
表格与曲线 两者都消费 tableData,但依赖数组下标解释列语义 两者统一消费带命名字段的数据点
防重复操作 已有 isSaving 与 isSharing 按钮状态 使用 finally 进一步保证异常路径复位
后续页面 报告页通过路由继续接收 this.result 仅允许规范化后的结果继续传递和持久化

本文给出的代码用于说明可落地的改造方法,但没有声称这些建议已经完成工程编译、真机运行或 AGC 上架验证。实际实施时应按第十三节分阶段修改,并在每个阶段重新构建和回归。

总结

实验结果链路的可靠性,不取决于接口文件里有没有 interface,而取决于运行时对象是否经过校验、列语义是否被类型表达、所有展示是否消费同一份数据,以及异常时页面有没有诚实反馈。

对化学实验室当前实现,最先应该修的是曲线范围:默认 pH 数据下降,最后值并不是最大值。随后把 number[][] 收敛为 ExperimentDataPoint[],再通过 Normalizer 隔离路由参数与页面状态。完成这三步后,表格、Canvas、报告和分享才能真正共享一份可预测、可迁移、可测试的实验结果载荷。

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

相关推荐
万物智能信息科技2 小时前
气体传感器MQ-2芯片驱动开发—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
驱动开发·嵌入式硬件·华为·开源·harmonyos
懿路向前2 小时前
HarmonyOS 粒子特效实战:ArkTS V2 状态管理三坑:@Local 判据 / 引用替换 / 双通道更新(HarmonyOS)
华为·harmonyos
蒿豪郝灬号3 小时前
UniApp接入鸿蒙版阿里百川
华为·uni-app·harmonyos
云边有个稻草人5 小时前
鸿蒙远控电脑,ToDesk和向日葵各用一周,三个细节差距比想象中大
harmonyos·todesk
Flutter_OH21 小时前
Flutter OHOS 三方库与插件适配相关问题
harmonyos
Flutter_OH21 小时前
Flutter OHOS ohos应用编译相关问题
harmonyos
雨雨雨雨雨别下啦1 天前
【OpenHarmony北向开发】ArkTS基础教学
typescript·arkts·openharmonys
马剑威(威哥爱编程)1 天前
【共创稿事节】HarmonyOS 7 Agent 开发实战 01:云端大模型 API 接入底座
华为·harmonyos
万物智能信息科技1 天前
人体感应芯片PIR模块驱动开发—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
驱动开发·嵌入式硬件·华为·开源·harmonyos