【细胞工坊|11】HarmonyOS ArkTS Canvas 可视化实战:控制实验图形刷新并降低无效重绘
部分内容由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 后的真实尺寸 |
这里最容易被忽略的是 canvasWidth 和 canvasHeight。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() 是第一个明确绘制入口。它做三件事:
- 从
canvasCtx.width读取实际宽度; - 从
canvasCtx.height读取实际高度; - 调用
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() 的顺序很清楚:
- 尺寸未准备好时直接返回;
- 全画布清屏;
- 绘制渐变背景;
- 绘制公共网格;
- 绘制实验台;
- 根据
expId绘制具体实验图形; - 绘制 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 仍然在推进 progress 和 drawCanvas();再次进入页面又开一个 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、脏矩形、帧率统计或性能采样数据。
更多推荐


所有评论(0)