细胞培养实验如果只做成“培养皿里长出几个点”的动画,用户很难知道自己调了什么、风险从哪里来、为什么最后得到某个纯度或评分。细胞工坊的源码没有接入真实培养设备,也没有云端实验数据,但它用 HarmonyOS ArkTS 做了一套完整的模拟闭环:实验卡片负责入口展示,实验模型定义培养条件,模拟页用 Canvas 绘制培养过程,指标函数把温度、无菌指数、开盖时长、消毒覆盖、营养水平和环境压力转成污染、活性、成功率,结果页再用差异化表头和结论模板解释实验反馈。

这篇文章基于 D:\huawei\one14-9 的真实源码,重点拆解培养相关实验:bacteria_culturesterile_operationcell_growth_cycleyeast_fermentation。它们都不是泛泛的科普文案,而是有明确参数边界、过程绘制、指标计算和结果反馈的 ArkTS 页面状态。本文不会把它夸大成真实生物检测能力;它解决的是教育类 HarmonyOS 应用里更实际的问题:怎样让模拟培养条件、过程反馈和失败边界都能从源码复查。

封面:细胞培养实验反馈闭环

本文会覆盖五个工程问题:

  1. ExperimentCard 如何展示培养实验入口和收藏动作。
  2. Experiment.ets 如何定义培养温度、无菌指数、开盖时长等参数。
  3. ExperimentSimPage 如何用进度和参数计算污染、活性、成功率。
  4. Canvas 如何区分培养皿、无菌操作、细胞生长和酵母发酵场景。
  5. ExperimentFeedback.ets 如何把结果页表头和结论做成可解释反馈。

流程图:细胞培养反馈流程

结构图:培养实验职责结构

一、入口卡片先把实验边界说清楚

培养实验不是从 Canvas 开始的,而是从列表入口开始。ExperimentCard.ets 接收实验名称、描述、分类、等级、图标和收藏状态,然后把这些信息展示为可点击卡片。

@Component
export struct ExperimentCard {
  @Prop name: string = ''
  @Prop description: string = ''
  @Prop category: string = ''
  @Prop level: string = ''
  @Prop icon: Resource = $r('app.media.ic_bio_microscope')
  @Prop isFavorite: boolean = false
  onCardClick: () => void = () => {}
  onFavoriteClick: () => void = () => {}
}

这段组件有一个实际作用:它让用户在进入模拟页之前就知道当前实验是“培养”“基础”“进阶”还是其他类别。细胞培养相关实验在模型层有不同分类和等级,比如“培养细菌菌落”属于 培养 / 经典,“无菌操作挑战”属于 培养 / 进阶,“酵母发酵观察”属于 培养 / 初级

卡片里的描述文本使用单行省略:

Text(this.description)
  .fontSize(AppFonts.CAPTION_SIZE)
  .fontColor(AppColors.TEXT_SECONDARY)
  .margin({ top: 4 })
  .maxLines(1)
  .textOverflow({ overflow: TextOverflow.Ellipsis })

这对多设备适配有意义。module.json5 声明了 phonetablet2in1,入口卡片不能因为描述过长把收藏按钮或分类标签挤出屏幕。培养实验的入口要简洁,真正复杂的参数和反馈放到模拟页和结果页。

二、培养条件来自模型,不来自控件硬编码

细胞培养相关实验定义在 Experiment.ets。以细菌培养为例:

{
  id: 'bacteria_culture',
  name: '培养细菌菌落',
  description: '配置培养皿、营养基和培养箱,观察菌落增长。',
  category: '培养',
  level: '经典',
  icon: $r('app.media.ic_bio_petri'),
  isFavorite: false,
  params: [
    { name: '培养温度', unit: '℃', min: 20, max: 42, defaultValue: 37, step: 1 },
    { name: '无菌指数', unit: '%', min: 50, max: 100, defaultValue: 86, step: 2 }
  ]
}

这段模型把培养温度和无菌指数写成明确边界。滑杆只读取 minmaxdefaultValuestep,不自己定义“温度范围应该是多少”。这样做的好处是,后续结果页和记录页都能回到同一组参数。

