ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

【细胞工坊|01】HarmonyOS ArkTS 显微观察模拟实战:把缩放、样本与观察结果写成可复查状态

【细胞工坊|01】HarmonyOS ArkTS 显微观察模拟实战:把缩放、样本与观察结果写成可复查状态

显微观察模拟最怕做成一张“看起来像实验”的静态图。用户点开始以后,显微镜、样本、染色强度、观察进度和最终结果如果没有真实状态支撑,页面再好看也很难维护。细胞工坊的ExperimentSimPage.ets给了一个更适合 HarmonyOS ArkTS 项目的做法:用实验模型定义参数边界,用 ArkUI 状态承接用户输入,用 Canvas 绘制动态视野,用计时器推进进度,用数据层保存收藏、学习时长和实验记录。

这篇文章只围绕D:\huawei\one14-9的真实源码展开,主线是默认实验microscope_observation,也就是“显微镜观察洋葱表皮”。源码里并没有端侧 AI 识别、真实摄像头采集或云端实验平台,所以本文不会伪造成那些能力。它解决的是另一个更常见的工程问题:一个 HarmonyOS 教育类模拟页面,怎样把缩放、样本状态、观察指标和结果页参数写成可以回读、可以复盘、可以回归测试的状态链路。

本文会从五个可复核点切入:

  1. Experiment.ets如何定义显微观察实验和参数范围。
  2. ExperimentSimPage.ets如何从路由参数初始化页面状态。
  3. Canvas 如何根据进度和参数绘制显微镜观察场景。
  4. 计时器如何推进进度、更新指标并生成结果。
  5. 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 保存学习记录。每一层都能从源码复核。

这类页面的关键不是“画得像不像显微镜”,而是“状态能不能解释画面和结果”。只要缩放、染色强度、进度、样本状态、成功率、污染指数和记录都能追溯到同一套状态链路,后续做回归测试、发布审核和功能扩展都会更稳。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进