50 Canvas 动画:@State + @Watch + animateTo 驱动模式

50 Canvas 动画:@State + @Watch + animateTo 驱动模式

前言

图:50 Canvas 动画:@State + @Watch + animateTo 驱动模式 运行效果截图(HarmonyOS NEXT)

在"鹿鹿·笔迹心理分析"App 的三个 Canvas 组件(雷达图、折线图、圆环图)中,所有动画效果都基于同一个核心模式:状态变量驱动 Canvas 重绘

这个模式将 ArkUI 的声明式状态管理(@State)与 Canvas 命令式绘制结合在一起------通过 animateTo 改变一个 drawProgress 变量,再用 @Watch 监听变化触发 draw() 重绘。本文系统解析这种动画模式的原理和最佳实践。

鸿蒙官方·animateTo 文档:developer.huawei.com

项目源码:AppAnimations.ets(file:///Users/fiona/Downloads/bijixinli/harmony-app/entry/src/main/ets/common/theme/AppAnimations.ets)

图:@State + @Watch + animateTo 驱动 Canvas 重绘的完整闭环
Canvas 重绘 draw() 方法 @Watch onProgressChange @State drawProgress animateTo 组件 aboutToAppear Canvas 重绘 draw() 方法 @Watch onProgressChange @State drawProgress animateTo 组件 aboutToAppear #mermaid-svg-sRPplh41YEyrcKS3{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-sRPplh41YEyrcKS3 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-sRPplh41YEyrcKS3 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-sRPplh41YEyrcKS3 .error-icon{fill:#552222;}#mermaid-svg-sRPplh41YEyrcKS3 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-sRPplh41YEyrcKS3 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-sRPplh41YEyrcKS3 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-sRPplh41YEyrcKS3 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-sRPplh41YEyrcKS3 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-sRPplh41YEyrcKS3 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-sRPplh41YEyrcKS3 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-sRPplh41YEyrcKS3 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-sRPplh41YEyrcKS3 .marker.cross{stroke:#333333;}#mermaid-svg-sRPplh41YEyrcKS3 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-sRPplh41YEyrcKS3 p{margin:0;}#mermaid-svg-sRPplh41YEyrcKS3 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-sRPplh41YEyrcKS3 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-sRPplh41YEyrcKS3 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-sRPplh41YEyrcKS3 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-sRPplh41YEyrcKS3 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-sRPplh41YEyrcKS3 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-sRPplh41YEyrcKS3 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-sRPplh41YEyrcKS3 .sequenceNumber{fill:white;}#mermaid-svg-sRPplh41YEyrcKS3 #sequencenumber{fill:#333;}#mermaid-svg-sRPplh41YEyrcKS3 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-sRPplh41YEyrcKS3 .messageText{fill:#333;stroke:none;}#mermaid-svg-sRPplh41YEyrcKS3 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-sRPplh41YEyrcKS3 .labelText,#mermaid-svg-sRPplh41YEyrcKS3 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-sRPplh41YEyrcKS3 .loopText,#mermaid-svg-sRPplh41YEyrcKS3 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-sRPplh41YEyrcKS3 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-sRPplh41YEyrcKS3 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-sRPplh41YEyrcKS3 .noteText,#mermaid-svg-sRPplh41YEyrcKS3 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-sRPplh41YEyrcKS3 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-sRPplh41YEyrcKS3 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-sRPplh41YEyrcKS3 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-sRPplh41YEyrcKS3 .actorPopupMenu{position:absolute;}#mermaid-svg-sRPplh41YEyrcKS3 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-sRPplh41YEyrcKS3 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-sRPplh41YEyrcKS3 .actor-man circle,#mermaid-svg-sRPplh41YEyrcKS3 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-sRPplh41YEyrcKS3 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 每一帧都重复此循环直到动画结束 触发入场动画 drawProgress: 0 → 1 状态变化通知 调用 draw() ctx.clearRect + 重新绘制

一、动画核心模式

1.1 三行代码驱动动画

typescript 复制代码
// 第 1 行:声明驱动变量
@State @Watch('onDrawProgressChange') drawProgress: number = 0

// 第 2 行:触发动画
aboutToAppear(): void {
  animateTo({ duration: 1000, curve: Curve.FastOutSlowIn }, () => {
    this.drawProgress = 1      // 0 → 1 过渡
  })
}

// 第 3 行:监听变化 → 重绘
onDrawProgressChange(): void {
  this.draw()
}

执行流程:

复制代码
  aboutToAppear()
       │
       ▼
  animateTo({ duration: 1000ms })
       │
       ├── drawProgress = 0.0 → @Watch 触发 → draw()
       ├── drawProgress = 0.1 → @Watch 触发 → draw()
       ├── drawProgress = 0.2 → @Watch 触发 → draw()
       ├── ...
       └── drawProgress = 1.0 → @Watch 触发 → draw()(最终状态)

1.2 工作原理

复制代码
┌─────────────────────────────────────────────────────┐
│  ① animateTo 驱动 @State                           │
│     ├── 产生插值(0→1 逐帧过渡)                      │
│     └── 每帧更新 drawProgress                       │
│                                                     │
│  ② @Watch 监听变化                                  │
│     ├── detect: drawProgress 变化                    │
│     └── call: onDrawProgressChange() → draw()        │
│                                                     │
│  ③ draw() 读取 drawProgress                         │
│     ├── 控制雷达数据多边形的半径比例                    │
│     ├── 控制折线图的可见点数                            │
│     └── 控制圆环的弧角度                               │
└─────────────────────────────────────────────────────┘

二、三个组件的动画对比

2.1 动画参数

组件 动画时长 动画曲线 drawProgress 的作用
HandwritingRadar 1000ms FastOutSlowIn 控制数据多边形大小(0→1)
EmotionTrendChart 1200ms FastOutSlowIn 控制可见数据点数(0→N)
MatchRing 1200ms FastOutSlowIn 控制弧角度(0→2π×match/100)

2.2 动画效果

typescript 复制代码
// ① 雷达图:从中心向外扩散
const r = radius * (p.value / 100) * this.drawProgress
// drawProgress=0 → 所有顶点汇聚在中心
// drawProgress=1 → 数据多边形到达最终位置

// ② 折线图:从左到右逐段绘制
const visibleCount = Math.floor(this.drawProgress * this.data.length)
// drawProgress=0 → visibleCount=0(空)
// drawProgress=0.5 → 显示一半数据点
// drawProgress=1 → 全部显示

// ③ 圆环图:从 12 点顺时针展开
const end = start + (Math.PI * 2 * this.matchValue / 100) * this.drawProgress
// drawProgress=0 → end=start(圆弧长度为 0)
// drawProgress=0.5 → 展开一半弧
// drawProgress=1 → 最终弧角度

2.3 动画时序

动画进度 (drawProgress) 雷达图 折线图 圆环图
0% (0ms) 数据点全部在中心 空白画布 只有起点
25% (250ms/300ms) 多边形展开 25% 前 1/4 数据点 弧展开 25%
50% (500ms/600ms) 多边形展开一半 一半数据点显示 弧展开一半,圆点出现
75% (750ms/900ms) 多边形接近完成 大部分点可见 弧接近完成
100% (1000ms/1200ms) 完整雷达图 全部数据 + 最终高亮 完整圆环 + 圆点

三、AppAnimations 常量管理

3.1 Canvas 相关常量

项目中所有动画时长和曲线通过 AppAnimations.ets(file:///Users/fiona/Downloads/bijixinli/harmony-app/entry/src/main/ets/common/theme/AppAnimations.ets) 统一管理:

typescript 复制代码
export class AppAnimations {
  // Canvas 绘制动画时长
  static readonly RADAR_DRAW_DURATION: number = 1000    // 雷达图展开
  static readonly CHART_DRAW_DURATION: number = 1200    // 折线图绘制
  static readonly RING_DRAW_DURATION: number = 1200     // 匹配环展开
  static readonly RING_FADE_DURATION: number = 600      // 渐变环入场

  // 公用曲线
  static readonly CURVE_SMOOTH: Curve = Curve.FastOutSlowIn
}

3.2 为什么用常量而不用硬编码

typescript 复制代码
// ❌ 硬编码:难以维护,难以统一调整
animateTo({ duration: 1000, curve: Curve.FastOutSlowIn }, () => {
  this.drawProgress = 1
})

// ✅ 常量:统一修改,语义清晰
animateTo({
  duration: AppAnimations.RADAR_DRAW_DURATION,
  curve: AppAnimations.CURVE_SMOOTH
}, () => {
  this.drawProgress = 1
})

四、@Watch 的最佳实践

4.1 监听函数格式

typescript 复制代码
// @Watch 的函数名要与装饰器中的字符串一致
@State @Watch('onDrawProgressChange') drawProgress: number = 0

onDrawProgressChange(): void {
  this.draw()
}

注意事项:

typescript 复制代码
// ✅ 正确:函数名匹配
@State @Watch('myCallback') x: number = 0
myCallback(): void { ... }

// ❌ 错误:函数名不匹配
@State @Watch('myCallback') x: number = 0
myDifferentCallback(): void { ... }  // 永远不会被调用

4.2 避免在 @Watch 中做耗时操作

typescript 复制代码
// ❌ 不推荐:@Watch 中做复杂计算
onDrawProgressChange(): void {
  this.heavyComputation()   // 每帧都执行,卡顿
  this.draw()
}

// ✅ 推荐:计算放在 draw() 前一次完成
onDrawProgressChange(): void {
  this.draw()
}

private draw(): void {
  // 所有计算集中在 draw() 中,首次 @Watch 时才执行
  this.calculateAngles()    // 如果数据未变可缓存
  this.renderGraphics()
}

五、首次绘制与动画的配合

5.1 onReady + aboutToAppear 时序

项目中的首次绘制由 onReady 触发,动画由 aboutToAppear 触发:

typescript 复制代码
aboutToAppear(): void {
  // ② animateTo → drawProgress 0→1(触发 @Watch → draw)
  animateTo({ ... }, () => { this.drawProgress = 1 })
}

onDrawProgressChange(): void {
  this.draw()   // ③ 动画每帧触发 draw()
}

build() {
  Canvas(this.context)
    .onReady(() => this.draw())  // ① 首次绘制(drawProgress=0 的初始状态)
}

执行时序细化:

复制代码
T=0ms   组件创建
          │
          ├── aboutToAppear() 开始 animateTo...
          │
T=5ms   Canvas 布局完成
          │
          ├── onReady() → this.draw() 首次绘制
          │    (drawProgress=0 → 网格可见,数据多边形为点)
          │
T=5~1005ms  动画进行中
          │
          ├── @Watch 持续触发 → draw() 不断更新
          │    (drawProgress 逐渐从 0 增加到 1)
          │
T=1005ms 动画完成
          │
          └── drawProgress=1,最终状态渲染完毕

5.2 为什么不只靠 onReady?

typescript 复制代码
// ❌ 如果只靠 onReady:
build() {
  Canvas(this.context)
    .onReady(() => {
      this.draw()        // 只画一次
      // 没有动画,直接显示最终状态
    })
}

// ✅ 正确做法:onReady 画初始状态 + animateTo 驱动动画
// 用户会看到:空白 → 网格出现 → 数据渐变填充

六、三种动画模式

6.1 模式一:缩放动画(雷达图)

typescript 复制代码
// 数据多边形从中心向外扩散
// 通过缩放顶点到中心的距离实现
const r = radius * (p.value / 100) * this.drawProgress

6.2 模式二:分段显现(折线图)

typescript 复制代码
// 数据点从左到右逐段出现
// 通过控制可见点数实现
const visibleCount = Math.floor(this.drawProgress * this.data.length)

6.3 模式三:弧展开(圆环图)

typescript 复制代码
// 圆弧从起点到终点逐步展开
// 通过控制结束角度实现
const end = start + (Math.PI * 2 * this.matchValue / 100) * this.drawProgress

6.4 对比

模式 动画类型 数学本质 适用场景
缩放 大小渐变 半径 × progress 多边形、矩形
分段 部分→完整 count × progress 折线、柱状图
弧展开 角度渐变 angle × progress 环形、饼图

七、动画性能考量

7.1 Canvas 重绘频率

animateTo 驱动 @State 变化的频率取决于动画时长和帧率:

typescript 复制代码
// 1000ms 动画,60fps → ~60 次重绘
// 每次 draw() 执行完整绘制流程

// 避免在 draw() 中:
//   1. 创建大量临时对象
//   2. 执行不必要的清除和重绘
//   3. 连续调用多次 fill/stroke

// ✅ draw() 中推荐:
//   1. 预计算不变数据(如 angles 数组)
//   2. 聚合路径(一个 beginPath + 多个 moveTo/lineTo + 一次 stroke)
//   3. 必需的清除操作(clearRect)

7.2 预计算优化

typescript 复制代码
private draw(): void {
  // ❌ 不推荐:每帧都在循环中计算 cos/sin
  for (let i = 0; i < sides; i++) {
    const angle = (Math.PI * 2 * i) / sides - Math.PI / 2
    const x = cx + r * Math.cos(angle)   // 每帧都算
  }

  // ✅ 推荐:angles 数组只在数据变化时计算一次
  // 在 draw 开始时预计算(或缓存为成员变量)
  const angles = this.cachedAngles ?? this.computeAngles()
}

八、@State 与 Canvas 的协作总结

8.1 关键要点

要点 说明
@State 驱动 声明式状态管理触发 Canvas 命令式绘制
animateTo 渡值 平滑地将 drawProgress 从 0 过渡到 1
@Watch 桥接 检测状态变化,调用 draw() 重绘
onReady 兜底 Canvas 就绪时的首次绘制
绘制函数纯净 draw() 只读取状态,不修改状态

8.2 常见问题排查

现象 原因 解决
动画不执行 animateTo 未调用 检查 aboutToAppear 是否正确
Canvas 空白 onReady 未触发 检查 Canvas 宽高是否 > 0
绘制闪烁 draw 中未 clearRect 每次 draw 先清除画布
动画卡顿 @Watch 中耗时操作 将计算移到 draw 外部
最终状态不对 drawProgress 逻辑有误 debug 输出 drawProgress 值

总结

本文系统解析了 Canvas 动画的 @State + @Watch + animateTo 驱动模式:

  1. 核心模式animateTo 驱动 drawProgress(0→1)@Watch 触发 draw()
  2. 三种动画效果:缩放(雷达图)、分段(折线图)、弧展开(圆环图)
  3. AppAnimations 常量:统一管理动画时长和曲线
  4. 首次绘制onReady 画初始状态 + animateTo 驱动动画
  5. 性能优化:预计算 + 聚合路径 + 避免 @Watch 耗时操作

下一篇文章将介绍 双色叠加雷达图------在 DuetReportPage 中绘制两个人的对比雷达图。
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!


参考资源:

  • animateTo 显式动画
  • @Watch 装饰器
  • @State 装饰器
  • HandwritingRadar 动画源码(file:///Users/fiona/Downloads/bijixinli/harmony-app/entry/src/main/ets/common/components/HandwritingRadar.ets)
  • EmotionTrendChart 动画源码(file:///Users/fiona/Downloads/bijixinli/harmony-app/entry/src/main/ets/common/components/EmotionTrendChart.ets)
  • MatchRing 动画源码(file:///Users/fiona/Downloads/bijixinli/harmony-app/entry/src/main/ets/common/components/MatchRing.ets)
  • AppAnimations 动画常量(file:///Users/fiona/Downloads/bijixinli/harmony-app/entry/src/main/ets/common/theme/AppAnimations.ets)
  • HarmonyOS 开发文档

互动投票: 你更喜欢哪种 Canvas 动画实现方式?

  • A. @State + @Watch + animateTo(本项目方案)
  • B. requestAnimationFrame 手动循环
  • C. CSS 过渡 / Web 动画 API(其他平台)
  • D. 直接用三方图表库(ECharts 等)

欢迎在评论区分享你的选择!

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!

相关推荐
l134062082351 小时前
鸿蒙实战:RDB 数据库设计与 DatabaseService
数据库·华为·harmonyos·鸿蒙系统
ZZZMMM.zip2 小时前
数据侦探社-数据趋势分析的HarmonyOS开发实践
人工智能·华为·harmonyos·鸿蒙·鸿蒙系统
星释3 小时前
鸿蒙智能体开发实战:34.鸿蒙壁纸大师 - 提示词工程与优化
android·华为·harmonyos·鸿蒙
千逐684 小时前
React Native 性能优化深度解析:从原理到实战
flutter·华为·鸿蒙
ujainu小4 小时前
并发性能测试:心跳监测与主线程阻塞分析
华为·性能优化·harmonyos
●VON5 小时前
HarmonyKit | 鸿蒙开发:项目目录结构与多模块架构最佳实践
华为·架构·单元测试·harmonyos·鸿蒙
tyqtyq226 小时前
营养餐单规划:AI 科学饮食规划系统的鸿蒙实现
人工智能·学习·华为·职场和发展·生活·harmonyos
不才难以繁此生6 小时前
HarmonyOS RDB 索引实战:中式美食搜索和购物清单查询怎么避免扫全表
harmonyos·arkts·arkui·rdb·中式美食·relationalstore
星释6 小时前
鸿蒙智能体开发实战:26.Skill 与插件协同开发
microsoft·华为·ai·harmonyos·鸿蒙·智能体
星释7 小时前
鸿蒙智能体开发实战:32.鸿蒙壁纸大师 - API认证与会话管理
华为·ai·harmonyos·智能体