
显微观察模拟最怕做成一张“看起来像实验”的静态图。用户点开始以后显微镜、样本、染色强度、观察进度和最终结果如果没有真实状态支撑页面再好看也很难维护。细胞工坊的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.etsexport 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.etsexport 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 } }数据层使用 Preferencesstatic async appendRecordT(record: T): Promisevoid { try { const list await DataStore.loadRecordsT() list.unshift(record) await DataStore.putString(experiment_records, JSON.stringify(list)) } catch (_) { } }这里没有把Resource、Canvas 对象或页面状态直接写入持久化。StoredExperimentRecord只保存实验 id、名称、分类、参数摘要和时间戳视图层需要图标时再通过storedToRecord()从实验模型找回。这是一个值得保留的边界持久化数据尽量保持 JSON 结构UI 资源留在视图模型转换阶段。十、可执行的验证清单基于源码显微观察模拟至少要验证这些路径验证项操作预期默认进入不传路由参数打开页面标题为“显微镜观察洋葱表皮”参数初始化查看滑杆放大倍数 100x染色强度 60%参数调整拖动滑杆指标和 Canvas 在未运行时刷新开始运行点击开始实验isRunningtrue进度持续增长重复点击快速点开始/暂停不产生多个计时器完成实验等待进度到 100%isFinishedtrue生成记录结果页点击查看实验反馈参数、指标、阶段带到结果页退出页面返回或离开停止计时器并保存学习时长这些用例都能从源码字段对应出来不需要猜测。尤其是重复点击和退出页面容易被忽略但它们直接关系到计时器泄漏和学习时长统计是否准确。十一、常见问题和修复方向问题源码定位处理方向页面空白canvasWidth 0或页面未触发onReady检查 Canvas 尺寸和生命周期指标不刷新updateDisplay()未执行检查滑杆回调和计时器结果页参数不一致buildResultParams()或路由参数缺失统一由模拟页构建最终参数历史记录丢失Preferences 未初始化或 JSON 解析失败检查DataStore.init()和兜底重复计时多个 interval 未清理启动前调用stopTimer()运行中调参滑杆未禁用保持.enabled(!this.isRunning)如果要继续增强可以考虑把catch (_) {}里的错误做日志记录至少在调试版本里输出数据层失败原因。当前源码选择静默兜底优点是用户流程不容易被 Preferences 异常打断缺点是排查持久化问题时证据较少。十二、总结细胞工坊的显微观察模拟适合写成 HarmonyOS 技术文章是因为它不是泛泛的 UI 页面而是有真实工程边界实验模型定义参数页面状态承接输入Canvas 输出可视化计时器推进过程结果页消费最终参数DataStore 保存学习记录。每一层都能从源码复核。这类页面的关键不是“画得像不像显微镜”而是“状态能不能解释画面和结果”。只要缩放、染色强度、进度、样本状态、成功率、污染指数和记录都能追溯到同一套状态链路后续做回归测试、发布审核和功能扩展都会更稳。