设想一个场景:你打开某款蓝牙耳机的App,屏幕上有个雷达正在一圈一圈扫描周围的设备;或者给智能家居配网时,页面不停扩散着波纹,告诉你"正在寻找设备"。这类"设备搜索动画"在很多Flutter项目里都会遇到,但真要做起来,远没有想象中简单。我上一次在做IoT配网工具时,就被这个UI卡了两天——用GIF画质糊、换Lottie没现成素材、硬编码每帧又太蠢,最后干脆用Flutter从零手写了一个搜索动画组件,把它彻底吃透了。
这篇文章不打算只讲"转圈圈"怎么画,而是把设备搜索动画拆成三个视觉层次:向外扩散的波纹、旋转的雷达扫描线、中心呼吸的脉冲点。我会从动画设计思路、技术选型逻辑、完整代码实现讲到性能优化和踩坑记录,把SearchRadarWidget这个可直接复用的组件完整交付出来。无论你是刚接触Flutter动画的入门读者,还是正在做蓝牙扫描页、智能家居配网页、设备发现页的客户端同行,这篇都值得从头到尾读一遍。
1. 从"设备搜索"这个场景说起:为什么值得单独做一个组件
1.1 设备搜索动画的真实使用场景
很多界面其实都在做"设备搜索",只是UI形态各不相同:蓝牙配对页的雷达扫描、Wi-Fi配网时的波纹扩散、局域网发现打印机和投屏设备时的加载动画、甚至扫码枪连接之前的手势等待动画。这些场景有几个共同点:耗时不确定、结果不确定,用户看不到也摸不着"到底搜没搜到",所以天然带着焦虑感。
我在IoT配网项目中观察到的现象是:配网过程通常要4到10秒,这段时间用户盯着屏幕,如果只有一个静态文案,流失率明显偏高;换上有明确动态反馈的搜索动画后,用户主动取消的比例降了不少。设备搜索动画在真实产品里承担的不是"装饰"角色,而是实打实的体验杠杆。
1.2 动画不是装饰,是产品的"反馈语言"
这里说一个反直觉的结论:做设备搜索动画,核心难点不在"怎么让动画好看",而在"怎么让用户读懂系统正在干什么"。好的设备搜索动画应该传递三件事:第一,系统还在工作,没有死机;第二,搜索正在向更大范围扩展;第三,搜索有阶段、有进度,不是原地空转。
我以前犯过一个错:把动画做得太炫,一圈一圈放射五彩光晕,结果测试用户反馈"并不知道是在搜索设备"。后来删掉花哨的颜色,只保留经典雷达语意——扩散的同心圆加一条旋转扫描线,用户一眼就懂了。记住,设备搜索动画本质上是产品与用户的"反馈语言",设计时要先考虑语义,再考虑视觉效果。
1.3 这篇文章最终交付什么
这篇是"设备搜索动画"系列的第一篇,成品是一个可以直接复制进项目的基础组件SearchRadarWidget:
- 一个SearchRadarWidget组件,包含三种视觉元素:扩散波纹、雷达扫描线、中心脉冲
- 完整的状态控制接口:搜索中、停止、成功、空闲
- 一套思路清晰的Flutter动画技术选型逻辑,以及自定义绘制中的性能优化经验
代码结构我会拆开讲,每一段都会说明为什么这么写。后续系列文章里,我计划继续做"搜索结果进入列表的转场动效"和"失败重试的状态切换动画",把设备搜索这个主题做成一个完整的状态动画矩阵。
2. 技术选型的核心逻辑:为什么是AnimationController + CustomPainter
2.1 三种实现路径的实际对比
拿到一个搜索动画需求,大家通常会有三种思路:用隐式动画硬堆、找现成素材(GIF/Lottie/序列帧)、用显式动画加自绘。我直接放一张对比表:
| 实现方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 隐式动画(AnimatedContainer/AnimatedOpacity等) | 声明式写法简单、代码量少 | 多元素循环节奏难同步、无法精细控制 | 单元素过渡、入场退场动画 |
| 现成素材(GIF/Lottie/APNG) | 视觉上限高、表现力强 | 包体变大、清晰度受分辨率影响、状态切换不灵活 | 复杂且固定不变的过场动效 |
| AnimationController + CustomPainter | 轻量、完全可控、矢量无限清晰 | 需要理解绘制机制、上手有门槛 | 循环播放、多元素联动、需要状态驱动的动画 |
设备搜索动画恰好落在第三类的射程里:三个视觉元素需要严格同步循环,还要响应搜索状态切换,用隐式动画堆出来光控制器就要建四五个,管理和同步都很痛苦;用GIF和Lottie虽然能"看到效果",但颜色适配主题、尺寸伸缩、状态切换都是后续的坑。所以最优解很明确:用AnimationController驱动一个CustomPainter,把所有视觉元素统一画在一张画布上。
2.2 先搞懂Flutter动画的几个底层概念
很多人看到AnimationController就照葫芦画瓢,却不理解原理,一遇到问题就懵。我尽量用大白话讲透。
Flutter动画的源头是Ticker(节拍器):屏幕每一帧刷新时,Ticker都会触发一次回调。AnimationController的本质就是一个"持有Ticker的进度条",value从0.0一路变到1.0,duration决定了走完全程需要多长时间。你可以repeat让它在0和1之间无限循环,也可以forward正着走、reverse倒着走。vsync参数的作用是把进度条和屏幕刷新节奏对齐,避免消耗多余性能。
Curve(曲线)则是调整进度条走速的"调节器"。默认线性Curve.linear是匀速走,Curves.easeInOut会在开始和结束时减速,中间加速,视觉上更接近真实物体的运动。打个比方:Ticker是一台每秒敲一次的节拍器,AnimationController是从0走到1的秒表,Curve是给秒表指针动的速度加上"起步缓、中途快、临停缓"的阻尼器。这三者配合,才能做出自然的动画。
2.3 为什么这种场景必须用自绘而不是堆Widget
我见过很多人用Stack叠三层来实现波纹效果:外层套Opacity、中间套Transform.scale、内层再套AnimatedContainer。这样确实能跑,但麻烦很快就来了:三个元素要节奏同步,每个都得建Controller;透明度变化又要监听进度;当搜索状态突然切换时,你还要手动暂停和恢复多个Controller,代码瞬间变成意大利面条。
用CustomPainter就清晰得多:所有元素在paint方法里按顺序绘制,共享同一个progress进度值。波纹的半径和透明度是progress的纯函数、扫描线的旋转角度是progress的线性映射、中心脉冲的半径是progress的三角函数——同一输入,三种输出,节奏天然同步。性能上也有优势:一次Canvas绘制调用搞定所有元素,不需要频繁触发Widget层的build和layout。
3. 动画设计拆解:波纹、雷达线、脉冲点分别承担什么角色
3.1 扩散波纹:向用户传递"范围正在扩大"
一个设备搜索动画里,波纹是绝对主角。它的视觉隐喻是"从中心点向外探测",一圈一圈的同心圆像声呐一样不断扩张,用户通过波纹半径的逐渐变大,理解到"系统正在更大的范围内寻找设备"。
实际实现时要注意两个细节:波纹的起始半径不能是0。如果从0开始扩散,第一帧会有一个"点突然出现"的突兀感;我一般把起始半径设在最大半径的15%左右,让波纹"从有到无"地扩展出去。另外环的数量3到4个就足够了,超过5个在视觉上会糊成一团,用户反而看不清边界。
透明度衰减也是关键。每个波纹从内向外扩散时,透明度要线性地从0.8降到0.0。如果透明度不衰减,扩散到边缘的圆环和中心的圆环亮度一样,整个动画会显得"平"且"生硬"。衰减曲线其实用线性就够了,不必加Curve——因为扩散本身已经提供了速度感,线性衰减反而更干净。
3.2 雷达扫描线:制造"正在工作"的方向感和机械感
如果说波纹负责表现"范围扩大",雷达扫描线则负责传递"我正在进行一轮完整的扫描"。一条从圆心向外延伸的光带绕圈旋转,每个周期就是一次完整的"扫描动作",这和蓝牙搜索过程中"发射询问信号—等待回应"的机制在语义上高度吻合,用户看到雷达线的时候几乎不需要学习成本。
雷达线的实现我倾向于画一个扇形光带,扇形角度控制在120度左右。角度太小像一条细细的针,存在感弱;角度太大(超过260度)则像一个劣质的旋转风扇,机械感过强。颜色上使用从圆心往外逐渐透明的渐变,形成"尾部拖影"的效果,视觉上更柔和。方向统一顺时针,这和大多数人习惯的"扫描方向"一致。
3.3 中心脉冲:让主体"活"起来
中心位置的小圆点是整个动画的视觉锚点——它既是波纹的源头,也是用户视线最终聚焦的地方。这个锚点不能是静止的,否则波纹向外扩散、雷达线旋转时,中心点会给人一种"漏气"的感觉。我给它加了一个规律的呼吸效果:半径随进度正弦波动,像个心脏一样收缩舒张。
一个容易被忽略的细节是节奏错位:脉冲的收缩频率要明显快于波纹的扩散周期,让"心跳感"独立于"范围感"。如果脉冲和波纹完全同频,视觉上会显得很机械,像齿轮咬合;略微错开之后,整体才有了层次。我一般把脉冲周期设为波纹周期的1/3左右,也就是在同一个搜索周期内,脉冲完成三次起伏。
3.4 三个要素的节奏配合与参数经验值
在实际项目中,我推荐的参考周期是1600ms。在这个周期里:扩散波纹从最小半径扩张到最大半径,透明度缓缓降为0;雷达扫描线绕圆心旋转一整圈(360度);中心脉冲完成三次收缩舒张。三者互不干扰,又共同塑造了"正在搜索"的氛围。
参数推荐值我整理一下:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 动画周期 | 1200-1800ms | 太短显得急躁,太长显得迟钝 |
| 波纹数量 | 3-4个 | 大于5个视觉会糊 |
| 波纹最小半径 | 最大半径的15% | 避免从0开始的突兀 |
| 扫描扇形角度 | 100-140度 | 太窄无感,太宽像风扇 |
| 脉冲频率倍率 | 波纹周期的2-3倍 | 强化"呼吸感" |
4. 代码实现:从零搭一个可直接复用的SearchRadarWidget
4.1 组件骨架与状态定义
直接上代码。先定义一个枚举来表示搜索状态:空闲、搜索中、成功、失败。成功和失败的状态驱动动画预留到系列后续文章,本篇先把搜索中效果做扎实。
import 'dart:math'; import 'dart:ui'; import 'package:flutter/material.dart'; enum SearchState { idle, searching, success, fail } class SearchRadarWidget extends StatefulWidget { const SearchRadarWidget({ super.key, this.size = 240, this.color = const Color(0xFF4FC3F7), this.ringCount = 3, this.duration = const Duration(milliseconds: 1600), }); final double size; final Color color; final int ringCount; final Duration duration; @override State<SearchRadarWidget> createState() => _SearchRadarWidgetState(); } class _SearchRadarWidgetState extends State<SearchRadarWidget> with SingleTickerProviderStateMixin { late AnimationController _controller; SearchState _state = SearchState.idle; @override void initState() { super.initState(); _controller = AnimationController(vsync: this, duration: widget.duration); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _controller, builder: (context, child) { return CustomPaint( size: Size.square(widget.size), painter: _SearchRadarPainter( progress: _controller.value, color: widget.color, ringCount: widget.ringCount, state: _state, ), ); }, ); } }这里有几个细节想多说一句。我用的是SingleTickerProviderStateMixin而不是TickerProviderStateMixin,因为组件只有一个AnimationController,没必要给自己开多Ticker的"派对"——少一个Ticker就少一份资源开销。另外注意CustomPaint的size参数,我用Size.square让画布宽高相等,雷达这种圆形组件最怕的就是宽高不一致导致波纹变椭圆。
4.2 扩散波纹的实际绘制代码
接下来是核心Painter,我先把扩散波纹的完整实现给出来,再逐行解释。
class _SearchRadarPainter extends CustomPainter { _SearchRadarPainter({ required this.progress, required this.color, required this.ringCount, required this.state, }); final double progress; final Color color; final int ringCount; final SearchState state; @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); final maxRadius = size.width / 2 * 0.9; // 扩散波纹 for (int i = 0; i < ringCount; i++) { final ringProgress = (progress + i / ringCount) % 1.0; final radius = maxRadius * (0.15 + 0.85 * ringProgress); final opacity = (1 - ringProgress).clamp(0.0, 1.0); canvas.drawCircle( center, radius, Paint() ..style = PaintingStyle.stroke ..strokeWidth = 2.0 ..color = color.withValues(alpha: opacity * 0.8), ); } } }逐行拆解这十几个公式。maxRadius * 0.9是我预留的10%边距,让波纹扩散到最大时也不会顶到画布边缘。ringProgress = (progress + i / ringCount) % 1.0这行是灵魂:当progress从0到1走完一轮时,第0个环从0开始扩张,第1个环的起始点被平移到1/3处,第2个环在2/3处——三个环均匀错开,视觉上就是一个接一个向外扩散的连续波。如果去掉i / ringCount,所有环会同时扩张,看起来就像在吹一个不断变大的气球,完全失去了波纹的层次。
radius = maxRadius * (0.15 + 0.85 * ringProgress)的意图是把最小半径锚定在15%的位置,避免从圆心一个小点突然冒出来。opacity = (1 - ringProgress).clamp(0.0, 1.0)则是让每个环在扩散过程中逐渐变淡,环扩张得越大,透明度越低,最后像气泡一样"消失"在边缘。
这里特别提醒一个版本坑:Flutter 3.27之前,设置透明度用的是withOpacity(0.8);从3.27开始,官方标记withOpacity为废弃,推荐用withValues(alpha: 0.8)。如果你的项目还在用withOpacity,编译时会出现deprecation警告,虽然不是错误,但在新版本上迟早要迁移。我代码里已经用了withValues。
4.3 扫描线与中心脉冲的绘制细节
继续在paint方法里追加雷达扫描线和中心脉冲。
// 雷达扫描扇形 final scanAngle = 2.5; // 约143度 final startAngle = -pi / 2 + progress * 2 * pi; final sectorRect = Rect.fromCircle(center: center, radius: maxRadius); final sweepPaint = Paint() ..shader = SweepGradient( startAngle: startAngle, endAngle: startAngle + scanAngle, colors: [ color.withValues(alpha: 0.35), color.withValues(alpha: 0.0), ], stops: const [0.0, 1.0], ).createShader(sectorRect); canvas.drawArc(sectorRect, startAngle, scanAngle, false, sweepPaint); // 中心脉冲 final pulseRadius = maxRadius * (0.10 + 0.03 * sin(progress * 2 * pi)); canvas.drawCircle( center, pulseRadius, Paint()..color = color.withValues(alpha: 0.85), );扫描线的关键在于SweepGradient和drawArc的配合。SweepGradient是一个以圆心为中心、按角度渐变的着色器,它的startAngle从圆心正上方(-pi/2)起始,然后随着progress不断旋转。当progress从0变到1,startAngle正好从-π/2旋转到-π/2 + 2π,也就是整整一圈。drawArc的第二个参数是起始角,第三个参数是扫过的角度,我这里设为2.5弧度(约143度),配合渐变色形成一个"前亮后暗"的扇形光带。
有一个非常容易踩的坑:SweepGradient的startAngle和drawArc的startAngle是同一个坐标系,但Unity、CSS等其他平台的旋转起点各不相同。Flutter里0弧度指向右侧,-π/2指向正上方,我在代码里显式写了-pi/2,让雷达线初始位置朝上,符合多数用户对扫描动画的心理预期。
中心脉冲的做法是让半径围绕基准值做正弦波动:0.10 + 0.03 * sin(...),基准半径为最大半径的10%,波动幅度是3%。sin函数让它在一个周期内先变大再变小,形成呼吸效果。注意这里周期和主progress周期一致,但视觉上脉冲会比波纹"快半拍",因为sin的斜率在0到π之间变化剧烈,而波纹是线性扩散——两者叠加在一起,脉冲的起伏感会自然突出。
4.4 组件状态控制:startSearch、stopSearch与生命周期的坑
光有build逻辑还不够,组件必须能响应外部状态。我加三个公开方法:
void startSearch() { if (_controller.isAnimating) return; _controller.repeat(); setState(() => _state = SearchState.searching); } void stopSearch() { _controller ..stop() ..value = 0.0; setState(() => _state = SearchState.idle); } void reset() { _controller ..stop() ..value = 0.0; setState(() => _state = SearchState.idle); } SearchState get currentState => _state;startSearch里的if (_controller.isAnimating) return这一行至关重要。我见过不少同事直接调用_controller.repeat(),结果在连续点击场景下,控制台疯狂输出"AnimationController.repeat() called after stop()"之类的警告,动画还可能出现闪跳。加上状态判断之后,重复调用就变成了"幂等操作"。
stopSearch为什么要把value手动归零?如果不归零,下次startSearch时,progress会从上次停下的位置继续走——视觉上波纹会从半空中突然冒出来,非常突兀。归零确保每次重新搜索都是从"最小半径+初始透明度"的状态重新开始。
最后,单例Controller的dispose千万不能漏。我在前面代码里已经写了_controller.dispose()。Flutter官方文档里说的内存泄漏案例,有一半都是忘了释放Ticker。特别是页面频繁进出时,不释放会导致异常:页面销毁后Controller还在请求帧回调,轻则报错,重则卡死。
5. 性能优化与踩坑记录:同一套代码,不同写法差3倍
5.1 首版为什么在低端Android机器上掉帧
我第一次写完这个组件,兴致勃勃地跑到一台老款Android测试机上跑,结果帧率只有40fps上下,动画一顿一顿的。排查过程很有意思:问题不在CustomPainter本身,而在widget树结构。
当时我把AnimatedBuilder直接放在了包含整个搜索页面的外层,导致动画每帧变化时,整个页面(包括背景图、文字、列表项)全部跟着重建。这些无关组件每帧做build和layout,白白吃掉大量GPU与CPU时间。后来我做了两件事:一是把AnimatedBuilder收缩到CustomPaint组件内部,只让它负责画布区域的重建;二是在CustomPaint外面包一层RepaintBoundary,把重绘范围严格隔离在雷达画布内。
return RepaintBoundary( child: AnimatedBuilder( animation: _controller, builder: (context, child) { return CustomPaint(...); }, ), );改完之后,同样一台低端机,帧率稳定在60fps。RepaintBoundary的原理是给子组件一个独立的Layer,当子组件内容变化时,不会连带绘制父层的其他部分。这在Flutter里是动画性能优化最基础的"手术刀"。
5.2 合理实现shouldRepaint:不是无脑返回true
CustomPainter的设计里有一个shouldRepaint方法,决定当新的Painter实例传入时,要不要重新绘制。很多人图省事直接return true,这在简单场景没问题,但在复杂页面里,每次parent rebuild都会触发整个画布重绘,性能就浪费了。
正确做法是精确比较依赖字段:
@override bool shouldRepaint(covariant _SearchRadarPainter oldDelegate) { return oldDelegate.progress != progress || oldDelegate.color != color || oldDelegate.ringCount != ringCount || oldDelegate.state != state; }由于progress每帧都变,实际运行时每次都会返回true——但这是一个"必要的true",颜色和环数变化时才会触发额外重绘。更重要的是,当节点从widget树上摘除(比如页面切走)时,不会因为shouldRepaint误判而做无意义的绘制。
5.3 我在实际项目里踩过的四个坑
第一个坑:忘了加SingleTickerProviderStateMixin,直接报"Could not find a TickerProvider"。原因是AnimationController需要拿到TickerProvider来做vsync。解决办法就是在State后加上mixin,或者干脆改用TickerProviderStateMixin(多个控制器时用)。
第二个坑:重复调用repeat导致警告。前面已经讲过了,加上isAnimating判断即可,这里不再赘述。
第三个坑:withOpacity废弃警告。这个很多人还不知道:Flutter 3.27开始Color.withOpacity正式废弃,推荐withValues(alpha: ...)。代码里用新API,至少在维护周期里不会过时。如果项目还被锁在旧版本,那还是用withOpacity。
第四个坑:Hot Reload之后动画卡住不动。这其实不是代码问题:热重载会重建widget tree,但AnimationController的Ticker状态可能没有正确恢复。处理方法很简单:页面重新进入一次,或者把控制器dispose重来。真机调试时遇到动画卡顿,先试试热重启,别一上来就怀疑算法。
5.4 用PerformanceOverlay做真实帧率监测
调试动画性能不能只靠眼神感受,我习惯在开发阶段临时启用性能叠加层:
return MaterialApp( title: 'SearchRadar Demo', builder: (context, child) { return PerformanceOverlay( options: PerformanceOverlayOption.totalFrames, child: child!, ); }, );在debug模式下,屏幕上方会出现帧率和耗时曲线;切换Release/Profile模式后,叠加层消失,得到真实渲染性能。要注意的是:不要只看Debug模式的数字。Debug模式本身有大量断言和调试逻辑,开销比Release高不少。真正要验证手机端性能,请用Profile模式跑。我实测这个搜索动画在Profile模式下能稳定60fps,Debug模式只有48fps左右——如果有人拿Debug模式帧数说"动画卡",大概率是没切Profile。
6. 把动画组件接到真实业务里的适配思路
6.1 从"控件"变成"状态机"的接入模型
很多Flutter初学者会把搜索动画当成一个"装饰控件"使用:搜索开始就让它转,搜完就让它停。但真实业务远比这个复杂:蓝牙扫描可能反复回调、配网可能中途失败、用户可能中途取消。我的建议是把组件改造成一个"状态机"来使用。
组件对外只暴露最少的状态入口:startSearch、stopSearch、reset、以及currentState。业务层通过蓝牙/配网SDK的回调来驱动这些方法。举个例子,在一个蓝牙扫描页里:
void _onScanStarted() { radarKey.currentState?.startSearch(); } void _onDeviceFound(BluetoothDevice device) { setState(() => devices.add(device)); // 找到设备不立刻停止动画,让用户看到列表在增加 } void _onScanTimeout() { radarKey.currentState?.stopSearch(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('未发现设备')), ); }搜索到设备时不急着停动画,这是一个值得强调的细节:设备是一个一个出现的,动画继续转,用户才知道"还在继续找新的"。只有超时或明确结束,才需要停止动画。这种状态机思维是设备搜索动画区别于普通loading动画的关键点。
6.2 参数化封装:让同一个组件适配多种业务
第二个经验是把组件参数充分暴露出去。不同App里设备搜索动画的配色、尺寸、环数往往不同。我建议在构造参数里至少暴露这些:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| size | double | 240 | 画布直径 |
| color | Color | 0xFF4FC3F7 | 主题色 |
| ringCount | int | 3 | 波纹数量 |
| duration | Duration | 1600ms | 单周期时长 |
| strokeWidth | double | 2.0 | 波纹线宽 |
| scanAngle | double | 2.5 | 扫描扇形弧度 |
调用方可以按自己的品牌色生成不同副本,而无需改动内部逻辑。我一般会把组件放进项目的components目录,配上默认参数后全局复用。一个组件在三个项目里反复用,每次只改几行参数,这种"一次做好、到处可用"的收益远大于在页面上临时写死一堆动画逻辑。
6.3 关于成功态和失败态的一点预告
这篇只实现了"搜索中"的动态,但真实业务最终都会走向"搜索完成"。我在设计状态枚举时提前把success和fail放了进来,为的就是给后续留出扩展位。目前的规划是:成功态用一圈从内向外扩散的实心色块收拢成对勾;失败态则让波纹以收拢方式退场,配合震动感。这些内容我准备放在系列第二篇讲。
为什么不在这一篇里一起做完?因为设备搜索的成功态和失败态本质上属于"转场过渡动画",它涉及的不是单一组件,而是搜索动画与结果列表、错误提示之间的联动。把搜索中的"循环动画"和结束后的"一次性过渡动画"混在一个组件里,反而会让组件职责变重。拆开做系列,组件边界反而清晰。
做完这个组件再回头看,最大的体会是:设备搜索动画的价值不在特效本身,而在于它是否让用户安心——知道设备在被找、知道找的范围在扩大、知道整个过程有始有终。你在实际项目里接入任何搜索动画时,也不妨先问自己一句:这个动画有没有让用户读懂系统正在干什么?如果答案是肯定的,哪怕波纹只有两圈、扫描线只是一个点,那它也是好动画;反之,特效再炫也只是空转。我很建议拿到代码后先跑起来,然后逐个改参数看效果——动画这东西,看一百篇文章不如自己改一遍数值来得深刻。