1. 项目缘起与整体设计思路
1.1 为什么选择这个效果作为练手项目
第一次看到这个效果的时候,我正坐在工位上刷手机,屏幕上那个旋转的小太空人配合着背后若隐若现的蛛网线条,视觉冲击力确实强。当时脑子里第一反应是:这玩意儿用原生Android写,少说得折腾半天自定义View加属性动画,但用Flutter来做,可能就是一个CustomPainter加AnimationController的事。
事实证明这个判断是对的。这个项目本质上是一个纯绘制型动画,不涉及网络请求、不涉及状态管理、不涉及复杂的路由跳转,核心就两件事:画一个会转的太空人和画一张会呼吸的蛛网。对于想深入理解Flutter绘制体系和动画体系的开发者来说,这是一个绝佳的练手素材。
我把它定位成一个中级Flutter开发者的进阶练习。如果你已经能熟练使用基础Widget搭建页面,但对CustomPaint、Canvas、AnimationController这些概念还停留在“知道但没怎么用过”的阶段,那这个项目正好能帮你把这些知识点串起来。它不需要你有多深的数学功底,但需要你对三角函数、坐标系变换、动画曲线这些基础概念有基本的感知。
1.2 整体架构拆解:两个图层,一个控制器
整个效果的架构可以拆成三个部分来理解。
第一部分是背景蛛网层。这一层负责绘制从中心向外辐射的蛛网结构,包括径向的蛛丝和环向的蛛丝。它的特点是静态中带着动态——蛛丝本身位置固定,但透明度会随着动画周期变化,形成一种“呼吸感”。
第二部分是太空人层。这一层负责绘制太空人的各个部件(头盔、身体、四肢、背包),并且让整个太空人围绕中心点做旋转运动。旋转的同时,太空人自身可能还有轻微的摆动或缩放,增加灵动感。
第三部分是动画控制器。一个AnimationController同时驱动两个图层,但两个图层对动画值的响应方式不同。蛛网层用的是透明度映射,太空人层用的是旋转角度映射。这种“一个控制器驱动多个视觉属性”的模式,在实际项目中非常常见,比如加载动画、引导页动效等。
提示:不要把两个图层拆成两个独立的AnimationController,那样会导致动画不同步,视觉上会有割裂感。一个控制器,多个Listener,是更稳妥的做法。
1.3 技术选型背后的考量
为什么用CustomPainter而不是用现成的Widget组合?这个问题我一开始也纠结过。用Transform.rotate加Image.asset也能让一张太空人图片转起来,但那样做有几个问题:一是图片资源需要额外准备,二是旋转时的锯齿和模糊不好控制,三是没法做部件级别的独立动画。
用CustomPainter的好处在于,每一个像素都在你的掌控之中。太空人的头盔可以用圆弧画,身体可以用圆角矩形画,四肢可以用路径画,背包可以用矩形加圆角画。所有部件都是矢量图形,放大缩小不会失真,旋转时也不会有图片插值带来的模糊问题。而且后续想改颜色、改粗细、改比例,都是改几个参数的事,不需要重新导出图片资源。
蛛网层更是如此。用图片做蛛网,要么是静态的,要么得做序列帧,无论哪种都不如直接用Canvas画线来得灵活。蛛网的线条数量、粗细、透明度、辐射角度,全部可以通过参数控制,想调成什么样就调成什么样。
2. 核心细节解析与实操要点
2.1 坐标系与旋转中心的理解
在开始画任何东西之前,必须先搞清楚Canvas的坐标系。Flutter的Canvas坐标系原点在左上角,x轴向右,y轴向下。这个和数学课上学的笛卡尔坐标系不一样,y轴是反的。很多人在画旋转效果时觉得“转起来怪怪的”,十有八九是坐标系没理解到位。
旋转中心的问题更关键。Canvas默认的旋转中心是左上角原点,但我们的太空人显然应该围绕屏幕中心旋转。所以必须用canvas.translate()把坐标原点移到屏幕中心,然后再做旋转。这里有一个容易踩的坑:translate和rotate的顺序不能反。如果先rotate再translate,旋转中心就变成了左上角,效果完全不对。
正确的顺序是这样的:
canvas.save(); canvas.translate(center.dx, center.dy); // 先把原点移到中心 canvas.rotate(angle); // 再围绕新原点旋转 // 在这里画太空人,坐标以中心为原点 canvas.restore();save()和restore()的配对使用也是必须的。save()保存当前画布状态,restore()恢复到save()之前的状态。如果不做这个配对,translate和rotate的影响会累积到后续所有绘制操作上,导致蛛网层也跟着转,那就乱套了。
2.2 太空人各部件的绘制拆解
太空人的绘制我把它拆成了六个部件:头盔、面罩、身体、左臂、右臂、背包。每个部件用不同的Canvas API来画。
头盔用一个圆形来画,圆心在太空人的头部位置,半径根据整体比例来定。面罩是头盔内部的一个稍小的圆形或椭圆,用半透明颜色填充,模拟玻璃质感。
身体用一个圆角矩形来画,宽度比头盔略窄,高度大概是头盔直径的1.5倍。圆角半径取宽度的三分之一左右,看起来比较圆润。
手臂用路径来画,从身体两侧伸出,稍微向下倾斜。手臂的粗细用strokeWidth控制,端点用StrokeCap.round做成圆头,看起来更柔和。
背包用一个小矩形画在身体后方,颜色比身体深一个色号,增加层次感。
这里有一个细节:所有部件的坐标都应该是相对于太空人自身中心的,而不是相对于屏幕的。这样在旋转时,整个太空人会作为一个整体围绕屏幕中心转动,而不是各个部件各自为政。
2.3 蛛网效果的数学原理
蛛网的本质是一个极坐标系下的网格。径向的蛛丝是从中心向外辐射的直线,环向的蛛丝是以中心为圆心的同心圆弧。
径向蛛丝的画法:假设有N条径向蛛丝,那么每条蛛丝与x轴的夹角就是2π/N * i,其中i从0到N-1。每条蛛丝的起点是中心点,终点是中心点加上一个固定长度的向量,向量的方向由夹角决定。
环向蛛丝的画法:假设有M条环向蛛丝,那么每条环向蛛丝的半径就是maxRadius/M * j,其中j从1到M。每条环向蛛丝是一个圆弧,从0度画到360度。
这里有一个视觉上的小技巧:径向蛛丝和环向蛛丝的交点处可以加一个小圆点,模拟蜘蛛网上的露珠或者节点,会让整个蛛网看起来更精致。这个小圆点的半径可以随着动画周期变化,增加细节上的动感。
2.4 动画曲线的选择与调优
AnimationController本身只是一个线性插值器,从0到1均匀变化。但真实的动画很少是匀速的,所以需要Curve来调整动画的节奏感。
对于太空人的旋转,我用的是Curves.linear,因为旋转本身就是一个匀速运动,用其他曲线反而会显得卡顿。但对于蛛网的透明度变化,我用的是Curves.easeInOut,让透明度变化有一个平滑的加速和减速过程,看起来更自然。
还有一个细节:动画的持续时间。太空人转一圈的时间我设的是4秒,这个速度不快不慢,既能看清太空人的细节,又不会让人觉得拖沓。蛛网的呼吸周期我设的是2秒,比太空人转一圈快一倍,这样两个动画的节奏有差异,视觉上更丰富。
注意:动画持续时间不要设得太短,否则视觉上会显得很急躁。也不要设得太长,否则用户会觉得卡住了。2到5秒是一个比较舒适的区间。
3. 实操过程与核心环节实现
3.1 项目初始化与基础框架搭建
新建一个Flutter项目,把默认的计数器代码全部删掉。在main.dart里,我们只需要一个MaterialApp,home指向一个StatefulWidget,我把它命名为SpaceWebPage。
为什么用StatefulWidget而不是StatelessWidget?因为我们需要管理AnimationController的生命周期。AnimationController必须在initState里创建,在dispose里释放,这两个生命周期钩子只有StatefulWidget才有。
在initState里创建AnimationController的时候,vsync参数传this,并且当前State需要混入SingleTickerProviderStateMixin。这个Mixin的作用是告诉Flutter框架,这个State需要一个Ticker来驱动动画。如果忘了混入这个Mixin,运行时会直接报错。
class _SpaceWebPageState extends State<SpaceWebPage> with SingleTickerProviderStateMixin { late AnimationController _controller; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(seconds: 4), )..repeat(); } @override void dispose() { _controller.dispose(); super.dispose(); } }repeat()方法让动画无限循环,这正是我们需要的效果。如果只想播放一次,可以用forward()。
3.2 蛛网层的完整绘制代码
蛛网层的绘制我封装成了一个独立的CustomPainter类,叫SpiderWebPainter。它接收一个animationValue参数,范围是0到1,用来控制透明度。
class SpiderWebPainter extends CustomPainter { final double animationValue; final int radialCount; final int ringCount; final double maxRadius; SpiderWebPainter({ required this.animationValue, this.radialCount = 12, this.ringCount = 5, required this.maxRadius, }); @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); final paint = Paint() ..color = Colors.white.withOpacity(0.15 + animationValue * 0.1) ..strokeWidth = 1.0 ..style = PaintingStyle.stroke; // 画径向蛛丝 for (int i = 0; i < radialCount; i++) { final angle = (2 * pi / radialCount) * i; final endPoint = Offset( center.dx + maxRadius * cos(angle), center.dy + maxRadius * sin(angle), ); canvas.drawLine(center, endPoint, paint); } // 画环向蛛丝 for (int j = 1; j <= ringCount; j++) { final radius = (maxRadius / ringCount) * j; canvas.drawCircle(center, radius, paint); } // 画节点 final nodePaint = Paint() ..color = Colors.white.withOpacity(0.3 + animationValue * 0.2) ..style = PaintingStyle.fill; for (int i = 0; i < radialCount; i++) { final angle = (2 * pi / radialCount) * i; for (int j = 1; j <= ringCount; j++) { final radius = (maxRadius / ringCount) * j; final point = Offset( center.dx + radius * cos(angle), center.dy + radius * sin(angle), ); canvas.drawCircle(point, 2.0, nodePaint); } } } @override bool shouldRepaint(covariant SpiderWebPainter oldDelegate) { return oldDelegate.animationValue != animationValue; } }这段代码里有几个值得注意的点。第一,maxRadius我传的是屏幕宽度的一半再乘以1.2,这样蛛网会稍微超出屏幕边缘,视觉上更有延伸感。第二,shouldRepaint方法必须正确实现,否则动画不会触发重绘。第三,节点的绘制用了双重循环,遍历所有径向和环向的交点。
3.3 太空人层的完整绘制代码
太空人层的绘制封装在AstronautPainter里,它接收一个rotationAngle参数,控制旋转角度。
class AstronautPainter extends CustomPainter { final double rotationAngle; AstronautPainter({required this.rotationAngle}); @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); canvas.save(); canvas.translate(center.dx, center.dy); canvas.rotate(rotationAngle); // 画背包 final backpackPaint = Paint() ..color = const Color(0xFF4A4A6A) ..style = PaintingStyle.fill; canvas.drawRRect( RRect.fromRectAndRadius( const Rect.fromLTWH(-30, -20, 20, 40), const Radius.circular(6), ), backpackPaint, ); // 画身体 final bodyPaint = Paint() ..color = const Color(0xFFE0E0E0) ..style = PaintingStyle.fill; canvas.drawRRect( RRect.fromRectAndRadius( const Rect.fromLTWH(-20, -15, 40, 50), const Radius.circular(12), ), bodyPaint, ); // 画头盔 final helmetPaint = Paint() ..color = const Color(0xFFF5F5F5) ..style = PaintingStyle.fill; canvas.drawCircle(const Offset(0, -35), 22, helmetPaint); // 画面罩 final visorPaint = Paint() ..color = const Color(0xFF1A1A2E).withOpacity(0.8) ..style = PaintingStyle.fill; canvas.drawArc( Rect.fromCircle(center: const Offset(0, -35), radius: 16), -pi * 0.8, pi * 1.6, true, visorPaint, ); // 画手臂 final armPaint = Paint() ..color = const Color(0xFFD0D0D0) ..strokeWidth = 8 ..strokeCap = StrokeCap.round ..style = PaintingStyle.stroke; canvas.drawLine(const Offset(-20, -5), const Offset(-35, 15), armPaint); canvas.drawLine(const Offset(20, -5), const Offset(35, 15), armPaint); canvas.restore(); } @override bool shouldRepaint(covariant AstronautPainter oldDelegate) { return oldDelegate.rotationAngle != rotationAngle; } }这段代码里,所有坐标都是相对于中心点的。头盔在(0, -35)的位置,身体从(-20, -15)到(20, 35),背包在身体左侧。手臂从身体两侧伸出,稍微向下倾斜。
面罩的绘制用了一个小技巧:用drawArc画一个扇形,起始角度是-pi * 0.8,扫过pi * 1.6弧度,这样面罩会覆盖头盔的大部分区域,但底部留出一点空间,看起来更像一个面罩而不是一个完整的圆。
3.4 两个图层的组合与动画驱动
在build方法里,我们用AnimatedBuilder来监听动画控制器的变化,然后把两个CustomPaint叠在一起。
@override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFF0D0D1A), body: AnimatedBuilder( animation: _controller, builder: (context, child) { return Stack( children: [ CustomPaint( size: MediaQuery.of(context).size, painter: SpiderWebPainter( animationValue: _controller.value, maxRadius: MediaQuery.of(context).size.width * 0.6, ), ), CustomPaint( size: MediaQuery.of(context).size, painter: AstronautPainter( rotationAngle: _controller.value * 2 * pi, ), ), ], ); }, ), ); }这里_controller.value从0变到1,乘以2 * pi就变成了0到360度的旋转角度。蛛网层直接用_controller.value作为透明度参数,范围是0到1。
提示:Stack里的两个CustomPaint顺序很重要。先放的在下层,后放的在上层。蛛网应该在太空人下面,所以蛛网的CustomPaint放在前面。
4. 常见问题与排查技巧实录
4.1 动画不流畅或卡顿怎么办
这是最常见的问题。首先检查shouldRepaint方法是否正确实现。如果shouldRepaint永远返回false,动画就不会触发重绘,画面就是静止的。如果shouldRepaint永远返回true,每一帧都会重绘,性能会受影响,但至少动画是动的。
其次检查AnimationController的duration是否设置合理。如果duration设得太短,比如100毫秒,动画会快得看不清。如果设得太长,比如30秒,又会觉得卡住了。
还有一个容易被忽略的点:不要在paint方法里创建Paint对象。Paint对象的创建是有开销的,如果每一帧都创建新的Paint对象,性能会下降。正确的做法是把Paint对象作为Painter类的成员变量,在构造函数里初始化,或者用late final延迟初始化。
4.2 旋转中心不对怎么排查
如果太空人不是围绕屏幕中心旋转,而是围绕左上角或者某个奇怪的点旋转,那一定是translate和rotate的顺序或者参数有问题。
排查步骤是这样的:先在translate之后、rotate之前画一个红色的点,看看这个点是不是在屏幕中心。如果是,说明translate没问题。然后再在rotate之后画一个点,看看这个点是不是围绕中心旋转的。如果红点位置对了但旋转还是不对,那可能是rotate的角度计算有问题。
还有一个可能是canvas.save()和canvas.restore()没有配对。如果save了但没有restore,后续的绘制操作会继承之前的变换矩阵,导致画面错乱。
4.3 蛛网线条太粗或太细怎么调
蛛网的线条粗细由Paint的strokeWidth控制。我一开始设的是2.0,画出来发现太粗了,整个蛛网看起来很笨重。后来改成1.0,感觉刚好。如果你在高分辨率屏幕上觉得太细,可以适当调大,但一般不建议超过2.0。
线条的透明度也很关键。我用的是Colors.white.withOpacity(0.15 + animationValue * 0.1),基础透明度0.15,动画叠加0.1,范围是0.15到0.25。这个透明度在深色背景上刚好能看清,又不会太抢眼。如果背景色比较浅,可能需要换成深色线条。
4.4 太空人部件位置错乱怎么排查
太空人的部件位置都是硬编码的坐标,如果画出来位置不对,最可能的原因是坐标计算错误。建议先用一个简单的坐标系来验证:在中心点画一个十字线,然后逐个部件画上去,看看每个部件相对于中心点的位置是否符合预期。
另一个常见问题是部件的绘制顺序。背包应该在身体后面,身体应该在头盔后面,手臂应该在身体后面。如果顺序反了,会出现部件互相遮挡的奇怪效果。在Canvas里,后画的会覆盖先画的,所以绘制顺序就是图层顺序。
4.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 动画完全不动 | shouldRepaint返回false | 在shouldRepaint里打印日志 | 确保animationValue变化时返回true |
| 旋转中心在左上角 | translate和rotate顺序反了 | 在translate后画红点验证 | 先translate再rotate |
| 画面闪烁 | 每帧创建新Paint对象 | 检查paint方法内是否有new Paint | 把Paint提为成员变量 |
| 蛛网超出屏幕太多 | maxRadius设得太大 | 打印maxRadius值 | 调整为屏幕宽度的0.5到0.6倍 |
| 太空人部件重叠错乱 | 绘制顺序不对 | 逐个注释掉部件绘制代码 | 按背包、身体、手臂、头盔的顺序绘制 |
| 动画速度太快 | duration设得太短 | 检查duration值 | 调整为2到5秒 |
| 内存泄漏 | dispose里没释放controller | 检查dispose方法 | 调用_controller.dispose() |
4.6 性能优化的几个实操心得
第一个心得是尽量减少save和restore的嵌套层数。每一层save都会把当前画布状态压入栈中,restore时再弹出。如果嵌套太多层,会有一定的性能开销。在这个项目里,只需要在太空人绘制时做一次save和restore就够了。
第二个心得是用RepaintBoundary隔离重绘区域。如果页面里还有其他不动的元素,可以用RepaintBoundary把它们包起来,这样动画重绘时不会影响到它们。不过在这个项目里,整个页面都是动画区域,所以RepaintBoundary的作用不大。
第三个心得是避免在paint方法里做复杂计算。比如蛛网的节点坐标,如果每次paint都重新计算所有节点的位置,会有一定的计算开销。可以在构造函数里预先计算好所有节点的坐标,存到一个List里,paint方法直接遍历List就行。
// 在构造函数里预计算节点坐标 final List<Offset> _nodes = []; SpiderWebPainter({...}) { for (int i = 0; i < radialCount; i++) { final angle = (2 * pi / radialCount) * i; for (int j = 1; j <= ringCount; j++) { final radius = (maxRadius / ringCount) * j; _nodes.add(Offset(radius * cos(angle), radius * sin(angle))); } } }这样paint方法里只需要遍历_nodes列表,加上中心点的偏移量就行了,省去了每次重算三角函数的开销。
4.7 视觉效果调优的几个小技巧
第一个技巧是给太空人加一点阴影。在头盔和身体之间画一个半透明的椭圆,模拟阴影效果,会让太空人看起来更有立体感。
第二个技巧是给蛛网加一点渐变。用Paint()..shader = RadialGradient(...)创建一个径向渐变,让蛛网从中心到边缘逐渐变淡,视觉效果会更柔和。
第三个技巧是给太空人加一点浮动。在旋转的同时,让太空人的y坐标做一个正弦波动,幅度不用太大,5到10个像素就够了,看起来就像在太空中漂浮一样。
final floatOffset = sin(_controller.value * 2 * pi) * 8; canvas.translate(center.dx, center.dy + floatOffset);这个浮动效果和旋转叠加在一起,整个画面会生动很多。我实测下来,加了浮动之后,视觉上的“太空感”明显增强了。
4.8 后续扩展方向
这个项目的基础框架搭好之后,可以往几个方向扩展。一是增加交互,比如点击屏幕时太空人加速旋转,或者蛛网收缩一下。二是增加粒子效果,在蛛网上随机分布一些闪烁的小点,模拟星空。三是增加多个太空人,让它们以不同的速度和方向旋转,形成更复杂的视觉效果。
还有一个比较实用的扩展方向是把这个效果封装成一个可复用的Widget,接收颜色、速度、大小等参数,方便在其他项目里直接调用。封装的时候注意把AnimationController的生命周期管理好,对外只暴露配置参数,不暴露内部实现细节。
我个人在实际操作中的体会是,这种纯绘制型的动画项目,最难的不是写代码,而是调参数。同样的代码,参数不同,视觉效果可能天差地别。所以建议你在写的时候,把关键参数都提出来做成可配置的,边调边看,找到最舒服的视觉效果。不要一开始就追求完美,先让动画跑起来,再慢慢打磨细节。