部分内容由AI辅助生成。

本文面向 HarmonyOS 5.0 及以上版本,基于 细胞工坊 项目真实源码展开,源码根目录为 D:\huawei\one14-9。本文重点复核:

  • entry/src/main/ets/views/experiment/ExperimentSimPage.ets

这篇文章只讨论源码已经实现的 Canvas 可视化链路:CanvasRenderingContext2D 初始化、Canvas.onReady() 获取尺寸、setInterval(..., 16) 推进实验进度、drawCanvas() 统一清屏和绘制、按 expId 分发不同实验场景、暂停/离页时清理定时器、参数滑动时只在未运行状态下重绘。源码没有使用 requestAnimationFrame、脏矩形、帧率统计、性能采样、离屏 Canvas 或 GPU 指标,文章不会把这些写成已实现能力。

封面图

1. Canvas 实验页的核心问题:什么时候画,什么时候不画

实验模拟页和普通列表页不一样。列表页主要是状态驱动 UI,Canvas 页还要管理绘制上下文、尺寸、定时器和每一帧的图形内容。如果不控制刷新入口,很容易出现几个问题:

问题 页面表现 当前源码的防线
Canvas 尺寸还没 ready 就绘制 首帧空白或绘制坐标错误 drawCanvas() 先判断 canvasWidth === 0
多次点击开始产生多个定时器 进度异常加速,CPU 占用上升 startTimer() 先调用 stopTimer()
离开页面后定时器继续跑 后台仍更新状态,学习时长不准 aboutToDisappear() 调用 stopTimer()
拖动参数时和运行帧同时绘制 参数变化与动画帧互相抢状态 Slider 运行中禁用,且 onChange 只在未运行时重绘
每个实验写一套 Canvas 外壳 维护困难,重复清屏和 HUD drawCanvas() 统一公共层,再按 expId 分发场景

当前源码不是“性能监控系统”,但它已经有一套实用刷新边界:首帧、重置、定时器 tick、未运行参数变更,这四类入口才触发绘制。

2. 页面状态:Canvas 可视化依赖的不是一个 progress

ExperimentSimPage 的关键状态如下:

@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[] = []
private timerId: number = -1
private settings: RenderingContextSettings = new RenderingContextSettings(true)
private canvasCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
private canvasWidth: number = 0
private canvasHeight: number = 0

这些字段分别承担不同职责。

字段 职责
progress 实验动画进度,范围最终被限制到 0~1
speed 控制定时器每次推进的步长
displayValues 页面指标条展示的数据
paramValues Slider 参数值,影响实验计算和图形
timerId 定时器句柄,负责防重复启动和清理
canvasCtx Canvas 2D 绘制上下文
canvasWidth/canvasHeight Canvas ready 后的真实尺寸

这里最容易被忽略的是 canvasWidthcanvasHeight。Canvas 的绘制函数使用的是像素坐标,如果没有拿到真实尺寸就开始画,绘制位置和比例都不可靠。源码在 drawCanvas() 第一行就处理了这个问题。

3. Canvas ready:先拿尺寸,再画首帧

页面构建中的 Canvas 代码:

Canvas(this.canvasCtx)
  .width('100%')
  .height(270)
  .onReady(() => {
    this.canvasWidth = this.canvasCtx.width
    this.canvasHeight = this.canvasCtx.height
    this.drawCanvas()
  })

onReady() 是第一个明确绘制入口。它做三件事:

  1. canvasCtx.width 读取实际宽度;
  2. canvasCtx.height 读取实际高度;
  3. 调用 drawCanvas() 绘制首帧。

这比在 aboutToAppear() 里直接画更稳。aboutToAppear() 只说明页面生命周期到了,不代表 Canvas 已经完成布局。当前源码也确实在 resetExperiment() 里调用 drawCanvas(),但 drawCanvas() 内部有宽度保护,所以 Canvas 未 ready 时不会执行实际绘制。

4. drawCanvas:统一清屏、背景、公共层和场景分发

