部分内容由AI辅助生成。本文面向 HarmonyOS 5.0 及以上版本,基于 细胞工坊 项目真实源码展开,源码根目录为 D:\huawei\one14-9。文中示例围绕 entry/src/main/ets/views/experiment/ExperimentSimPage.etsentry/src/main/ets/model/Experiment.etsentry/src/main/ets/model/ExperimentFeedback.ets 中已经存在的 DNA 实验流程,不虚构设备能力或后台服务。

DNA 提取类实验页最容易出错的地方,不是某个按钮没有响应,而是步骤、参数、动画和结果反馈没有被同一套状态约束。用户可能先调裂解液浓度,再启动水果 DNA 提取;也可能切到 DNA 分层离心或 PCR 扩增。如果页面只把这些流程当作三张静态说明图,结果页很容易出现两个问题:一个是跳步后仍然显示完成结果,另一个是反馈字段沿用上一类实验的语义,比如把 PCR 的非特异条带写成离心混浊。

细胞工坊 的做法更接近一个小型实验引擎:实验模型定义参数边界,模拟页持有运行状态,进度驱动阶段变化,指标函数按 expId 分支计算,Canvas 按同一个 expId 绘制不同场景,最后再把结果参数交给反馈页。本文拆解这条链路,重点回答一个实际工程问题:在 HarmonyOS ArkTS 应用里,如何让 DNA 提取流程既可交互,又不因为跳步、参数错配、反馈复用而产生错误结果。

封面:DNA 提取流程状态链路

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

  • 如何在 Experiment.ets 中把水果 DNA 提取、DNA 分层离心、PCR 扩增定义成可复核的模型。
  • 如何用 progressisRunningisFinishedparamValues 约束页面步骤,避免运行中修改参数或未完成就生成结果。
  • 如何让温度、活性、污染、成功率这些指标随不同 DNA 实验语义变化,而不是复用同一套通用公式。
  • 如何让 Canvas 场景、反馈表头、结论模板和本地记录都跟随同一个 expId,保证结果页可追溯。

流程图:从路由进入到反馈记录

结构图:DNA 实验的代码归属

一、先把 DNA 流程定义成模型,而不是写死在页面里

如果把“提取水果 DNA”的名称、参数滑块范围、图标和分类直接写在页面组件中,后面增加 DNA 分层离心、PCR 扩增时就会出现大量条件判断。细胞工坊 把实验入口集中放在 Experiment.ets,每个实验都有稳定的 id、展示名称、说明、分类、等级、图标和参数数组。

DNA 相关的三组模型可以抽象成下面的结构:

export interface ExperimentParam {
  name: string
  unit: string
  min: number
  max: number
  defaultValue: number
  step: number
}

export interface Experiment {
  id: string
  name: string
  description: string
  category: string
  level: string
  icon: Resource
  isFavorite: boolean
  params: ExperimentParam[]
}

这段模型的边界很清楚:它只负责描述实验,不负责运行实验。页面拿到 params 后生成滑块,指标函数通过 paramValues 读取当前值,反馈页通过 expId 决定表头和结论。这样做的好处是,一个 DNA 实验的参数含义从入口到结果页都可以被同一个模型追踪。

当前源码里至少有三类 DNA 流程:

实验 ID 名称 参数 1 参数 2 工程含义
fruit_dna_extraction 提取水果DNA 裂解液浓度 20%-100% 酒精温度 -10℃-15℃ 控制絮状物析出、杂质和产率
dna_layering DNA分层离心 离心转速 1000-8000 rpm 离心时间 1-12 min 控制分层度、混浊和纯度
pcr_amplification PCR基因扩增 循环次数 10-40 次 退火温度 45℃-70℃ 控制扩增量、非特异条带和纯度

这里最关键的不是参数多,而是参数名称进入了后续查询逻辑。例如 ExperimentSimPage 里的 getParamValue('转速', 4200)getParamValue('循环', 25) 都依赖参数名称中包含对应关键词。如果后续改名,需要同步评估这些查询点,否则 UI 仍能显示滑块,但模拟场景会读不到真实参数。

