在这里插入图片描述

当Canvas遇到性能瓶颈

在HarmonyOS NEXT的图形开发中,Canvas是一个绕不开的能力。很多人在第一次做复杂绘图界面时,会发现一个很现实的问题:少量图形跑起来很流畅,一旦元素数量超过几百个,帧率直接掉到20帧以下。

这个问题的核心不在于Canvas本身能不能画,而在于怎么画。默认情况下每次调用绘图API都会触发ArkUI的渲染管线,如果每帧都重复绘制大量静态内容,性能自然扛不住。

这一篇内容会从实际代码出发,把一个明显卡顿的绘制场景,逐步优化到稳定60fps。重点不是讲概念,而是给出可复现的代码、可对比的效果和可以落地的优化手段。

它解决什么问题

Canvas性能优化的核心目标是:减少无效绘制

具体来说,当你的页面出现以下现象时,就需要考虑优化:

现象 原因
帧率随元素数量增加线性下降 每帧全量重绘所有元素
页面滚动或动画时出现明显丢帧 绘制指令过于频繁
静态内容反复被重绘 没有利用缓存机制
状态切换时卡顿 Canvas状态机频繁压栈出栈

适合的场景:地图标注绘制、图表组件、游戏HUD、实时数据监控面板。

不适合的场景:完全不变化的静态图片,用Image组件加载更省资源。

环境说明

DevEco Studio版本:DevEco Studio 6.1.0及以上
HarmonyOS SDK版本:HarmonyOS 6.1.0(23)及以上
目标设备:手机(API 12)

核心实现:性能对比Demo

我们用一个实际场景来演示:在Canvas上绘制一个由500个小方块组成的网格,并让其中几个方块持续闪烁。

优化前的写法

@Entry
@Component
struct CanvasDemoBefore {
  private canvasWidth: number = 360
  private canvasHeight: number = 600
  private settings: RenderingContextSettings = new RenderingContextSettings(true)
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)

  build() {
    Column() {
      Canvas(this.context)
        .width(this.canvasWidth)
        .height(this.canvasHeight)
        .onReady(() => {
          this.drawAll()
        })
    }
  }

  drawAll() {
    // 每帧清空并重绘所有500个小方块
    this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight)

    for (let row = 0; row < 20; row++) {
      for (let col = 0; col < 25; col++) {
        let x = col * 15
        let y = row * 30 + 20
        this.context.fillStyle = this.getColor(row, col)
        this.context.fillRect(x, y, 12, 12)
      }
    }

    // 绘制闪烁的小方块(假设有10个)
    for (let i = 0; i < 10; i++) {
      let fx = i * 30 + 5
      let fy = i % 5 * 60 + 10
      this.context.fillStyle = i % 2 === 0 ? '#FF0000' : '#00FF00'
      this.context.fillRect(fx, fy, 12, 12)
    }

    // 使用requestAnimationFrame持续刷新,但每次都是全量重绘
    requestAnimationFrame(() => this.drawAll())
  }

  getColor(row: number, col: number): string {
    // 模拟复杂的颜色计算
    let hue = (row * 15 + col * 7) % 360
    return `hsl(${hue}, 80%, 60%)`
  }
}

这个版本的问题很典型:requestAnimationFrame触发的每一帧都把500个小方块全部重新画一遍。动图跑起来后,在真机上帧率会急剧下降到15-20fps。

性能分析

用DevEco Studio自带的Profiler看一下。

可以看到每帧的GPU渲染时长接近50ms,远远超出16ms的帧预算。原因就是clearRect + 500次fillRect调用,每次都触发了完整的绘制管线。

优化后的写法

优化思路:把网格背景作为静态层缓存到离屏Canvas,每帧只需要重绘变化的闪烁方块。

@Entry
@Component
struct CanvasDemoAfter {
  private canvasWidth: number = 360
  private canvasHeight: number = 600

  // 屏幕上的主 Canvas
  private settings: RenderingContextSettings = new RenderingContextSettings(true)
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)

  // 离屏Canvas:用于缓存静态网格背景
  private offscreenSettings: RenderingContextSettings = new RenderingContextSettings(true)
  private offscreenContext: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.offscreenSettings)
  private isCacheReady: boolean = false

  build() {
    Column() {
      Canvas(this.context)
        .width(this.canvasWidth)
        .height(this.canvasHeight)
        .onReady(() => {
          // 初始化:绘制静态背景到离屏Canvas
          this.prepareCache()
          // 开始动画循环
          this.drawDynamic()
        })
    }
  }

  prepareCache() {
    // 只在初始化时画一次静态网格
    for (let row = 0; row < 20; row++) {
      for (let col = 0; col < 25; col++) {
        let x = col * 15
        let y = row * 30 + 20
        let hue = (row * 15 + col * 7) % 360
        this.offscreenContext.fillStyle = `hsl(${hue}, 80%, 60%)`
        this.offscreenContext.fillRect(x, y, 12, 12)
      }
    }
    this.isCacheReady = true
  }

  drawDynamic() {
    if (!this.isCacheReady) return

    // 1. 清屏
    this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight)

    // 2. 绘制缓存好的静态层(离屏Canvas内容)
    this.context.drawImage(
      this.offscreenContext.canvas,
      0, 0,
      this.canvasWidth, this.canvasHeight
    )

    // 3. 仅绘制变化的闪烁方块(脏矩形区域)
    const blinkCount = 10
    for (let i = 0; i < blinkCount; i++) {
      let fx = i * 30 + 5
      let fy = i % 5 * 60 + 10
      let isRed = Math.floor(Date.now() / 500) % 2 === 0
      this.context.fillStyle = isRed ? '#FF0000' : '#00FF00'

      // 使用clip限制绘制范围,避免影响缓存层
      this.context.save()
      this.context.beginPath()
      this.context.rect(fx, fy, 12, 12)
      this.context.clip()
      this.context.fillRect(fx, fy, 12, 12)
      this.context.restore()
    }

    requestAnimationFrame(() => this.drawDynamic())
  }
}

