Flutter CustomPainter 实战:旋转太空人与呼吸蛛网动画

发布时间:2026/10/18 16:37:59
Flutter CustomPainter 实战:旋转太空人与呼吸蛛网动画 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里我们只需要一个MaterialApphome指向一个StatefulWidget我把它命名为SpaceWebPage。为什么用StatefulWidget而不是StatelessWidget因为我们需要管理AnimationController的生命周期。AnimationController必须在initState里创建在dispose里释放这两个生命周期钩子只有StatefulWidget才有。在initState里创建AnimationController的时候vsync参数传this并且当前State需要混入SingleTickerProviderStateMixin。这个Mixin的作用是告诉Flutter框架这个State需要一个Ticker来驱动动画。如果忘了混入这个Mixin运行时会直接报错。class _SpaceWebPageState extends StateSpaceWebPage 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 ListOffset _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的生命周期管理好对外只暴露配置参数不暴露内部实现细节。我个人在实际操作中的体会是这种纯绘制型的动画项目最难的不是写代码而是调参数。同样的代码参数不同视觉效果可能天差地别。所以建议你在写的时候把关键参数都提出来做成可配置的边调边看找到最舒服的视觉效果。不要一开始就追求完美先让动画跑起来再慢慢打磨细节。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询