二、路由进入后先初始化实验,避免页面带着旧状态运行

实验页从路由参数进入,aboutToAppear() 会读取 expIdexpName、知识点标题和摘要,然后调用 initExperiment()resetExperiment()loadFavoriteState()。这个顺序能解决一个常见问题:同一个页面组件复用时,旧实验的 paramDefsprogress 不能带到新实验里。

源码中的关键链路可以简化为:

aboutToAppear(): void {
  const params = router.getParams() as SimRouterParams | undefined
  if (params?.expId) {
    this.expId = params.expId
  }
  if (params?.expName) {
    this.title = params.expName
  }
  this.initExperiment()
  this.resetExperiment()
  this.loadFavoriteState()
}

private currentExperiment(): Experiment {
  const found = getAllExperiments().find((item: Experiment) => item.id === this.expId)
  return found ? found : getAllExperiments()[0]
}

private initExperiment(): void {
  const exp = this.currentExperiment()
  this.title = exp.name
  this.paramDefs = exp.params
  this.paramValues = this.paramDefs.map((p: ExperimentParam) => p.defaultValue)
}

这段代码有三个工程意图。

第一,expId 是主键。页面标题可以展示,分类可以参与结果页,但真正决定参数、指标、动画、反馈模板的是 expId

第二,参数值不是随便创建的数组,而是从当前实验的 paramDefs 映射出默认值。水果 DNA 提取的默认值是裂解液浓度 70%、酒精温度 0℃;DNA 分层离心默认是 4200 rpm、5 min;PCR 默认是 25 次循环、58℃退火温度。

第三,currentExperiment() 提供了兜底。如果路由传入未知 expId,页面不会直接崩溃,而是回到第一个实验。这个兜底对上线应用很实用,但也意味着测试时要覆盖非法路由参数,否则可能把路由错误掩盖成默认实验。

三、步骤状态用 progress 表达,按钮只改变运行态

DNA 提取流程在 UI 上看起来像“器材准备、样本处理、动态观察、自动检测、实验完成”几个阶段,但源码没有把它拆成多个页面,而是用 progress 统一驱动。startTimer() 每 16ms 推进一次进度,getStageLabel() 按进度区间返回阶段文案。

private startTimer(): void {
  this.stopTimer()
  this.timerId = setInterval(() => {
    this.progress = Math.min(1, this.progress + 0.0045 * this.speed)
    this.updateDisplay()
    this.drawCanvas()
    this.persistLearningTime(false)
    if (this.progress >= 1) {
      this.isRunning = false
      this.isFinished = true
      this.stopTimer()
      this.persistLearningTime()
      DataStore.incrementExperimentCount()
      this.persistRecord()
    }
  }, 16)
}

private getStageLabel(): string {
  if (this.progress < 0.18) return '器材准备'
  if (this.progress < 0.42) return '样本处理'
  if (this.progress < 0.72) return '动态观察'
  if (this.progress < 1) return '自动检测'
  return '实验完成'
}

这套状态设计比单独的“下一步”按钮更稳,原因是所有下游逻辑都读同一个 progress

状态字段 作用 防止的问题
progress 表示实验推进程度和阶段 防止动画、指标、结果页各自维护进度
isRunning 控制运行中状态 防止运行中随意改参数
isFinished 控制结果入口展示 防止未完成就进入反馈页
speed 控制推进倍率 允许加速演示,但不破坏阶段边界

对 DNA 提取这类流程来说,这一点很重要。裂解液浓度或退火温度应在实验开始前确定;一旦进入运行中,用户再改参数会导致指标曲线和最终反馈难以解释。源码里的 Slider 使用 .enabled(!this.isRunning),正是用运行态限制参数改动。

四、温度不是公共字段,而是按实验语义计算

