【细胞工坊|01】HarmonyOS ArkTS 显微观察模拟实战:把缩放、样本与观察结果写成可复查状态
显微观察模拟最怕做成一张“看起来像实验”的静态图。用户点开始以后,显微镜、样本、染色强度、观察进度和最终结果如果没有真实状态支撑,页面再好看也很难维护。细胞工坊的 ExperimentSimPage.ets 给了一个更适合 HarmonyOS ArkTS 项目的做法:用实验模型定义参数边界,用 ArkUI 状态承接用户输入,用 Canvas 绘制动态视野,用计时器推进进度,用数据层保存收藏、学习时长和实验记录。
这篇文章只围绕 D:\huawei\one14-9 的真实源码展开,主线是默认实验 microscope_observation,也就是“显微镜观察洋葱表皮”。源码里并没有端侧 AI 识别、真实摄像头采集或云端实验平台,所以本文不会伪造成那些能力。它解决的是另一个更常见的工程问题:一个 HarmonyOS 教育类模拟页面,怎样把缩放、样本状态、观察指标和结果页参数写成可以回读、可以复盘、可以回归测试的状态链路。

本文会从五个可复核点切入:
Experiment.ets如何定义显微观察实验和参数范围。ExperimentSimPage.ets如何从路由参数初始化页面状态。- Canvas 如何根据进度和参数绘制显微镜观察场景。
- 计时器如何推进进度、更新指标并生成结果。
DataStore.ets如何保存收藏、学习时长和实验记录。


