显微观察模拟最怕做成一张“看起来像实验”的静态图。用户点开始以后,显微镜、样本、染色强度、观察进度和最终结果如果没有真实状态支撑,页面再好看也很难维护。细胞工坊的 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)
}

这组状态把页面拆成几个明确边界:expIdtitle 决定当前实验,paramDefsparamValues 决定用户可调参数,progressisRunning 决定实验推进,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() 会保存 experimentIdexperimentNamecategory 和参数摘要。如果实验模型没有稳定 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 只负责把状态画出来,状态来源仍然是 progressparamValuesexpId 等页面字段。显微观察场景里,代码会绘制显微镜、载玻片、细胞簇和观察圈,并让观察圈跟随进度变化。

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,让画面和数据保持同步。第四,进度完成后统一落盘学习时长、实验次数和实验记录。

显微观察模拟里的“运行中”“暂停”“已完成”不是文字状态,而是由 isRunningisFinishedprogress 共同决定。这样的状态结构便于在 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辅助生成

Logo

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

更多推荐