在生物实验模拟里,“温度”不一定来自同一个参数。水果 DNA 提取关心酒精温度,PCR 关心退火温度,DNA 分层离心则更像低温离心场景。ExperimentSimPagegetTemperature() 先尝试读取包含“温度”的显式参数;如果没有,再按 expId 给出业务默认值。

private getTemperature(): number {
  const explicit = this.getParamValue('温度', -999)
  if (explicit > -100) return explicit
  switch (this.expId) {
    case 'fruit_dna_extraction': return this.rawParam(1, 0)
    case 'pcr_amplification': return this.rawParam(1, 58)
    case 'dna_layering': return 4
    default: return 37
  }
}

这段逻辑看起来简单,但能避免一个非常具体的错误:如果统一把第二个参数当“温度”,DNA 分层离心的第二个参数是“离心时间”,结果页就会显示离心时间对应的温度。源码通过 case 'dna_layering': return 4 明确把分层离心固定成 4℃,避免了参数语义错配。

更稳的扩展方式,是把“展示温度”看成计算指标,而不是模型的固定字段。不同实验如果没有温度概念,可以返回环境默认值;如果有,就从明确的参数中读取。这样页面展示的“温度”不会阻止新增实验,也不会要求所有实验都有同名参数。

五、污染、活性、成功率要分支计算,不能复用一个公式

DNA 提取流程的反馈质量取决于三类指标:活性、污染和成功率。源码没有把所有实验套进同一公式,而是先用 normParam() 把参数归一化到 0-1,再按实验 ID 分支。

水果 DNA 提取的污染和活性逻辑可以简化为:

case 'fruit_dna_extraction':
  contamination = clamp(15 + (1 - p0) * 20 + p1 * 18 + progress * 8, 0, 100)
  activity = clamp(35 + p0 * 35 + (1 - p1) * 22 + progress * 20, 0, 100)
  successRate = clamp(40 + p0 * 26 + (1 - p1) * 18 + progress * 14, 0, 100)

其中 p0 对应裂解液浓度,p1 对应酒精温度。浓度越高,絮状物析出表现越好;酒精温度越低,DNA 析出更稳定。这和反馈模板中的建议一致:如果絮状量偏低,就提示提高裂解液浓度或降低酒精温度。

DNA 分层离心的逻辑不同:

case 'dna_layering':
  contamination = clamp((1 - p0) * 40 + (1 - p1) * 18 + progress * 10, 0, 100)
  activity = clamp(30 + p0 * 42 + p1 * 18 + progress * 15, 0, 100)
  successRate = clamp(35 + p0 * 32 + p1 * 18 + progress * 12, 0, 100)

这里 p0 是离心转速,p1 是离心时间。转速低、时间短,混浊更高;转速和时间提升后,分层度和纯度提高。注意这不是严格生化模型,而是教学模拟中的交互映射,适合用来让学生理解参数变化对结果趋势的影响。

PCR 扩增又是另一类约束:

case 'pcr_amplification':
  contamination = clamp((1 - Math.abs(p1 - 0.52)) * -20 + 35 + p0 * 12 + progress * 8, 0, 100)
  activity = clamp(25 + p0 * 50 + (1 - Math.abs(p1 - 0.52)) * 22 + progress * 12, 0, 100)
  successRate = clamp(40 + (1 - Math.abs(p1 - 0.52)) * 24 + p0 * 18 - Math.max(0, p0 - 0.7) * 22 + progress * 12, 0, 100)

PCR 的核心是循环次数和退火温度。循环次数提升会增加扩增量,但过高循环也可能拉低最终纯度;退火温度偏离合适区间时,非特异条带会上升。源码用 Math.abs(p1 - 0.52) 表达退火温度接近目标区间时结果更好,再通过 Math.max(0, p0 - 0.7) 限制过多循环。

六、Canvas 场景跟随 expId,不要让三个 DNA 流程共用一张动画

DNA 类实验如果只换标题不换场景,用户很难判断自己正在做的是提取、离心还是扩增。源码在 drawCanvas() 里按 expId 分发绘制函数:

