尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

【细胞工坊|11】HarmonyOS ArkTS Canvas 可视化实战:控制实验图形刷新并降低无效重绘

【细胞工坊|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 实验页的核心问题什么时候画什么时候不画实验模拟页和普通列表页不一样。列表页主要是状态驱动 UICanvas 页还要管理绘制上下文、尺寸、定时器和每一帧的图形内容。如果不控制刷新入口很容易出现几个问题问题页面表现当前源码的防线Canvas 尺寸还没 ready 就绘制首帧空白或绘制坐标错误drawCanvas()先判断canvasWidth 0多次点击开始产生多个定时器进度异常加速CPU 占用上升startTimer()先调用stopTimer()离开页面后定时器继续跑后台仍更新状态学习时长不准aboutToDisappear()调用stopTimer()拖动参数时和运行帧同时绘制参数变化与动画帧互相抢状态Slider 运行中禁用且 onChange 只在未运行时重绘每个实验写一套 Canvas 外壳维护困难重复清屏和 HUDdrawCanvas()统一公共层再按expId分发场景当前源码不是“性能监控系统”但它已经有一套实用刷新边界首帧、重置、定时器 tick、未运行参数变更这四类入口才触发绘制。2. 页面状态Canvas 可视化依赖的不是一个 progressExperimentSimPage的关键状态如下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~1speed控制定时器每次推进的步长displayValues页面指标条展示的数据paramValuesSlider 参数值影响实验计算和图形timerId定时器句柄负责防重复启动和清理canvasCtxCanvas 2D 绘制上下文canvasWidth/canvasHeightCanvas 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: Recordstring, () 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)和textOverflowCanvas 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 更新运行中参数开始后查看 SliderSlider 禁用避免运行中改参结束等进度到 100%停止 timer写入实验次数和记录这类测试最好不要只看页面是否“动起来”。重点要看不该动的时候是否停住离页后是否停住重置时是否同步刷新。19. 常见问题与修复方向问题可能原因修复方向Canvas 首帧空白onReady()前调用绘制保留canvasWidth 0防线并在onReady()后绘制动画越来越快多次启动产生多个定时器startTimer()先stopTimer()返回后仍耗电离页未清理 intervalaboutToDisappear()必须调用stopTimer()参数拖动时画面抖动Slider 和 timer 同时触发绘制运行中禁用 Slider或仅未运行时手动绘制结束后还在写记录进度到 1 后没停 timer到达终点后stopTimer()并设置isFinishedHUD 标题超出Canvas 文本没有自动省略缩短标题、增加measureText或把标题放到 ArkUI新实验显示默认图expId没有加入 switch新增绘制函数并更新分发分支20. 小结Canvas 优化先从刷新入口开始ExperimentSimPage的 Canvas 实现不是复杂渲染引擎但它有清楚的工程边界。绘制入口集中在onReady()、resetExperiment()、startTimer()的 tick以及未运行时的 Slider 变化。定时器通过stopTimer()统一清理暂停、重置、离页都会停止后台刷新。drawCanvas()统一处理清屏、背景、公共层、场景分发和 HUD让各实验绘制函数只关注自身画面。从 HarmonyOS ArkTS 项目的角度看这种写法适合教学模拟类页面先保证生命周期正确、绘制入口可控、状态和画面同步再考虑更重的优化。当前源码支持定时器驱动和刷新门控没有requestAnimationFrame、脏矩形、帧率统计或性能采样数据。
返回列表