核心绘制函数:

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
    case 'plant_cell_structure': this.drawPlantCellScene(ctx, w, h); break
    case 'bacteria_culture': this.drawCultureScene(ctx, w, h); break
    case 'sterile_operation': this.drawSterileScene(ctx, w, h); break
    case 'fruit_dna_extraction': this.drawDnaScene(ctx, w, h); break
    default: this.drawMicroscopeScene(ctx, w, h); break
  }

  this.drawHud(ctx, w)
}

源码实际 switch 覆盖了更多实验场景,包括病毒、疫苗、遗传、细胞周期、PCR、渗透压等。这里截取部分分支,是为了说明结构。

drawCanvas() 的顺序很清楚:

  1. 尺寸未准备好时直接返回;
  2. 全画布清屏;
  3. 绘制渐变背景;
  4. 绘制公共网格;
  5. 绘制实验台;
  6. 根据 expId 绘制具体实验图形;
  7. 绘制 HUD 信息层。

这是一种可维护的 Canvas 分层:公共层放在外壳函数里,差异化实验放进独立绘制函数。

5. 全量清屏不是脏矩形,但适合当前页面规模

源码每次绘制都执行:

ctx.clearRect(0, 0, w, h)

这意味着当前实现是全量重绘,不是脏矩形局部重绘。对这个页面来说,全量重绘可以接受,因为 Canvas 高度固定为 270,图形内容也主要是教学可视化元素,而不是海量粒子或复杂 3D 场景。

必须明确:文章不能把它写成“脏矩形优化”。当前源码降低无效重绘的方式不是减少每帧绘制区域,而是减少触发绘制的入口,避免不该画的时候画。

如果未来场景更复杂,可以考虑:

private shouldDrawFrame(): boolean {
  return this.canvasWidth > 0 && (this.isRunning || this.isFinished || this.progress === 0)
}

也可以继续拆分静态背景缓存、动态对象层、HUD 层。但当前源码没有这些机制。

6. 公共背景层:网格和实验台复用

drawGrid() 绘制固定网格:

private drawGrid(ctx: CanvasRenderingContext2D, w: number, h: number): void {
  ctx.strokeStyle = '#123047'
  ctx.lineWidth = 1
  const step = 24
  for (let x = 0; x <= w; x += step) {
    ctx.beginPath()
    ctx.moveTo(x, 0)
    ctx.lineTo(x, h)
    ctx.stroke()
  }
  for (let y = 0; y <= h; y += step) {
    ctx.beginPath()
    ctx.moveTo(0, y)
    ctx.lineTo(w, y)
    ctx.stroke()
  }
}

drawLabBench() 绘制底部实验台和进度条:

private drawLabBench(ctx: CanvasRenderingContext2D, w: number, h: number): void {
  const y = h - 58
  ctx.fillStyle = '#111827'
  ctx.fillRect(0, y, w, 58)
  ctx.strokeStyle = '#00D9FF'
  ctx.lineWidth = 2
  ctx.beginPath()
  ctx.moveTo(0, y)
  ctx.lineTo(w, y)
  ctx.stroke()
  ctx.fillStyle = '#00D9FF22'
  ctx.fillRect(0, y + 2, w * this.progress, 4)
}

这两个函数对所有实验通用。统一背景层的好处是场景函数只关心“实验画什么”,不用重复处理网格、底座和进度条。

7. 场景分发:一个 expId 对应一个绘制函数

drawCanvas() 里的 switch (this.expId) 是场景路由。每个实验 ID 分发到一个绘制函数,例如:

switch (this.expId) {
  case 'microscope_observation':
    this.drawMicroscopeScene(ctx, w, h)
    break
  case 'bacteria_culture':
    this.drawCultureScene(ctx, w, h)
    break
  case 'virus_invasion':
    this.drawVirusScene(ctx, w, h)
    break
  case 'pcr_amplification':
    this.drawPcrScene(ctx, w, h)
    break
  default:
    this.drawMicroscopeScene(ctx, w, h)
    break
}