switch (this.expId) {
  case 'fruit_dna_extraction':
    this.drawDnaScene(ctx, w, h)
    break
  case 'dna_layering':
    this.drawCentrifugeScene(ctx, w, h)
    break
  case 'pcr_amplification':
    this.drawPcrScene(ctx, w, h)
    break
}

drawDnaScene() 绘制两条随进度摆动的 DNA 曲线和底部析出进度条,适合表达水果 DNA 提取中絮状物逐步析出。drawCentrifugeScene() 绘制离心机和四个旋转样品管,并在底部显示当前转速。drawPcrScene() 则根据循环数推导拷贝数量,用多个 DNA 小片段表达扩增过程。

这类分发函数适合放在页面内部,因为它依赖 Canvas 上下文、尺寸和当前进度;但业务含义不能散落。建议扩展时遵守一个规则:新增实验 ID 后,同时检查四个地方是否同步更新。

更新点 文件 缺失后的表现
实验模型 Experiment.ets 列表无法进入或参数为空
指标分支 ExperimentSimPage.ets 成功率、污染、活性不符合实验语义
绘制分支 ExperimentSimPage.ets 页面显示默认或错误场景
反馈模板 ExperimentFeedback.ets 结果页字段和建议不匹配

这个表很适合放进代码评审清单。因为 DNA 实验不是单一页面,它是一条横跨模型、页面、绘制、反馈和存储的链路。

七、结果参数统一从 buildResultParams() 生成

实验完成后,页面才显示“查看实验反馈”按钮。点击时不是直接把页面上的文本拼进结果页,而是调用 buildResultParams() 生成稳定对象,再通过路由传给 ExperimentResultPage

private buildResultParams(): ResultRouterParams {
  const exp = this.currentExperiment()
  return {
    expId: this.expId,
    expName: this.title,
    category: exp.category,
    progress: Math.round(this.progress * 100),
    successRate: Math.round(this.getSuccessRate()),
    activity: Math.round(this.getActivity()),
    contamination: Math.round(this.getContamination()),
    temperature: Math.round(this.getTemperature() * 10) / 10,
    stage: this.getStageLabel(),
    paramSummary: this.getParamSummary()
  }
}

这里的字段可以分成三层:

字段 来源 作用
expIdexpNamecategory 实验模型和页面状态 决定反馈模板和展示归属
progressstage 步骤状态 表达实验是否完整推进
successRateactivitycontaminationtemperatureparamSummary 指标函数与参数摘要 形成反馈页可解释结果

这比在反馈页重新读取参数更稳定。结果页只消费已经完成的快照,不参与运行时计算,能减少“进入结果页后参数又变了”的问题。

八、反馈表头和结论模板也必须按 DNA 实验区分

ExperimentFeedback.ets 做了一件很关键的事:同样是四列反馈表,它没有用“活性、污染、评分”覆盖所有实验,而是按 expId 返回不同表头。

export function getFeedbackSchema(expId: string): FeedbackSchema {
  switch (expId) {
    case 'fruit_dna_extraction':
      return { headers: ['进度', '絮状量', '杂质', '产率'], unitOverrides: ['%', '%', '%', ''] }
    case 'dna_layering':
      return { headers: ['转速段', '分层度', '混浊', '纯度'], unitOverrides: ['%', '%', '%', ''] }
    case 'pcr_amplification':
      return { headers: ['循环', '扩增量', '非特异', '纯度'], unitOverrides: ['%', '%', '%', ''] }
    default:
      return { headers: ['进度', '活性', '污染', '评分'], unitOverrides: ['%', '%', '%', ''] }
  }
}

这一层防止了结果语义串台。举例来说,activity 在水果 DNA 提取里可解释为絮状物析出量,在 DNA 分层离心里可解释为分层度,在 PCR 中则对应扩增量。如果反馈页只显示“活性”,用户能看到数字,却无法理解这个数字代表什么。

