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 驱动模式:
- 核心模式 :
animateTo驱动drawProgress(0→1)→@Watch触发draw() - 三种动画效果:缩放(雷达图)、分段(折线图)、弧展开(圆环图)
- AppAnimations 常量:统一管理动画时长和曲线
- 首次绘制 :
onReady画初始状态 +animateTo驱动动画 - 性能优化:预计算 + 聚合路径 + 避免 @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 等)
欢迎在评论区分享你的选择!
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!