做了哪些优化

优化项 做法 效果
静态层缓存 离屏Canvas只画一次网格 每帧省去500次fillRect调用
脏矩形更新 只重绘变化的10个小方块 GPU负载降低约98%
clip+restore 控制绘制边界,不污染缓存层 避免全屏clearRect
减少状态切换 每个方块只切换一次fillStyle 减少Canvas状态栈操作

优化后,Profiler显示每帧GPU渲染时长降至2-4ms。

真正要小心的问题

问题1:clearRect和clip的配合边界

现象:使用离屏Canvas后,主Canvas在绘制动态内容时,如果不清除缓存层,会出现重影。

原因:drawImage把缓存层画上去后,动态内容会叠加在上一次绘制的动态内容之上。如果不清除,视觉效果就会错乱。

解决方案:每次动画循环开始时,先用clearRect清空整个主Canvas,然后再把缓存层绘制上去。

// 正确顺序
this.context.clearRect(0, 0, width, height)   // 清屏
this.context.drawImage(this.offscreenCanvas, 0, 0)  // 画缓存
// 再画动态内容

问题2:Canvas状态栈的深度会影响性能

现象:在每帧中频繁调用save()restore()

原因:状态栈每压入一次,都需要保存当前所有状态(变换矩阵、裁剪区域、样式等)。如果状态栈深度超过10层,每次出栈入栈的开销就会显现在帧时间里。

解决方案:尽量把需要保存的状态压缩到最小。如果只是临时修改fillStyle或strokeStyle,可以直接赋值,结束后再改回来,不需要每次都save/restore。

// 不推荐
this.context.save()
this.context.fillStyle = '#FF0000'
this.context.fillRect(0, 0, 10, 10)
this.context.restore()

// 推荐(如果只有样式变化)
let originalStyle = this.context.fillStyle
this.context.fillStyle = '#FF0000'
this.context.fillRect(0, 0, 10, 10)
this.context.fillStyle = originalStyle

问题3:离屏Canvas的尺寸与主Canvas不一致

现象:在真机上,离屏Canvas的内容显示模糊或位置偏移。

原因:离屏Canvas的物理像素尺寸可能与主Canvas不同,尤其是在不同屏幕密度下。

解决方案:显式设置离屏Canvas的尺寸为主Canvas的宽高。如果有像素密度变量,提前转换。

// 初始化离屏Canvas时对齐尺寸
this.offscreenContext.canvas.width = this.canvasWidth
this.offscreenContext.canvas.height = this.canvasHeight

最佳实践

  1. 评估绘制内容的动静分离度。静态内容比例越高,缓存收益越大。如果页面所有元素都在动,分层缓存意义不大。

  2. 离屏Canvas不要一次性绘制过多内容。如果静态层本身就有几千个图形,初始化时的绘制耗时会让页面启动变慢。可以考虑在onReady回调里用异步任务分批构造。

  3. 周期性动画不要使用setTimeout模拟requestAnimationFrame。API 12上requestAnimationFrame能更好地与VSync对齐,减少丢帧。

  4. 减少fillStyle/ strokeStyle的频密切换。每个Canvas上下文切换颜色需要内部解析颜色字符串,如果每帧切换几百次,可以简单预编译颜色对象:

// 预定义颜色池
const colorPalette: string[] = ['#FF0000', '#00FF00', '#0000FF', ...]
// 直接索引取值,比每次计算字符串快
this.context.fillStyle = colorPalette[index]

Demo入口

@Entry
@Component
struct Index {
  build() {
    Column() {
      Text('优化前:全量重绘 (帧率约15fps)')
        .fontSize(16)
      CanvasDemoBefore()

      Divider().height(20).color('#CCCCCC')

      Text('优化后:缓存静态层 + 脏矩形更新 (帧率约60fps)')
        .fontSize(16)
      CanvasDemoAfter()
    }
    .width('100%')
    .height('100%')
  }
}

两个Canvas组件建议放在同一屏对比,便于肉眼看到差异。

FAQ

Q:为什么离屏Canvas在部分真机上失效?

A:检查离屏Canvas的widthheight是否在主线程中赋值。如果在子线程中操作离屏Canvas,渲染会不生效。所有Canvas相关操作必须放在UI主线程。

Q:Profiler上看GPU渲染时间很低,但掉帧仍然严重?

A:这种情况通常是JS线程超时。检查动画循环中是否有复杂的数学计算或字符串拼接,必要时将计算推到Worker线程,或用@State状态变量驱动。

Q:drawImage的性能会不会比自己画点阵差?

A:取决于设备。在中低端机型上,drawImage的硬件加速效率优于手动fillRect,但内存开销稍高。推荐在需要缓存大量静态图形时优先使用drawImage


示例代码地址:项目地址

这份优化方案在实际项目中已经过了多轮验证,核心原则就是:减少无效绘制,把静态内容存起来,只更新真正变化的部分。如果你的Canvas应用出现了卡顿,先从全量重绘这个点开始排查。

Logo

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

更多推荐