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 重绘的完整闭环
一、动画核心模式
1.1 三行代码驱动动画
// 第 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 动画效果
// ① 雷达图:从中心向外扩散constr=radius*(p.value/100)*this.drawProgress// drawProgress=0 → 所有顶点汇聚在中心// drawProgress=1 → 数据多边形到达最终位置// ② 折线图:从左到右逐段绘制constvisibleCount=Math.floor(this.drawProgress*this.data.length)// drawProgress=0 → visibleCount=0(空)// drawProgress=0.5 → 显示一半数据点// drawProgress=1 → 全部显示// ③ 圆环图:从 12 点顺时针展开constend=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) 统一管理:
exportclassAppAnimations{// Canvas 绘制动画时长staticreadonlyRADAR_DRAW_DURATION:number=1000// 雷达图展开staticreadonlyCHART_DRAW_DURATION:number=1200// 折线图绘制staticreadonlyRING_DRAW_DURATION:number=1200// 匹配环展开staticreadonlyRING_FADE_DURATION:number=600// 渐变环入场// 公用曲线staticreadonlyCURVE_SMOOTH:Curve=Curve.FastOutSlowIn}3.2 为什么用常量而不用硬编码
// ❌ 硬编码:难以维护,难以统一调整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 监听函数格式
// @Watch 的函数名要与装饰器中的字符串一致@State@Watch('onDrawProgressChange')drawProgress:number=0onDrawProgressChange():void{this.draw()}注意事项:
// ✅ 正确:函数名匹配@State@Watch('myCallback')x:number=0myCallback():void{...}// ❌ 错误:函数名不匹配@State@Watch('myCallback')x:number=0myDifferentCallback():void{...}// 永远不会被调用4.2 避免在 @Watch 中做耗时操作
// ❌ 不推荐:@Watch 中做复杂计算onDrawProgressChange():void{this.heavyComputation()// 每帧都执行,卡顿this.draw()}// ✅ 推荐:计算放在 draw() 前一次完成onDrawProgressChange():void{this.draw()}privatedraw():void{// 所有计算集中在 draw() 中,首次 @Watch 时才执行this.calculateAngles()// 如果数据未变可缓存this.renderGraphics()}五、首次绘制与动画的配合
5.1 onReady + aboutToAppear 时序
项目中的首次绘制由onReady触发,动画由aboutToAppear触发:
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?
// ❌ 如果只靠 onReady:build(){Canvas(this.context).onReady(()=>{this.draw()// 只画一次// 没有动画,直接显示最终状态})}// ✅ 正确做法:onReady 画初始状态 + animateTo 驱动动画// 用户会看到:空白 → 网格出现 → 数据渐变填充六、三种动画模式
6.1 模式一:缩放动画(雷达图)
// 数据多边形从中心向外扩散// 通过缩放顶点到中心的距离实现constr=radius*(p.value/100)*this.drawProgress6.2 模式二:分段显现(折线图)
// 数据点从左到右逐段出现// 通过控制可见点数实现constvisibleCount=Math.floor(this.drawProgress*this.data.length)6.3 模式三:弧展开(圆环图)
// 圆弧从起点到终点逐步展开// 通过控制结束角度实现constend=start+(Math.PI*2*this.matchValue/100)*this.drawProgress6.4 对比
| 模式 | 动画类型 | 数学本质 | 适用场景 |
|---|---|---|---|
| 缩放 | 大小渐变 | 半径 × progress | 多边形、矩形 |
| 分段 | 部分→完整 | count × progress | 折线、柱状图 |
| 弧展开 | 角度渐变 | angle × progress | 环形、饼图 |
七、动画性能考量
7.1 Canvas 重绘频率
animateTo驱动@State变化的频率取决于动画时长和帧率:
// 1000ms 动画,60fps → ~60 次重绘// 每次 draw() 执行完整绘制流程// 避免在 draw() 中:// 1. 创建大量临时对象// 2. 执行不必要的清除和重绘// 3. 连续调用多次 fill/stroke// ✅ draw() 中推荐:// 1. 预计算不变数据(如 angles 数组)// 2. 聚合路径(一个 beginPath + 多个 moveTo/lineTo + 一次 stroke)// 3. 必需的清除操作(clearRect)7.2 预计算优化
privatedraw():void{// ❌ 不推荐:每帧都在循环中计算 cos/sinfor(leti=0;i<sides;i++){constangle=(Math.PI*2*i)/sides-Math.PI/2constx=cx+r*Math.cos(angle)// 每帧都算}// ✅ 推荐:angles 数组只在数据变化时计算一次// 在 draw 开始时预计算(或缓存为成员变量)constangles=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 等)
欢迎在评论区分享你的选择!
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!