为一篇技术教程生成一张手绘笔记风格的信息图,主题是“HarmonyOS7 Canvas 动画循环详解

前言

静态图形会画了,只能算学到一半。Canvas 真正开始有表现力,往往是在它动起来之后。旋转、粒子、波纹、仪表盘、加载动画,很多你平时觉得“这页面有点高级”的效果,本质上都是一帧一帧刷出来的。

问题也正出在这里。一旦开始动画,Canvas 就不再只是“画一张图”,而变成“持续重绘一个状态变化中的画面”。这跟前面学静态图形,完全不是一个思路。

这个案例非常适合做第一步,因为它只做了一件事:让一个方块旋转起来。

这页案例到底讲了什么

表面看它在讲旋转动画,实际上讲的是一套最基础的 Canvas 动画循环模型:

为一篇讲解 HarmonyOS7 Canvas 动画的技术教程生成一张手绘流程图,内容必须严格对应代

  • 用状态变量保存动画进度
  • 用定时器持续更新状态
  • 每次重绘前清空旧画面
  • 通过坐标变换实现旋转

这套模型虽然简单,但后面的很多动画都离不开它。

完整代码

import { DEMO_BG_COLOR, DEMO_CARD_COLOR, DEMO_THEME_COLOR, DEMO_SUBTEXT_COLOR } from './types'

@Entry
@Component
struct CanvasAnimationLoop {
  @State isShow: boolean = true
  @State angle: number = 0
  private settings: RenderingContextSettings = new RenderingContextSettings(true)
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
  private timerId: number = -1
  @State isAnimating: boolean = false

  startAnimation(): void {
    if (this.isAnimating) return
    this.isAnimating = true
    this.timerId = setInterval(() => {
      this.angle = (this.angle + 5) % 360
    }, 50)
  }

  stopAnimation(): void {
    this.isAnimating = false
    clearInterval(this.timerId)
  }