一、先确认这不是静态科普页
细胞工坊的显微观察页面不是简单展示一段科普文案。ExperimentSimPage 里维护了运行状态、参数值、进度、指标、收藏状态、Canvas 上下文和学习时间。页面进入时读取路由参数,退出时停止计时器并持久化学习时长。
@Entry
@Component
struct ExperimentSimPage {
@State title: string = '显微镜观察洋葱表皮'
@State expId: string = 'microscope_observation'
@State isRunning: boolean = false
@State isFinished: boolean = false
@State speed: number = 1.0
@State displayValues: string[][] = []
@State progress: number = 0
@State paramValues: number[] = []
@State paramDefs: ExperimentParam[] = []
@State isFavorite: boolean = false
private timerId: number = -1
private canvasCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
}
这组状态把页面拆成几个明确边界:expId 和 title 决定当前实验,paramDefs 和 paramValues 决定用户可调参数,progress 和 isRunning 决定实验推进,displayValues 决定指标展示,canvasCtx 决定绘制输出。后续所有 UI、Canvas 和结果页参数都从这里派生,而不是在每个控件里临时计算一份。
这对 HarmonyOS 页面很重要。ArkUI 是声明式 UI,页面可维护性取决于状态是否清楚。如果显微镜图形、滑杆、指标卡和结果页各自维护独立状态,用户暂停、重置、切换倍速或返回时,就很容易出现“画面停了但指标还在变”“结果页参数和滑杆不一致”这类问题。
二、实验模型定义参数边界
显微观察的默认实验定义在 Experiment.ets:
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[]
}
microscope_observation 的参数不是硬编码在滑杆控件里,而是作为模型的一部分:
{
id: 'microscope_observation',
name: '显微镜观察洋葱表皮',
description: '拖放显微镜、载玻片和染色液,观察植物细胞结构。',
category: '基础',
level: '初级',
icon: $r('app.media.ic_bio_microscope'),
isFavorite: false,
params: [
{ name: '放大倍数', unit: 'x', min: 40, max: 400, defaultValue: 100, step: 20 },
{ name: '染色强度', unit: '%', min: 0, max: 100, defaultValue: 60, step: 5 }
]
}
这段模型承担了三个工程职责。
第一,控制输入边界。放大倍数的范围是 40 到 400,步长 20;染色强度的范围是 0 到 100,步长 5。滑杆只负责呈现和修改值,不负责发明边界。
第二,让多个实验复用同一页面。源码中 getAllExperiments() 返回了显微观察、细菌培养、DNA 提取、PCR 扩增、血型鉴定、渗透压观察等 18 个实验。页面通过 expId 找到当前实验,不需要为每个实验复制一套页面。
第三,给结果记录提供稳定上下文。后续 buildRecord() 会保存 experimentId、experimentName、category 和参数摘要。如果实验模型没有稳定 id,历史记录就很难回溯到真实实验。
三、页面进入时把路由参数合并到默认状态
aboutToAppear() 是显微观察模拟的入口。它先读取路由参数,再初始化实验,最后重置进度和加载收藏状态。
aboutToAppear(): void {
const params = router.getParams() as SimRouterParams | undefined
if (params?.expId) {
this.expId = params.expId
}
if (params?.expName) {
this.title = params.expName
}
if (params?.kpTitle) {
this.kpTitle = params.kpTitle
}
if (params?.kpSummary) {
this.kpSummary = params.kpSummary
}
this.initExperiment()
this.resetExperiment()
this.loadFavoriteState()
}
这里的顺序很关键。先接收路由参数,再根据 expId 初始化模型,否则页面可能先加载默认显微观察参数,随后又被路由参数覆盖标题,造成标题和参数不一致。initExperiment() 的职责是取当前实验定义并初始化参数值:
private initExperiment(): void {
const exp = this.currentExperiment()
this.title = exp.name
this.paramDefs = exp.params
this.paramValues = this.paramDefs.map((p: ExperimentParam) => p.defaultValue)
}
currentExperiment() 还做了兜底:如果传入的 expId 找不到,就返回实验列表第一项。这种兜底适合教育类应用,因为它能避免错误路由导致空白页。
private currentExperiment(): Experiment {
const found = getAllExperiments().find((item: Experiment) => item.id === this.expId)
return found ? found : getAllExperiments()[0]
}
这不是“容错文案”,而是真实代码行为。文章里的复查点也应该落在这里:传入不存在的实验 id 时,页面应回退到默认实验;传入 microscope_observation 时,页面应加载放大倍数和染色强度两个参数。
四、Canvas 不是装饰,而是状态输出
显微观察模拟使用 Canvas 绘制实验场景。drawCanvas() 不是只画背景,它先清屏、绘制实验室网格和台面,然后根据 expId 分发到不同场景。显微观察会进入 drawMicroscopeScene()。
private drawCanvas(): void {
if (this.canvasWidth === 0) return
const ctx = this.canvasCtx
const w = this.canvasWidth
const h = this.canvasHeight
ctx.clearRect(0, 0, w, h)
const bg = ctx.createLinearGradient(0, 0, 0, h)
bg.addColorStop(0, '#0B1120')
bg.addColorStop(0.56, '#0F172A')
bg.addColorStop(1, '#07111F')
ctx.fillStyle = bg
ctx.fillRect(0, 0, w, h)
this.drawGrid(ctx, w, h)
this.drawLabBench(ctx, w, h)
switch (this.expId) {
case 'microscope_observation': this.drawMicroscopeScene(ctx, w, h); break
default: this.drawMicroscopeScene(ctx, w, h); break
}
this.drawHud(ctx, w)
}
这段代码说明了一个重要边界:Canvas 只负责把状态画出来,状态来源仍然是 progress、paramValues、expId 等页面字段。显微观察场景里,代码会绘制显微镜、载玻片、细胞簇和观察圈,并让观察圈跟随进度变化。
private drawMicroscopeScene(ctx: CanvasRenderingContext2D, w: number, h: number): void {
const cx = w * 0.38
const cy = h * 0.50
ctx.strokeStyle = '#00D9FF'
ctx.lineWidth = 6
ctx.beginPath()
ctx.moveTo(cx - 18, cy - 60)
ctx.lineTo(cx - 34, cy - 8)
ctx.lineTo(cx + 22, cy - 8)
ctx.stroke()
ctx.fillStyle = '#00FFB2'
ctx.fillRect(cx - 50, cy + 2, 100, 8)
this.drawCellCluster(ctx, w * 0.68, h * 0.48, 58, '#00FFB2')
ctx.strokeStyle = '#00D9FF'
ctx.lineWidth = 2
ctx.beginPath()
ctx.arc(w * 0.68, h * 0.48, 64 + this.progress * 10, 0, Math.PI * 2)
ctx.stroke()
}
如果要做回归测试,可以直接盯住三个状态:canvasWidth 为 0 时不绘制;progress 增长时观察圈半径变化;重置后 progress 回到 0,画面回到初始状态。这样测试的是状态和绘制关系,而不是截图像不像。
五、运行计时器推进进度和指标
点击开始后,页面通过 startExperiment() 切换运行状态并启动计时器:
private startExperiment(): void {
if (this.isFinished) {
this.resetExperiment()
}
this.isRunning = true
if (this.learningStartedAt === 0) {
this.learningStartedAt = Date.now()
}
this.startTimer()
}
真正推进进度的是 startTimer():
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)
}
这段代码有几个值得复用的点。
第一,启动计时器前先 stopTimer()。这能避免重复点击开始后产生多个 interval。第二,进度使用 Math.min(1, ...) 限制上限,避免超过 100%。第三,每一帧都更新指标和 Canvas,让画面和数据保持同步。第四,进度完成后统一落盘学习时长、实验次数和实验记录。
显微观察模拟里的“运行中”“暂停”“已完成”不是文字状态,而是由 isRunning、isFinished 和 progress 共同决定。这样的状态结构便于在 UI、Canvas、结果页和数据层之间传递。
六、指标计算来自参数和进度
页面的指标卡由 updateDisplay() 生成:
private updateDisplay(): void {
const temp = this.getTemperature()
const activity = this.getActivity()
const contamination = this.getContamination()
const success = this.clamp(96 - contamination * 0.45 + activity * 0.18 + this.progress * 12, 0, 100)
this.displayValues = [
['样本状态', this.getStageLabel()],
['温度', `${temp.toFixed(temp < 10 ? 1 : 0)}℃`],
['活性', `${activity.toFixed(0)}%`],
['污染指数', `${contamination.toFixed(0)}%`],
['成功率', `${success.toFixed(0)}%`]
]
}
显微观察的阶段标签由进度决定:
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 '实验完成'
}
这让模拟页面具备可解释性。用户不是只看到一个进度条,而是能看到样本状态、温度、活性、污染指数和成功率如何跟着实验过程变化。对于教育类 HarmonyOS 应用,这比纯动画更有价值,因为结果页可以继续使用这些指标生成反馈曲线和实验结论。
需要注意的是,源码中的指标是模拟值,不是实验设备采集值。文章和上架材料都应该写成“模拟观察”“虚拟实验”“参数推演”,不能写成“真实检测”“自动识别真实样本”。
七、参数滑杆和运行状态要互相约束
页面使用 ForEach(this.paramDefs, ...) 渲染滑杆。每个滑杆来自模型参数,改变时更新 paramValues,并在非运行状态下刷新指标和 Canvas。
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)
这里的 .enabled(!this.isRunning) 很关键。实验运行中禁止调参,可以减少状态竞争:如果用户一边运行一边快速拖动放大倍数或染色强度,计时器和滑杆事件会同时更新 Canvas,结果页参数也可能变得不可解释。
更稳的交互策略是:运行前允许调参,运行中只允许暂停、倍速和观察,完成后再重置或查看结果。源码正是这么做的。发布前复查时,也应该覆盖“运行中不可调节”的状态,确认滑杆视觉和禁用行为一致。
八、结果页参数由模拟页统一构建
实验完成后,页面会显示“查看实验反馈”按钮,并通过 router.pushUrl() 进入结果页。结果页参数来自 buildResultParams():
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()
}
}
结果页 ExperimentResultPage.ets 会读取这些参数:
aboutToAppear(): void {
const params = router.getParams() as ResultRouterParams | undefined
if (params?.expId) this.expId = params.expId
if (params?.expName) this.expName = params.expName
if (params?.category) this.category = params.category
if (typeof params?.progress === 'number') this.progress = params.progress
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 (typeof params?.temperature === 'number') this.temperature = params.temperature
if (params?.stage) this.stage = params.stage
if (params?.paramSummary) this.paramSummary = params.paramSummary
this.tableData = this.buildTableData()
}
这种设计的优点是结果页不重新模拟实验,它只消费模拟页传来的最终状态,再生成表格和曲线。边界清楚以后,问题也更容易定位:如果结果页数值不对,先看 buildResultParams();如果模拟过程不对,先看 updateDisplay() 和进度推进;如果图形不对,先看 Canvas 绘制。
九、实验记录和学习时长落在 DataStore
模拟完成后,页面会调用 DataStore.incrementExperimentCount() 和 persistRecord()。记录结构定义在 ExperimentRecord.ets:
export interface StoredExperimentRecord {
id: string
experimentId: string
experimentName: string
category: string
sceneName: string
paramSummary: string
timestamp: string
}
buildRecord() 只保存可 JSON 序列化字段:
export function buildRecord(
experimentId: string,
experimentName: string,
category: string,
paramSummary: string
): StoredExperimentRecord {
const now = new Date()
return {
id: 'r_' + now.getTime(),
experimentId,
experimentName,
category,
sceneName: '',
paramSummary,
timestamp: ts
}
}
数据层使用 Preferences:
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 (_) {
}
}
这里没有把 Resource、Canvas 对象或页面状态直接写入持久化。StoredExperimentRecord 只保存实验 id、名称、分类、参数摘要和时间戳,视图层需要图标时再通过 storedToRecord() 从实验模型找回。这是一个值得保留的边界:持久化数据尽量保持 JSON 结构,UI 资源留在视图模型转换阶段。
十、可执行的验证清单
基于源码,显微观察模拟至少要验证这些路径:
| 验证项 | 操作 | 预期 |
|---|---|---|
| 默认进入 | 不传路由参数打开页面 | 标题为“显微镜观察洋葱表皮” |
| 参数初始化 | 查看滑杆 | 放大倍数 100x,染色强度 60% |
| 参数调整 | 拖动滑杆 | 指标和 Canvas 在未运行时刷新 |
| 开始运行 | 点击开始实验 | isRunning=true,进度持续增长 |
| 重复点击 | 快速点开始/暂停 | 不产生多个计时器 |
| 完成实验 | 等待进度到 100% | isFinished=true,生成记录 |
| 结果页 | 点击查看实验反馈 | 参数、指标、阶段带到结果页 |
| 退出页面 | 返回或离开 | 停止计时器并保存学习时长 |
这些用例都能从源码字段对应出来,不需要猜测。尤其是重复点击和退出页面,容易被忽略,但它们直接关系到计时器泄漏和学习时长统计是否准确。
十一、常见问题和修复方向
| 问题 | 源码定位 | 处理方向 |
|---|---|---|
| 页面空白 | canvasWidth === 0 或页面未触发 onReady |
检查 Canvas 尺寸和生命周期 |
| 指标不刷新 | updateDisplay() 未执行 |
检查滑杆回调和计时器 |
| 结果页参数不一致 | buildResultParams() 或路由参数缺失 |
统一由模拟页构建最终参数 |
| 历史记录丢失 | Preferences 未初始化或 JSON 解析失败 | 检查 DataStore.init() 和兜底 |
| 重复计时 | 多个 interval 未清理 | 启动前调用 stopTimer() |
| 运行中调参 | 滑杆未禁用 | 保持 .enabled(!this.isRunning) |
如果要继续增强,可以考虑把 catch (_) {} 里的错误做日志记录,至少在调试版本里输出数据层失败原因。当前源码选择静默兜底,优点是用户流程不容易被 Preferences 异常打断,缺点是排查持久化问题时证据较少。
十二、总结
细胞工坊的显微观察模拟适合写成 HarmonyOS 技术文章,是因为它不是泛泛的 UI 页面,而是有真实工程边界:实验模型定义参数,页面状态承接输入,Canvas 输出可视化,计时器推进过程,结果页消费最终参数,DataStore 保存学习记录。每一层都能从源码复核。
这类页面的关键不是“画得像不像显微镜”,而是“状态能不能解释画面和结果”。只要缩放、染色强度、进度、样本状态、成功率、污染指数和记录都能追溯到同一套状态链路,后续做回归测试、发布审核和功能扩展都会更稳。
部分内容由AI辅助生成
更多推荐




所有评论(0)