【细胞工坊|02】HarmonyOS ArkTS 细胞培养实验实战:组织培养条件、过程反馈与失败边界
细胞培养实验如果只做成“培养皿里长出几个点”的动画,用户很难知道自己调了什么、风险从哪里来、为什么最后得到某个纯度或评分。细胞工坊的源码没有接入真实培养设备,也没有云端实验数据,但它用 HarmonyOS ArkTS 做了一套完整的模拟闭环:实验卡片负责入口展示,实验模型定义培养条件,模拟页用 Canvas 绘制培养过程,指标函数把温度、无菌指数、开盖时长、消毒覆盖、营养水平和环境压力转成污染、活性、成功率,结果页再用差异化表头和结论模板解释实验反馈。
这篇文章基于 D:\huawei\one14-9 的真实源码,重点拆解培养相关实验:bacteria_culture、sterile_operation、cell_growth_cycle 和 yeast_fermentation。它们都不是泛泛的科普文案,而是有明确参数边界、过程绘制、指标计算和结果反馈的 ArkTS 页面状态。本文不会把它夸大成真实生物检测能力;它解决的是教育类 HarmonyOS 应用里更实际的问题:怎样让模拟培养条件、过程反馈和失败边界都能从源码复查。

本文会覆盖五个工程问题:
ExperimentCard如何展示培养实验入口和收藏动作。Experiment.ets如何定义培养温度、无菌指数、开盖时长等参数。ExperimentSimPage如何用进度和参数计算污染、活性、成功率。- Canvas 如何区分培养皿、无菌操作、细胞生长和酵母发酵场景。
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 声明了 phone、tablet 和 2in1,入口卡片不能因为描述过长把收藏按钮或分类标签挤出屏幕。培养实验的入口要简洁,真正复杂的参数和反馈放到模拟页和结果页。
二、培养条件来自模型,不来自控件硬编码
细胞培养相关实验定义在 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 }
]
}
这段模型把培养温度和无菌指数写成明确边界。滑杆只读取 min、max、defaultValue、step,不自己定义“温度范围应该是多少”。这样做的好处是,后续结果页和记录页都能回到同一组参数。
无菌操作挑战则定义了另一类培养风险:
{
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 分派场景
ExperimentSimPage 的 drawCanvas() 里有一个清晰的场景分派。培养相关 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
? '开盖时长过长导致污染,建议缩短开盖时间并加强消毒覆盖。'
: '操作链路稳定,可挑战更短开盖时长以提升等级。'
}`
这类模板不是简单夸奖用户,而是把失败边界反馈出来。对于教育类模拟应用,结论最好回答三个问题:当前参数是什么、过程指标是什么、下一次应该怎么调。源码里的 paramSummary、temperature、activity、contamination 和 successRate 正好支撑这些问题。
八、记录层只保存可追溯字段
模拟完成后,页面会调用 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.ets 和 initExperiment() |
确认 expId 和模型 id 一致 |
| 培养皿动画不变 | progress、drawCultureScene() |
确认计时器推进和 Canvas 重绘 |
| 污染指标不符合预期 | getContamination() |
检查归一化参数和 clamp 范围 |
| 结果页表头泛化 | getFeedbackSchema() |
为新增实验补独立 schema |
| 结论不能解释失败 | getConclusion() |
按污染、活性、成功率分支补建议 |
| 记录没有保存 | persistRecord() 和 DataStore.appendRecord() |
检查 Preferences 初始化和 JSON 解析 |
如果后续扩展真实数据采集或相机识别,就要重新定义能力边界、权限和隐私材料。当前源码是本地模拟,不需要额外声明摄像头、网络或云服务能力。
十一、总结
细胞工坊的培养实验之所以值得写成 HarmonyOS 技术文章,是因为它把教育模拟做成了可复查状态链路:入口卡片展示实验边界,模型层定义培养条件,模拟页用 Canvas 表达过程,用指标函数解释污染和活性,结果页用差异化表头和结论模板说明失败原因,数据层只保存轻量记录。
这种结构比“一个页面里堆动画和文字”更稳定。以后增加新的培养实验,只要补充实验模型、绘制分支、指标公式、反馈表头和结论模板,就能保持同一套运行和记录链路。对于 HarmonyOS ArkTS 应用来说,这就是可维护的多实验模拟架构。
部分内容由AI辅助生成
更多推荐



所有评论(0)