无菌操作挑战则定义了另一类培养风险:

{
  id: 'sterile_operation',
  name: '无菌操作挑战',
  description: '控制污染风险,完成样本转移和培养皿封存。',
  category: '培养',
  level: '进阶',
  params: [
    { name: '开盖时长', unit: 's', min: 1, max: 12, defaultValue: 4, step: 1 },
    { name: '消毒覆盖', unit: '%', min: 40, max: 100, defaultValue: 90, step: 5 }
  ]
}

这不是同一个“培养皿动画”的换皮。细菌培养关注温度和无菌指数;无菌操作关注开盖时长和消毒覆盖;酵母发酵关注糖浓度和反应温度;细胞生长周期关注营养水平和环境压力。模型差异决定了后续指标差异。

三、模拟页根据实验 id 分派场景

ExperimentSimPagedrawCanvas() 里有一个清晰的场景分派。培养相关 id 会进入不同绘制函数:

switch (this.expId) {
  case 'bacteria_culture':
    this.drawCultureScene(ctx, w, h)
    break
  case 'sterile_operation':
    this.drawSterileScene(ctx, w, h)
    break
  case 'cell_growth_cycle':
    this.drawGrowthCycleScene(ctx, w, h)
    break
  case 'yeast_fermentation':
    this.drawFermentationScene(ctx, w, h)
    break
}

这种分派方式把“实验类型”和“绘制场景”绑定起来,但没有把指标计算塞进绘制函数。Canvas 负责表达视觉过程,污染、活性、成功率仍然由独立函数计算。这是可维护的边界。

细菌培养场景会绘制培养皿,并根据进度增加菌落数量和大小:

private drawCultureScene(ctx: CanvasRenderingContext2D, w: number, h: number): void {
  const cx = w * 0.5
  const cy = h * 0.50
  ctx.fillStyle = '#0F3C35'
  ctx.beginPath()
  ctx.ellipse(cx, cy, w * 0.28, h * 0.24, 0, 0, Math.PI * 2)
  ctx.fill()
  ctx.strokeStyle = '#00D9FF'
  ctx.lineWidth = 4
  ctx.stroke()
  const colonies = 8 + Math.floor(this.progress * 20)
  for (let i = 0; i < colonies; i++) {
    const size = 3 + (i % 5) + this.progress * 5
    ctx.fillStyle = i % 4 === 0 ? '#7C4DFF' : '#00FFB2'
  }
}

这段代码说明,菌落增长是模拟效果,不是真实检测结果。文章和发布材料都应使用“模拟”“观察”“参数推演”这样的表达,不能写成“自动识别真实菌落数”。

四、污染指数要体现失败边界

培养类实验最关键的反馈是失败边界。源码里的 getContamination() 根据实验类型分别计算污染指数:

private getContamination(): number {
  const p0 = this.normParam(0)
  const p1 = this.normParam(1)
  const pr = this.progress
  switch (this.expId) {
    case 'bacteria_culture':
      return this.clamp((1 - p1) * 55 + pr * 15, 0, 100)
    case 'sterile_operation':
      return this.clamp(p0 * 55 + (1 - p1) * 25 + pr * 10, 0, 100)
    case 'cell_growth_cycle':
      return this.clamp(p1 * 55 + (1 - p0) * 22 + pr * 10, 0, 100)
    case 'yeast_fermentation':
      return this.clamp(15 + (1 - p0) * 18 + Math.abs(p1 - 0.5) * 60 + pr * 8, 0, 100)
  }
}

这四个公式表达了不同失败原因。

实验 参数含义 污染或失败来源
细菌培养 培养温度、无菌指数 无菌指数越低,污染越高
无菌操作 开盖时长、消毒覆盖 开盖越久、消毒越弱,污染越高
细胞生长周期 营养水平、环境压力 环境压力高、营养低,风险上升
酵母发酵 糖浓度、反应温度 糖不足、温度偏离中间值,杂菌或失败风险上升

教育类模拟页面需要这种失败边界。否则用户拖动参数只会看到动画变快或变慢,很难理解“为什么这个条件不合适”。源码用 clamp() 把结果限制在 0 到 100,也避免了参数极端时指标溢出。