结论模板同样按实验区分:

case 'fruit_dna_extraction':
  return `在${ctx.paramSummary}下完成${p}%流程,絮状物析出量${a}%,杂质残留${c}%,DNA产率${s}%。${a < 70 ? '建议提高裂解液浓度或降低酒精温度,加快DNA析出。' : 'DNA絮状物清晰可见,可继续比较不同水果之间的产率差异。'}`

case 'dna_layering':
  return `离心至${p}%阶段,分层清晰度${a}%、混浊${c}%,最终样本纯度${s}%。${c >= 30 ? '混浊偏高,建议延长离心时间或提高转速。' : '分层界面清晰,适合下一步取样分析。'}`

case 'pcr_amplification':
  return `循环推进到${p}%,目标片段扩增量${a}%、非特异条带${c}%,扩增产物纯度${s}%。${c >= 30 ? '退火温度偏低产生非特异扩增,建议提高2-4℃。' : 'PCR反应特异性良好,可减少循环次数避免引物二聚体。'}`

这段模板有一个值得保留的设计:建议不是固定文案,而是由指标触发。絮状量低就建议调整裂解液或酒精温度,混浊偏高就建议延长离心时间或提高转速,PCR 非特异偏高就建议提高退火温度。这样的反馈才像实验系统,而不是简单的完成页。

九、本地记录保存参数摘要,便于复盘

实验完成时,startTimer() 会调用 DataStore.incrementExperimentCount()persistRecord()。记录里保存了实验 ID、标题、分类和参数摘要。

private persistRecord(): void {
  const exp = this.currentExperiment()
  const parts: string[] = []
  for (let i = 0; i < this.paramDefs.length; i++) {
    const def = this.paramDefs[i]
    const val = this.paramValues[i] ?? def.defaultValue
    const unit = def.unit ? ' ' + def.unit : ''
    parts.push(`${def.name}:${val}${unit}`)
  }
  const summary = parts.length > 0 ? parts.join(';') : '默认实验条件'
  const rec: StoredExperimentRecord = buildRecord(this.expId, this.title, exp.category, summary)
  DataStore.appendRecord<StoredExperimentRecord>(rec)
}

这段逻辑对 DNA 实验尤其有用。一次水果 DNA 提取如果只保存“完成”,复盘价值很低;如果保存“裂解液浓度:70%;酒精温度:0℃”,下次就能比较不同条件下的产率差异。PCR 也一样,循环次数和退火温度必须被记录下来,否则“非特异条带偏高”的建议没有可追溯依据。

在 HarmonyOS 应用里,类似记录不建议由 UI 直接拼成散乱字符串到处传。当前源码把记录构造收敛在 persistRecord(),已经比每个按钮事件里直接写存储更容易维护。后续如果要做统计页,可以再把 paramSummary 拆成结构化字段或单独的 ExperimentRecord 模型版本。

十、运行中禁用参数滑块,避免同一轮实验出现两个条件

DNA 提取流程中,最容易被忽略的是运行中参数变更。源码中的 Slider 绑定了 paramValues[index],并通过 .enabled(!this.isRunning) 限制运行状态。

Slider({
  value: this.paramValues[index],
  min: param.min,
  max: param.max,
  step: param.step,
  style: SliderStyle.OutSet
})
  .onChange((value: number) => {
    this.paramValues[index] = Math.round(value * 100) / 100
    this.paramValues = [...this.paramValues]
    if (!this.isRunning) {
      this.updateDisplay()
      this.drawCanvas()
    }
  })
  .enabled(!this.isRunning)

这段代码同时做了两件事:

  • 参数变化后通过 this.paramValues = [...this.paramValues] 触发 ArkUI 状态刷新。
  • 只有未运行时才立即刷新指标和画布,运行中则不允许继续调整参数。

如果放开运行中修改,就会出现同一轮实验前半段使用 70% 裂解液、后半段使用 100% 裂解液,最终反馈却只展示最后一个参数摘要的情况。教学类实验可以简化真实过程,但不能让结果解释失去一致性。