  build() {
    Column() {
      if (this.isShow) {
        Scroll() {
          Column({ space: 16 }) {
            Text('Canvas 动画效果').fontSize(16).fontColor(DEMO_THEME_COLOR).fontWeight(FontWeight.Bold)

            Column() {
              Text('旋转动画演示').fontSize(13).fontColor('#4D96FF').margin({ bottom: 8 })
              Column() {
                Canvas(this.context)
                  .onReady(() => {
                    const w = 380
                    const h = 180
                    this.context.clearRect(0, 0, w, h)

                    this.context.save()
                    this.context.translate(w / 2, h / 2)
                    this.context.rotate(this.angle * Math.PI / 180)

                    this.context.fillStyle = `hsl(${this.angle}, 70%, 60%)`
                    this.context.fillRect(-30, -30, 60, 60)

                    this.context.strokeStyle = '#666666'
                    this.context.lineWidth = 1
                    this.context.beginPath()
                    this.context.arc(0, 0, 50, 0, Math.PI * 2)
                    this.context.stroke()

                    this.context.restore()

                    this.context.font = '12px sans-serif'
                    this.context.fillStyle = '#999999'
                    this.context.fillText(`旋转角度: ${this.angle}°`, 10, h - 10)
                  })
                  .width('100%')
                  .height(180)
                  .backgroundColor('#F0F0F0')
                  .borderRadius(8)
              }
              Row({ space: 12 }) {
                Button('开始动画').onClick(() => { this.startAnimation() })
                  .fontSize(12).backgroundColor(DEMO_THEME_COLOR)
                Button('停止动画').onClick(() => { this.stopAnimation() })
                  .fontSize(12).backgroundColor('#FF6B6B')
              }.margin({ top: 8 })
            }
            .backgroundColor(DEMO_CARD_COLOR)
            .padding(12)
            .borderRadius(12)

            Column({ space: 6 }) {
              Text('Canvas 动画实现').fontSize(14).fontColor(DEMO_THEME_COLOR).fontWeight(FontWeight.Bold)
              Text('利用 @State 变量驱动重绘').fontSize(12).fontColor('#333333')
              Text('setInterval 定时更新状态').fontSize(12).fontColor('#333333')
              Text('每次更新调用 context 重新绘制帧').fontSize(12).fontColor('#333333')
              Text('.clearRect 清除上一帧内容').fontSize(12).fontColor('#333333')
              Text('.save/.restore 保存恢复状态').fontSize(12).fontColor('#333333')
              Text('.translate/rotate/scale 变换矩阵').fontSize(12).fontColor('#333333')
              Text('使用 requestAnimationFrame 更流畅').fontSize(12).fontColor('#333333')
            }
            .width('100%')
            .backgroundColor('#E8F4FD')
            .padding(14)
            .borderRadius(12)
          }
          .width('100%')
        }
      }

      Text('CanvasAnimationLoop - setInterval 动画循环')
        .fontSize(12)
        .fontColor(DEMO_SUBTEXT_COLOR)
        .margin({ top: 12 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor(DEMO_BG_COLOR)
    .padding(16)
  }
}

为一篇 HarmonyOS7 ArkUI Canvas 技术文章生成一张手绘框架图,主题是“Canv

angle 是这段动画的发动机

案例里最关键的状态,就是这个角度:

@State angle: number = 0

它本质上不是“一个数字”,而是当前动画帧的描述。只要这个值持续变化,画面就会持续变化。

所以动画思维的第一步,不是先想怎么画,而是先想:哪一个状态变化,能带动画面变化?

在这个案例里,答案就是旋转角度。

startAnimation() 做的其实是“开循环”

startAnimation(): void {
  if (this.isAnimating) return
  this.isAnimating = true
  this.timerId = setInterval(() => {
    this.angle = (this.angle + 5) % 360
  }, 50)
}

这段代码有两个点必须看懂。

第一,if (this.isAnimating) return 是防止重复启动。没有这句,你多点几次开始按钮,就会叠出多个定时器,速度和状态都会乱掉。

第二,this.angle = (this.angle + 5) % 360 是让角度持续增加并循环归零。这样不管动画跑多久,数值都不会无限膨胀。

动画帧开始前为什么一定要 clearRect()

案例里每次绘制前都做了这件事:

this.context.clearRect(0, 0, w, h)

这是 Canvas 动画最基本的卫生习惯。

因为画布不会自动忘记上一帧。你不清空,新的方块会叠在旧方块上,最后就会形成拖影或者一团糊掉的痕迹。除非你故意想做残影效果,否则绝大多数动画都应该先清屏再重画。

旋转为什么要配合 translate() 使用

这段变换代码非常关键:

this.context.save()
this.context.translate(w / 2, h / 2)
this.context.rotate(this.angle * Math.PI / 180)
this.context.fillRect(-30, -30, 60, 60)
this.context.restore()

如果你直接 rotate(),图形会围绕当前坐标原点转,通常不是你想要的效果。

这里先 translate(w / 2, h / 2),意思是把坐标原点移动到画布中心。然后再旋转,方块就会围绕中心点转。

最后 fillRect(-30, -30, 60, 60) 用负坐标把方块中心对齐到新原点。这个小技巧非常常见。

save()restore() 为什么不能少

这两个方法是状态隔离的关键。

因为 translate()rotate() 会改变当前坐标系,如果你不把变换前的状态保存下来,后面画文字、画圆、画别的元素时,都会被这个旋转坐标系拖进去。

案例里旋转完成后马上 restore(),就是为了让后面的说明文字继续按照正常坐标绘制。

颜色跟着角度变化,是个很巧的示范

案例用了这句:

this.context.fillStyle = `hsl(${this.angle}, 70%, 60%)`

这不是必须的,但很聪明。因为它让你看到,动画状态不仅能控制位置和旋转,也能同时控制颜色变化。

这一下就把你的思路打开了。以后做动态进度、状态光效、节奏反馈时,完全可以让一个状态同时驱动多个视觉属性。

stopAnimation() 不只是停止按钮

stopAnimation(): void {
  this.isAnimating = false
  clearInterval(this.timerId)
}

停止动画这件事别轻视。它不仅是交互按钮需要,更是资源管理需要。

如果页面离开了、组件销毁了,动画还在后台跑,CPU 和电量都会被白白消耗。真实项目里,这部分通常还要结合页面生命周期进一步收口。

这套思路还能扩展成什么

你一旦理解这个案例,就能顺手扩展出很多东西:

  • 加载转圈动画
  • 仪表盘指针摆动
  • 粒子漂浮
  • 波纹扩散
  • 数据高亮闪动

区别只在于:驱动状态变成了什么,重绘逻辑换成了什么图形。

最容易踩的坑

第一个坑,是重复启动多个定时器。

第二个坑,是没清空上一帧,画面越跑越脏。

第三个坑,是做了坐标变换后忘记 restore(),后面的元素全歪。

第四个坑,是页面离开时没停动画,性能被偷偷吃掉。

写在最后

动画的本质,从来不是“会动”这么简单,而是你是否能稳定地控制一帧一帧的状态变化。这个案例看着小,但已经把 Canvas 动画最关键的几根骨头搭出来了。

把它练顺,后面无论你做加载、图表、交互反馈,都会更有底气。

Logo

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

更多推荐