五、活性和成功率不是同一个指标

培养实验还需要区分活性和成功率。getActivity() 计算过程活性:

private getActivity(): number {
  const p0 = this.normParam(0)
  const p1 = this.normParam(1)
  const pr = this.progress
  switch (this.expId) {
    case 'bacteria_culture':
      return this.clamp(40 + (1 - Math.abs(p0 - 0.77)) * 32 + pr * 28, 0, 100)
    case 'sterile_operation':
      return this.clamp(50 + p1 * 32 + pr * 18, 0, 100)
    case 'cell_growth_cycle':
      return this.clamp(30 + p0 * 45 - p1 * 22 + pr * 25, 0, 100)
    case 'yeast_fermentation':
      return this.clamp(40 + p0 * 28 + (1 - Math.abs(p1 - 0.5)) * 35 + pr * 12, 0, 100)
  }
}

活性关注过程状态,成功率关注最终效果。getSuccessRate() 对培养类实验又有单独分支:

private getSuccessRate(): number {
  const p0 = this.normParam(0)
  const p1 = this.normParam(1)
  const pr = this.progress
  switch (this.expId) {
    case 'bacteria_culture':
      return this.clamp(40 + p1 * 32 + pr * 16 - Math.abs(p0 - 0.77) * 18, 0, 100)
    case 'sterile_operation':
      return this.clamp(50 + p1 * 28 + pr * 14 - p0 * 18, 0, 100)
    case 'cell_growth_cycle':
      return this.clamp(40 + p0 * 32 - p1 * 22 + pr * 14, 0, 100)
    case 'yeast_fermentation':
      return this.clamp(40 + p0 * 22 + (1 - Math.abs(p1 - 0.5)) * 26 + pr * 12, 0, 100)
  }
}

这两个函数让结果反馈更细。以无菌操作为例,消毒覆盖可以提升活性和总评,但开盖时长会拉低成功率并提升污染。用户看到的不是单一分数,而是“规范度、污染、总评”之间的关系。

六、结果页表头跟实验类型绑定

结果页不是所有实验都显示“活性、污染、评分”。ExperimentFeedback.ets 给不同实验定义了不同表头:

export function getFeedbackSchema(expId: string): FeedbackSchema {
  switch (expId) {
    case 'bacteria_culture':
      return { headers: ['时长', '菌落数', '污染', '纯度'], unitOverrides: ['%', '%', '%', ''] }
    case 'sterile_operation':
      return { headers: ['步骤', '规范度', '污染', '总评'], unitOverrides: ['%', '%', '%', ''] }
    case 'cell_growth_cycle':
      return { headers: ['时长', '分裂率', '凋亡率', '活力'], unitOverrides: ['%', '%', '%', ''] }
    case 'yeast_fermentation':
      return { headers: ['时间', '产气量', '杂菌', '活性'], unitOverrides: ['%', '%', '%', ''] }
  }
}

这段代码解决了一个常见问题:同一张结果页复用到多个实验时,如果表头不变,用户会误解指标含义。培养菌落时第二列叫“菌落数”,酵母发酵时第二列叫“产气量”,细胞生长周期时第二列叫“分裂率”。它们共用数据结构,但展示语义不同。

结果页读取模拟页传来的参数后会构建表格:

aboutToAppear(): void {
  const params = router.getParams() as ResultRouterParams | undefined
  if (params?.expId) this.expId = params.expId
  if (params?.expName) this.expName = params.expName
  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 (params?.paramSummary) this.paramSummary = params.paramSummary
  this.tableData = this.buildTableData()
}

也就是说,模拟页负责“算出最终状态”,结果页负责“解释最终状态”。这个边界比在结果页重新计算一遍培养过程更可靠。

七、结论模板把失败原因讲出来

getConclusion() 根据 expId 生成差异化结论。细菌培养的结论会根据污染指数给出建议:

case 'bacteria_culture':
  return `培养至${p}%时菌落覆盖${a}%,污染指数${c}%,最终菌落纯度约${s}%。${
    c >= 30
      ? '污染偏高,建议核查培养箱密封性与无菌指数。'
      : `当前${ctx.temperature}℃培养温度有利于菌落均匀生长。`
  }`

无菌操作也会根据污染情况给出不同反馈:

case 'sterile_operation':
  return `完成${p}%操作步骤,规范度${a}%、污染${c}%,总评${s}分。${
    c >= 25
      ? '开盖时长过长导致污染,建议缩短开盖时间并加强消毒覆盖。'
      : '操作链路稳定,可挑战更短开盖时长以提升等级。'
  }`

这类模板不是简单夸奖用户,而是把失败边界反馈出来。对于教育类模拟应用,结论最好回答三个问题:当前参数是什么、过程指标是什么、下一次应该怎么调。源码里的 paramSummarytemperatureactivitycontaminationsuccessRate 正好支撑这些问题。

八、记录层只保存可追溯字段

模拟完成后,页面会调用 persistRecord(),把当前实验和参数摘要写入记录:

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

这里保存的是实验 id、标题、分类和参数摘要,而不是 Canvas 图片或完整运行帧。数据层 DataStore.appendRecord() 用 Preferences 保存 JSON 数组:

static async appendRecord<T>(record: T): Promise<void> {
  try {
    const list = await DataStore.loadRecords<T>()
    list.unshift(record)
    await DataStore.putString('experiment_records', JSON.stringify(list))
  } catch (_) {
  }
}

这适合本地教育工具:记录轻量、可排序、可恢复,且不依赖网络。发布材料也应该基于这个事实描述为“本地实验记录”,不要写成云端同步或在线实验报告。

九、培养实验的验证清单

基于源码,05-02 的核心验证可以拆成下面几组:

验证项 操作 预期
卡片入口 打开实验列表 培养类实验显示名称、描述、分类、等级
细菌培养参数 进入 bacteria_culture 默认温度 37℃,无菌指数 86%
无菌操作参数 进入 sterile_operation 默认开盖 4s,消毒覆盖 90%
运行过程 点击开始实验 进度推进,Canvas 和指标同步刷新
污染边界 降低无菌指数或提高开盖时长 污染指标上升,结果结论切换
结果页表头 完成不同培养实验 表头随实验类型改变
记录落盘 完成实验后查看记录 参数摘要和时间戳存在
返回退出 运行中离开页面 计时器停止,学习时长保存

这张清单直接对应源码字段和函数,适合做回归测试。尤其要覆盖污染边界,因为它决定结论模板是否能真正解释失败原因。

十、常见问题和排查方向

问题 优先查看 修复方向
进入实验后参数不对 Experiment.etsinitExperiment() 确认 expId 和模型 id 一致
培养皿动画不变 progressdrawCultureScene() 确认计时器推进和 Canvas 重绘
污染指标不符合预期 getContamination() 检查归一化参数和 clamp 范围
结果页表头泛化 getFeedbackSchema() 为新增实验补独立 schema
结论不能解释失败 getConclusion() 按污染、活性、成功率分支补建议
记录没有保存 persistRecord()DataStore.appendRecord() 检查 Preferences 初始化和 JSON 解析

如果后续扩展真实数据采集或相机识别,就要重新定义能力边界、权限和隐私材料。当前源码是本地模拟,不需要额外声明摄像头、网络或云服务能力。

十一、总结

细胞工坊的培养实验之所以值得写成 HarmonyOS 技术文章,是因为它把教育模拟做成了可复查状态链路:入口卡片展示实验边界,模型层定义培养条件,模拟页用 Canvas 表达过程,用指标函数解释污染和活性,结果页用差异化表头和结论模板说明失败原因,数据层只保存轻量记录。

这种结构比“一个页面里堆动画和文字”更稳定。以后增加新的培养实验,只要补充实验模型、绘制分支、指标公式、反馈表头和结论模板,就能保持同一套运行和记录链路。对于 HarmonyOS ArkTS 应用来说,这就是可维护的多实验模拟架构。

部分内容由AI辅助生成

Logo

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

更多推荐