十一、验证清单:确认 DNA 流程没有串台

开发或改造这类实验页时,可以按下面清单做最小验证:

验证项 操作 期望结果
模型加载 从列表进入 fruit_dna_extraction 页面标题为提取水果DNA,参数为裂解液浓度和酒精温度
阶段推进 点击开始并观察指标 阶段从器材准备推进到实验完成
运行中锁定 运行中查看参数滑块 滑块不可编辑
场景分发 分别进入三个 DNA 实验 分别看到 DNA 双链、离心机、PCR 扩增场景
结果快照 完成后进入反馈页 参数摘要、成功率、污染、温度和阶段来自 buildResultParams()
反馈语义 查看三类 DNA 反馈 表头分别为絮状量/分层度/扩增量等业务字段
记录保存 完成实验后查看记录 记录包含实验名称、分类和参数摘要

如果只能做一次快速回归,优先覆盖“进入实验、开始、完成、进入反馈页”这条主路径。因为这条路径同时经过模型、状态、Canvas、指标、路由、反馈和存储。

十二、常见问题与排查

问题 优先检查点 修复建议
页面显示了错误参数 Experiment.ets 中对应 idparams 确认路由传入的 expId 正确,参数名称不要和指标查询关键词脱节
温度显示不符合实验 getTemperature() 分支 对没有显式温度参数的实验增加固定业务温度或环境默认值
DNA 分层离心显示了 DNA 双链动画 drawCanvas() switch 检查 dna_layering 是否分发到 drawCentrifugeScene()
PCR 反馈仍然显示活性/污染/评分 getFeedbackSchema() pcr_amplification 增加专属表头
反馈建议和参数不一致 getConclusion() 与指标函数 保证建议条件引用的是同一组业务指标,例如非特异条带、混浊、絮状量
运行中修改参数导致结果难解释 Slider .enabled(!this.isRunning) 运行时锁定参数;如需动态调参,要记录调参时间线
完成后没有记录 startTimer()persistRecord() 检查 progress >= 1 分支是否执行,以及 DataStore 写入是否成功

排查时不要先改 UI 文案。DNA 流程的串台问题通常来自 expId、参数名、指标分支和反馈模板不一致。先把这四处对齐,再看视觉和交互。

十三、可继续优化的方向

当前源码已经把 DNA 三类流程串成了可运行链路。如果后续要继续增强,可以考虑三点。

第一,把指标公式从页面中抽离到独立的 ExperimentMetricService。现在 getContamination()getActivity()getSuccessRate() 都在页面组件内,阅读上比较集中,但随着实验数量增加,页面会越来越重。抽离后可以单独写单元测试,验证不同参数下的趋势是否符合预期。

第二,把 paramSummary 从字符串升级为结构化数组。当前字符串适合展示和记录,但如果后面要做“不同退火温度下 PCR 纯度对比”,结构化数据会更方便。

第三,为 DNA 实验增加更细的阶段标签。例如水果 DNA 提取可以拆成裂解、过滤、酒精沉淀、观察;PCR 可以拆成变性、退火、延伸。即使 UI 仍然只用一个 progress,内部也能按实验类型返回更贴近业务的阶段文案。

总结

这篇文章拆解的不是 DNA 提取的理论步骤,而是 HarmonyOS ArkTS 应用中让实验流程可交互、可解释、可复盘的状态设计。细胞工坊 的关键做法是把 expId 作为贯穿链路的主键:模型定义参数,页面初始化状态,进度驱动阶段,指标函数按实验语义计算,Canvas 场景跟随实验切换,反馈模板输出专属表头和建议,最后再把参数摘要写入本地记录。

对教学模拟类 HarmonyOS 应用来说,这套方法比堆叠说明卡片更可靠。只要新增实验时同步检查模型、指标、绘制、反馈和记录五个点,就能避免大多数跳步、串台和错误结果问题。

Logo

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

更多推荐