☰
【化学实验室|02】HarmonyOS ArkTS 实验结果载荷实战:用强类型模型隔离页面参数与结果展示
2026/10/15 8:20:51 网站建设 项目流程

一个实验页面把曲线画出来,并不等于结果链路可靠。真正容易出错的地方,往往发生在“模拟结束”到“结果页展示”之间:上一个页面通过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 1000005AppScope/app.json5当前声明
HarmonyOS SDK6.0.2(22)ArkTS / ArkUI Stage Model
结果模型ExperimentResultPayload.ets路由与页面共同使用的载荷
结果页面ExperimentResultPage.ets表格、Canvas、保存、分享与报告
输入来源router.getParams()运行时对象,不能只靠类型断言
数据形态number[][]列含义不明确、缺少行级校验

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

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

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

现有结果模型很简洁:

export interface ExperimentResultPayload { expId: string title: string category: string paramSummary: string conclusion: string tableData: number[][] }

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

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

const params = router.getParams() as ExperimentResultPayload | undefined

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

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

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

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

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()产生运行时异常;如果上游把列顺序传错,页面甚至不会报错。

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

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:

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 } }

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

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

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

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

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,模拟页、结果页、报告页和分享工具都要同步调整,风险较大。更稳的方式是先增加一个规范化函数:它接收旧二维数组,输出命名数据点,并对范围做统一处理。

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。

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

export interface NormalizeResult { payload: ExperimentResultPayloadV2 usedFallback: boolean warnings: string[] }

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

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

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

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

@State result: ExperimentResultPayload = defaultExperimentResultPayload() @State tableData: number[][] = defaultExperimentResultPayload().tableData

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

export enum ResultLoadState { LOADING = 'loading', CONTENT = 'content', FALLBACK = 'fallback', ERROR = 'error' }

页面加载流程可以写成:

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时,提供返回实验页和重试入口。这样默认数据仍然发挥兜底作用,又不会冒充真实实验。

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

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

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,第一行的纵坐标是:

y = padT + plotH - (7.0 / 1.2) * plotH

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

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

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[]后,表格不再依赖下标:

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:

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()不够清晰。

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

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、标题、分类和参数摘要:

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:

router.pushUrl({ url: 'views/experiment/ExperimentReportPage', params: this.result })

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

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

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

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某行只有两列过滤该行并记录 warningtoFixed异常
R-04某行包含NaN不进入页面数据点Canvas 非有限坐标
R-05pH 为 18规范化到 14 或拒绝载荷数值越界
R-06进度为 -5规范化到 0 或拒绝载荷业务范围
R-07路由参数为空显示明确降级提示默认数据冒充结果
R-08快速重复点击保存只执行一次持久化重复记录
R-09分享面板不可用Toast 提示且按钮恢复异常状态
R-10表格切换曲线Canvas 使用同一 points 重绘状态不同步

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

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

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

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 源码为依据,并由作者复核后发布。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询