这个结构适合当前项目:实验数量有限,每个实验画法差异大,直接分函数比配置化绘图库更容易维护。它也有一个明显边界:新增实验时必须同步新增绘制函数和分发分支,否则会落到默认显微镜场景。

如果后续实验数量继续增加,可以把分发表抽成映射:

private drawSceneById(ctx: CanvasRenderingContext2D, w: number, h: number): void {
  const drawers: Record<string, () => void> = {
    microscope_observation: () => this.drawMicroscopeScene(ctx, w, h),
    bacteria_culture: () => this.drawCultureScene(ctx, w, h)
  }
  const draw = drawers[this.expId] ?? drawers.microscope_observation
  draw()
}

当前源码使用 switch,可读性强,也方便单文件快速定位。

流程图

8. 定时器驱动:每 16ms 推进 progress

动画运行入口:

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)
}

这里有三个关键点。

第一,startTimer() 第一行先 stopTimer(),避免重复创建定时器。用户快速多次点击开始按钮时,页面不会叠加多个 interval。

第二,progress 使用 Math.min(1, ...) 限制上限,保证进度不会越过 1 后继续增长。

第三,每个 tick 同步做三件事:更新指标、绘制 Canvas、累加学习时长。到达终点后停止定时器并写入实验次数和实验记录。

当前源码使用的是 setInterval(..., 16),不是 requestAnimationFrame。因此文章只描述“约 16ms 定时器驱动”,不把它写成跟屏幕刷新率同步的动画机制。

9. stopTimer:暂停、重置、离页都要经过同一个清理函数

定时器清理函数:

private stopTimer(): void {
  if (this.timerId !== -1) {
    clearInterval(this.timerId)
    this.timerId = -1
  }
}

它被多个入口复用:

private resetExperiment(): void {
  this.stopTimer()
  this.isRunning = false
  this.isFinished = false
  this.progress = 0
  this.updateDisplay()
  this.drawCanvas()
}

private pauseExperiment(): void {
  this.isRunning = false
  this.stopTimer()
  this.persistLearningTime()
}

aboutToDisappear(): void {
  this.stopTimer()
  this.persistLearningTime()
}

这就是降低无效重绘的核心之一:不运行时没有定时器 tick;离开页面时没有后台绘制;重置前先清掉旧定时器。

如果没有这层清理,Canvas 页面会出现非常难排查的问题:用户已经返回上一页,interval 仍然在推进 progressdrawCanvas();再次进入页面又开一个 interval,进度速度异常;学习时长也可能重复计算。

10. 参数滑动门控:运行中禁用 Slider

参数区的 Slider 有两个限制:

.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)

第一,运行中禁用 Slider。用户不能在实验动画进行时继续改参数。

第二,即使 onChange 被触发,也只有 !this.isRunning 时才更新指标并重绘。这个判断让参数调整和动画 tick 不会同时抢绘制入口。

这不是复杂优化,但很有效。Canvas 页面最怕“所有状态变化都立即画”,尤其是 Slider 连续拖动时会产生大量变化。当前源码把参数重绘限制在未运行状态,运行时由定时器统一推进。

11. 重置首帧:手动重置也要刷新指标和图形

重置函数:

private resetExperiment(): void {
  this.stopTimer()
  this.isRunning = false
  this.isFinished = false
  this.progress = 0
  this.updateDisplay()
  this.drawCanvas()
}

它不是只把 progress 清零,还会刷新指标和 Canvas。这样用户点击重置后,不会出现指标已经回到初始值、Canvas 还停留在上一次结束画面的错位。

这类页面要避免“状态重置了,但画面没重置”。尤其是 Canvas 不是 ArkUI 组件树自动 diff 出来的图形,必须手动重新画。

12. 指标和 Canvas 分开计算,但同一 tick 更新

指标更新逻辑集中在 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)}%`]
  ]
}

命令行输出中部分中文有编码显示问题,但结构能确认:指标来自温度、活性、污染、成功率和阶段标签。startTimer() 中每个 tick 先 updateDisplay(),再 drawCanvas(),这样指标条和图形进度保持一致。

如果指标和 Canvas 分别由不同定时器更新,页面会更难调试。当前源码用一个 tick 同时更新两类输出,是更可控的做法。

13. HUD:图形层也需要状态摘要

HUD 绘制函数:

private drawHud(ctx: CanvasRenderingContext2D, w: number): void {
  ctx.fillStyle = '#0B1120CC'
  ctx.fillRect(12, 12, Math.min(w - 24, 240), 74)
  ctx.strokeStyle = '#00D9FF'
  ctx.strokeRect(12, 12, Math.min(w - 24, 240), 74)
  ctx.fillStyle = '#E5F7FF'
  ctx.font = 'bold 14px sans-serif'
  ctx.fillText(this.title, 24, 36)
  ctx.fillStyle = '#00FFB2'
  ctx.font = '12px sans-serif'
  ctx.fillText(`流程:${this.getStageLabel()}`, 24, 58)
  ctx.fillStyle = '#00D9FF'
  ctx.fillRect(24, 70, (Math.min(w - 48, 190)) * this.progress, 4)
}

HUD 把实验标题、阶段和进度条直接画在 Canvas 上。它和外部指标条不是重复关系:外部指标用于结构化展示,HUD 用于让用户在看图形时也能判断当前阶段。

这里同样没有做文本测量或超长标题裁剪。源码用 Math.min(w - 24, 240) 限制 HUD 背板宽度,但标题文字本身可能超出。当前页面顶部 ArkUI 标题有 maxLines(1)textOverflow,Canvas HUD 则没有类似能力。文章需要指出这个边界,不能把它写成完整文本自适应。

14. Canvas 容器:Stack 叠加状态角标

Canvas 被放在 Stack 里:

Stack() {
  Canvas(this.canvasCtx)
    .width('100%')
    .height(270)
    .onReady(() => {
      this.canvasWidth = this.canvasCtx.width
      this.canvasHeight = this.canvasCtx.height
      this.drawCanvas()
    })

  Row() {
    Text(this.isRunning ? '运行中' : (this.isFinished ? '已完成' : '待启动'))
      .fontSize(11)
      .fontColor(this.isRunning ? AppColors.ACCENT_GREEN : (this.isFinished ? AppColors.PRIMARY : AppColors.TEXT_SECONDARY))
      .fontWeight(AppFonts.WEIGHT_MEDIUM)
  }
  .position({ x: 12, y: 12 })
}
.width('100%')
.height(270)
.borderRadius(16)
.backgroundColor('#0B1120')
.clip(true)

源码输出里状态文字有编码问题,但条件结构明确:运行中、已完成、待启动三个状态用不同颜色展示。

这也是一个分层选择:动态实验图形由 Canvas 画,状态角标由 ArkUI 组件叠加。不是所有东西都要画进 Canvas。对可交互状态、文本截断、可访问性和主题适配来说,能用 ArkUI 的地方尽量用 ArkUI。

结构图

15. 当前实现真实支持的优化点

基于源码,可以把当前 Canvas 刷新控制总结为:

优化点 源码证据 作用
Canvas 未 ready 不绘制 if (this.canvasWidth === 0) return 避免无尺寸绘制
防重复定时器 startTimer()stopTimer() 避免多 interval 叠加
离页清理 aboutToDisappear() 调用 stopTimer() 避免后台继续绘制
暂停清理 pauseExperiment() 调用 stopTimer() 暂停后停止 tick
运行中禁用参数 .enabled(!this.isRunning) 避免动画中频繁改参
参数重绘门控 if (!this.isRunning) drawCanvas() 避免 Slider 与 timer 抢重绘
公共绘制层复用 drawGrid/drawLabBench/drawHud 降低场景函数重复

这些都是真实源码支撑的点。它们不是性能测试结论,也不是引擎级优化。更准确的说法是:当前源码通过生命周期和状态门控,减少明显无效的 Canvas 绘制入口。

16. 当前没有实现的能力边界

文章也要把未实现能力列清楚:

能力 当前源码是否实现 说明
requestAnimationFrame 当前使用 setInterval(..., 16)
脏矩形局部重绘 每次 clearRect(0,0,w,h) 全量清屏
帧率统计 没有 FPS 采样和显示
绘制耗时统计 没有 Date.now() 或 performance 计时
离屏缓存 背景、网格每帧重画
GPU 指标 无相关 API 和数据
文本测量自适应 Canvas HUD 标题未见 measureText

这张表可以防止文章过度包装。当前代码有工程价值,但价值在于“刷新入口控制”和“绘制职责分层”,不是完整性能框架。

17. 可迁移写法:把绘制入口收成 scheduleDraw

如果后续要继续收敛重绘入口,可以在当前源码基础上增加一层调度函数:

private scheduleDraw(reason: string): void {
  if (this.canvasWidth === 0) {
    return
  }
  this.updateDisplay()
  this.drawCanvas()
}

调用处改成:

private resetExperiment(): void {
  this.stopTimer()
  this.isRunning = false
  this.isFinished = false
  this.progress = 0
  this.scheduleDraw('reset')
}

这样未来要加日志、节流或采样时,不必在多个入口重复修改。当前源码还没有 scheduleDraw(),这是可迁移建议,不是现状描述。

18. 验证清单:先测生命周期,再测绘制

验证 Canvas 刷新控制,建议按下面顺序:

验证项 操作 预期
首帧 打开实验模拟页 onReady() 后 Canvas 非空,显示背景和场景
开始 点击开始实验 进度推进,指标和 Canvas 同步变化
暂停 运行中点击暂停 定时器停止,进度不再变化
重置 点击重置 进度归零,Canvas 和指标回到初始状态
离页 运行中返回上一页 aboutToDisappear() 清理 timer
参数变更 未运行时拖动 Slider 参数数值、指标和 Canvas 更新
运行中参数 开始后查看 Slider Slider 禁用,避免运行中改参
结束 等进度到 100% 停止 timer,写入实验次数和记录

这类测试最好不要只看页面是否“动起来”。重点要看不该动的时候是否停住,离页后是否停住,重置时是否同步刷新。

19. 常见问题与修复方向

问题 可能原因 修复方向
Canvas 首帧空白 onReady() 前调用绘制 保留 canvasWidth === 0 防线,并在 onReady() 后绘制
动画越来越快 多次启动产生多个定时器 startTimer()stopTimer()
返回后仍耗电 离页未清理 interval aboutToDisappear() 必须调用 stopTimer()
参数拖动时画面抖动 Slider 和 timer 同时触发绘制 运行中禁用 Slider,或仅未运行时手动绘制
结束后还在写记录 进度到 1 后没停 timer 到达终点后 stopTimer() 并设置 isFinished
HUD 标题超出 Canvas 文本没有自动省略 缩短标题、增加 measureText 或把标题放到 ArkUI
新实验显示默认图 expId 没有加入 switch 新增绘制函数并更新分发分支

20. 小结:Canvas 优化先从刷新入口开始

ExperimentSimPage 的 Canvas 实现不是复杂渲染引擎,但它有清楚的工程边界。

绘制入口集中在 onReady()resetExperiment()startTimer() 的 tick,以及未运行时的 Slider 变化。定时器通过 stopTimer() 统一清理,暂停、重置、离页都会停止后台刷新。drawCanvas() 统一处理清屏、背景、公共层、场景分发和 HUD,让各实验绘制函数只关注自身画面。

从 HarmonyOS ArkTS 项目的角度看,这种写法适合教学模拟类页面:先保证生命周期正确、绘制入口可控、状态和画面同步,再考虑更重的优化。当前源码支持定时器驱动和刷新门控;没有 requestAnimationFrame、脏矩形、帧率统计或性能采样数据。

Logo

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

更多推荐