☰
Flutter自定义绘制实战:旋转太空人与蛛网动效实现
2026/10/18 1:46:02 网站建设 项目流程

1. 从零拆解这个动效:旋转太空人与蛛网到底难在哪

第一次看到“旋转太空人+蛛网效果”这个需求时,我脑子里冒出来的第一个念头不是“怎么画”,而是“这玩意儿到底该用几层画布”。很多刚接触 Flutter 自定义绘制的朋友,看到这种带旋转、带连线、带粒子感的动效,第一反应是去找现成的动画库,或者干脆用 GIF 图糊弄过去。但真做过的人都知道,GIF 在移动端一放大就糊,而且没法跟手势、状态联动,属于典型的“能跑但没法用”。

这个项目的核心其实就两件事:一个会转的太空人,以及一张会呼吸的蛛网。听起来简单,但拆开来看,它涉及了 Flutter 绘制体系里最容易被忽略的几个点——CustomPainter的重绘边界、AnimationController与Transform的配合、以及大量线段绘制时的性能取舍。我见过太多人把CustomPaint当成万能画布,结果一帧画几百条线,手机直接烫得能煎蛋。

先说清楚这个内容适合谁看。如果你已经写过 Flutter 的基础页面,用过Container、Row、Column,但对CustomPainter还停留在“知道有这么个东西”的阶段,那这篇内容就是给你准备的。如果你已经能画个圆、画条线,但一遇到旋转、粒子、动态连线就不知道从哪下手,那更好,因为下面我会把每一步的“为什么”都讲透。至于完全没碰过 Flutter 的朋友,建议先把StatefulWidget和AnimationController的基本用法过一遍再回来,不然容易卡在语法上。

我个人的习惯是,拿到这种动效需求,先不急着写代码,而是拿张纸把画面拆成“静态层”和“动态层”。静态层就是那些不随时间变化的东西,比如太空人的身体轮廓、蛛网的节点位置;动态层则是旋转角度、连线透明度、粒子缩放这些每帧都在变的东西。拆完之后你会发现,真正需要每帧重绘的其实只有动态层,静态层完全可以缓存起来。这个思路在后面讲性能优化的时候会反复用到,先埋个伏笔。

还有一点得提前说,这个效果在 Flutter 2.0 时代算是比较典型的“炫技型”动效,当时社区里很多人拿它来演示自定义绘制的威力。但放到现在看,它的技术内核依然没过时,因为CustomPainter这套东西从 Flutter 早期到现在,核心 API 几乎没怎么变。所以你学到的不是某个版本的技巧,而是一套能长期用的绘制思维。

2. 整体设计思路:为什么我选择 CustomPainter 而不是现成组件

2.1 方案选型背后的取舍逻辑

做动效最怕的就是“用错工具”。Flutter 里能实现旋转和连线的方式其实不少,我列一下当时考虑过的几个方案,以及最后为什么选了CustomPainter。

方案实现难度性能表现灵活度适用场景
现成动画库低中等低标准过渡动画
GIF/APNG 图片极低差(内存大)极低一次性展示
多个 Transform 组件叠加中中等中简单旋转缩放
CustomPainter 手绘高好(可控)极高复杂动态图形

现成动画库的问题在于“不够自由”。蛛网效果里每一条线的透明度、长度、连接关系都是动态计算的,动画库很难表达这种“根据数据实时生成图形”的逻辑。GIF 就更不用说了,分辨率一变就糊,而且没法跟用户操作联动。用多个Transform组件叠加倒是能做出旋转,但蛛网那种几十条线交织的效果,用组件树去堆会非常臃肿,而且每加一条线就多一个 RenderObject,性能开销直线上升。

CustomPainter的优势在于,它把整个画布交给你,你想画什么就画什么,想画多少就画多少,所有的绘制逻辑都在一个paint方法里完成。对于蛛网这种“节点+连线”的结构,用Canvas.drawLine批量绘制是最自然的表达方式。而且CustomPainter配合repaint参数,可以精确控制重绘时机,避免不必要的刷新。

提示:不要一上来就想着用CustomPainter解决所有问题。如果你的动效只是简单的位移或缩放,用AnimatedBuilder加Transform会更省事。CustomPainter适合的是那种“图形结构本身在变化”的场景。

2.2 分层绘制:把静态和动态拆开

我在实际写的时候,把整个画面分成了三层。最底层是背景层,负责画一些渐变或者底色,这层基本不重绘。中间层是蛛网层,包含节点和连线,这层每帧都要重绘,因为连线的透明度和长度在变。最上层是太空人层,负责画太空人的身体和旋转,这层也是每帧重绘,但绘制内容相对固定,只是旋转角度在变。

为什么要分层?因为CustomPainter的shouldRepaint方法可以控制“什么时候需要重绘”。如果我把所有东西都塞进一个 Painter 里,那每次旋转角度一变,整个画布包括背景都要重画一遍,纯属浪费。分层之后,背景层可以单独用一个CustomPaint,并且让它的shouldRepaint永远返回false,这样它只画一次就缓存住了。

不过这里有个坑,Flutter 的CustomPaint本身并不自带缓存机制,shouldRepaint返回false只是告诉框架“不需要重绘”,但如果你把背景层和动态层放在同一个CustomPaint里,那动态层重绘时背景层还是会被带着一起画。所以真正的分层需要拆成多个CustomPaint组件,用Stack叠起来。这个细节很多人不知道,以为写个shouldRepaint就完事了,结果性能还是上不去。

2.3 动画驱动:AnimationController 的节奏控制

旋转和蛛网的“呼吸感”都需要一个时间驱动源。Flutter 里最标准的就是AnimationController,配合vsync使用。我当时的做法是创建一个AnimationController,持续时间设成 6 秒左右,然后让它无限循环。6 秒这个数字不是随便定的,太短了旋转会显得急躁,太长了又感觉不到动,实测下来 5 到 8 秒之间比较舒服。

蛛网的呼吸效果则是用另一个AnimationController,持续时间设成 3 秒,并且用Curves.easeInOut做缓动,这样连线一伸一缩的时候不会显得机械。两个控制器分开的好处是,旋转和呼吸的节奏可以独立调整,不会互相干扰。如果你把它们绑在一起,想改旋转速度就会连带影响呼吸频率,调起来很痛苦。

注意:AnimationController一定要在dispose里释放,不然会内存泄漏。我见过有人在initState里创建了控制器,结果页面销毁时忘了dispose,跑久了直接卡死。

3. 核心细节解析:旋转、连线与粒子的实现要点

3.1 太空人旋转:Transform 还是 Canvas.rotate

画太空人的时候,我一开始想的是用Transform.rotate包一个CustomPaint,这样旋转交给组件层处理,Painter 里只管画静态的太空人。但后来发现这样有个问题:旋转的中心点不好控制。Transform.rotate默认是绕组件中心旋转,但太空人的视觉重心可能不在正中心,比如头大身子小的时候,绕中心转看起来会有点飘。

后来我改成了在Canvas里直接用canvas.rotate。具体做法是先把画布平移到旋转中心,然后旋转,再平移回来,最后画太空人。这样旋转中心可以精确指定,想绕哪转就绕哪转。代码大概长这样:

canvas.save(); canvas.translate(centerX, centerY); canvas.rotate(angle); canvas.translate(-centerX, -centerY); // 在这里画太空人 canvas.restore();

save和restore一定要成对出现,不然画布的变换会累积,画出来的东西会越来越歪。这个坑我踩过,当时调了半天以为是自己坐标算错了,结果是忘了restore。

太空人本身的绘制其实不复杂,就是几个圆和弧线拼出来的。头部一个圆,身体一个椭圆,手臂和腿用drawArc或者drawPath。关键是比例要协调,我建议先在纸上画个草图,把各个部位的相对位置定好,再换算成代码里的坐标。别一上来就瞎试,那样调比例能调到崩溃。

3.2 蛛网连线:节点生成与距离计算

蛛网的核心是“节点+连线”。节点我是在画布范围内随机生成的,但完全随机会导致有的地方密有的地方疏,看起来不像蛛网。所以我用了一个简单的策略:先确定一个中心点,然后在中心点周围按环形分布生成节点,每个节点的角度和半径都加一点随机扰动。这样出来的节点既有规律感,又不会太死板。

节点生成之后,就是连线。连线的逻辑是:遍历每一对节点,如果它们的距离小于某个阈值,就画一条线。这个阈值很关键,太小了线太少,看起来不像网;太大了线太多,画面会糊成一团。我实测下来,阈值设在画布宽度的 0.3 到 0.4 倍之间比较合适。

for (int i = 0; i < nodes.length; i++) { for (int j = i + 1; j < nodes.length; j++) { final distance = (nodes[i] - nodes[j]).distance; if (distance < maxDistance) { final opacity = 1 - distance / maxDistance; paint.color = Colors.white.withOpacity(opacity * breathValue); canvas.drawLine(nodes[i], nodes[j], paint); } } }

这里有个性能问题:如果节点数量是 N,那双重循环就是 N 的平方次距离计算。N 等于 30 的时候就是 435 次,还能接受;N 等于 100 的时候就是 4950 次,每帧算这么多距离,手机就开始喘了。所以节点数量要控制,我一般不超过 40 个。如果确实需要更多节点,那就得用空间分割之类的算法来减少计算量,但那是另一个话题了。

连线的透明度跟距离成反比,距离越近越不透明,这样看起来更有层次感。再乘上一个呼吸值,让整张网有节奏地明暗变化。呼吸值就是从AnimationController里取出来的,经过Curves.easeInOut处理,范围在 0.3 到 1.0 之间。

3.3 粒子闪烁:给蛛网加点“星味”

光有线和节点还是有点单调,所以我加了一层粒子效果。粒子就是一些小圆点,随机分布在画布上,每个粒子有自己的闪烁周期和相位。闪烁的实现很简单,就是用sin函数算透明度:

final twinkle = (sin(particle.phase + time * particle.speed) + 1) / 2; paint.color = Colors.white.withOpacity(twinkle * 0.8); canvas.drawCircle(particle.position, particle.radius, paint);

粒子的半径不要太大,1 到 2 像素就够了,太大了会抢太空人的视觉焦点。粒子的数量可以比节点多一些,五六十个都没问题,因为画圆比画线便宜。

提示:粒子的位置最好在初始化时就算好,不要每帧重新随机。每帧随机的话,粒子会疯狂跳动,看起来像雪花屏,而不是闪烁。

4. 实操过程:从空项目到完整动效的每一步

4.1 项目初始化与依赖配置

新建一个 Flutter 项目,这个没什么好说的,flutter create一把梭。依赖方面,这个效果其实不需要任何第三方库,纯 Flutter 自带的 API 就能搞定。我见过有人为了画个旋转去引flutter_animation之类的库,完全没必要,反而增加了包体积。

pubspec.yaml里保持默认就行,不需要额外添加东西。如果你想让代码更整洁,可以加个flutter_lints做静态检查,但这不是必须的。

4.2 搭建页面骨架与动画控制器

页面用一个StatefulWidget,在initState里创建两个AnimationController:

late AnimationController _rotateController; late AnimationController _breathController; @override void initState() { super.initState(); _rotateController = AnimationController( vsync: this, duration: const Duration(seconds: 6), )..repeat(); _breathController = AnimationController( vsync: this, duration: const Duration(seconds: 3), )..repeat(reverse: true); }

repeat(reverse: true)让呼吸控制器来回播放,这样透明度是一伸一缩的,而不是单向变化。旋转控制器用repeat()单向循环,因为旋转本来就是一直转的。

然后在build方法里用AnimatedBuilder监听控制器,把动画值传给CustomPaint:

@override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFF0A0E27), body: AnimatedBuilder( animation: Listenable.merge([_rotateController, _breathController]), builder: (context, child) { return CustomPaint( size: Size.infinite, painter: SpaceWebPainter( rotateValue: _rotateController.value, breathValue: _breathController.value, ), ); }, ), ); }

Listenable.merge可以把多个控制器合并成一个,这样AnimatedBuilder只需要监听一个对象。不然你得嵌套两层AnimatedBuilder,代码会很难看。

4.3 编写 SpaceWebPainter 的完整逻辑

SpaceWebPainter继承自CustomPainter,核心是paint和shouldRepaint两个方法。paint里按顺序画三层:蛛网、粒子、太空人。顺序很重要,后画的会盖住先画的,所以太空人放最后,保证它永远在最上层。

class SpaceWebPainter extends CustomPainter { final double rotateValue; final double breathValue; final List<Offset> nodes; final List<Particle> particles; SpaceWebPainter({ required this.rotateValue, required this.breathValue, required this.nodes, required this.particles, }); @override void paint(Canvas canvas, Size size) { _drawWeb(canvas, size); _drawParticles(canvas, size); _drawAstronaut(canvas, size); } @override bool shouldRepaint(covariant SpaceWebPainter oldDelegate) { return oldDelegate.rotateValue != rotateValue || oldDelegate.breathValue != breathValue; } }

shouldRepaint里只比较动画值,节点和粒子列表在初始化后就固定了,不需要比较。这样每次重绘的判断很轻量,不会成为性能瓶颈。

4.4 蛛网绘制的参数计算与调优

蛛网的节点生成我放在initState里,只算一次:

void _generateNodes(Size size) { final center = Offset(size.width / 2, size.height / 2); final random = Random(42); // 固定种子,保证每次运行结果一致 nodes = List.generate(35, (index) { final angle = random.nextDouble() * 2 * pi; final radius = random.nextDouble() * size.width * 0.45; return Offset( center.dx + cos(angle) * radius, center.dy + sin(angle) * radius, ); }); }

用固定种子Random(42)的好处是,每次运行出来的蛛网形状是一样的,方便调试。如果你想要每次都不一样,把种子去掉就行。

连线的时候,最大距离我设的是size.width * 0.35。这个值我调了好几次,0.3 的时候线太少,网看起来稀稀拉拉的;0.4 的时候线太多,画面有点乱。0.35 是个比较平衡的值,你可以根据自己屏幕的比例微调。

4.5 太空人绘制:坐标与比例的手动校准

太空人的绘制是最费时间的部分,因为要手动调坐标。我的做法是先定义一个基准尺寸,比如以画布宽度的 0.15 倍作为太空人的“头半径”,然后其他部位都按这个半径的比例来算。这样不管画布多大,太空人的比例都是协调的。

void _drawAstronaut(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); final headRadius = size.width * 0.06; final bodyHeight = headRadius * 2.5; canvas.save(); canvas.translate(center.dx, center.dy); canvas.rotate(rotateValue * 2 * pi); canvas.translate(-center.dx, -center.dy); // 画头盔 final helmetPaint = Paint()..color = Colors.white; canvas.drawCircle(center, headRadius, helmetPaint); // 画面罩 final visorPaint = Paint()..color = const Color(0xFF1A1A2E); canvas.drawArc( Rect.fromCircle(center: center, radius: headRadius * 0.75), -pi * 0.8, pi * 1.6, true, visorPaint, ); // 画身体 final bodyRect = RRect.fromRectAndRadius( Rect.fromCenter( center: Offset(center.dx, center.dy + headRadius + bodyHeight / 2), width: headRadius * 1.6, height: bodyHeight, ), Radius.circular(headRadius * 0.5), ); canvas.drawRRect(bodyRect, helmetPaint); canvas.restore(); }

面罩用drawArc画一个半圆,看起来像宇航员的护目镜。身体用RRect画一个圆角矩形,比纯矩形柔和一些。手臂和腿我简化掉了,因为旋转起来其实看不太清楚,加了反而增加绘制负担。

注意:canvas.rotate的参数是弧度,不是角度。rotateValue是 0 到 1 的值,乘以2 * pi才是完整的 360 度。这个转换很容易搞错,我一开始忘了乘2 * pi,结果太空人只转了不到六分之一圈,还以为是动画没生效。

5. 性能优化与常见问题排查

5.1 重绘范围控制:别让整屏都在刷新

前面提到分层绘制,这里展开说一下具体怎么做。如果你把蛛网、粒子、太空人全放在一个CustomPaint里,那每次动画值变化,整个画布都会重绘。虽然CustomPainter的重绘是在 GPU 层面做的,但节点和连线的计算是在 CPU 层面,每帧算几百次距离和透明度,时间长了还是会卡。

我的做法是把粒子单独拆出去,因为粒子的闪烁周期和蛛网的呼吸周期不一样,没必要绑在一起重绘。粒子用一个独立的CustomPaint,放在Stack的最底层,它的shouldRepaint只比较粒子自己的时间值。这样蛛网重绘的时候,粒子层不受影响。

不过拆得太细也有代价,每个CustomPaint都是一个独立的 RenderObject,数量多了会有额外的布局开销。我实测下来,拆成两到三层是比较合适的,再多就得不偿失了。

5.2 常见问题速查表

问题现象可能原因解决方法
太空人不转忘了乘 2*pi,或控制器没 repeat检查 rotate 参数和 repeat 调用
蛛网线太多糊成一团最大距离阈值太大调小 maxDistance,建议 0.3~0.35 倍宽度
动画卡顿掉帧节点数量过多或每帧重新生成节点节点控制在 40 以内,初始化时生成一次
粒子疯狂跳动每帧重新随机粒子位置粒子位置在初始化时固定
画面偏移或变形canvas.save/restore 没配对检查每个 save 都有对应的 restore
内存持续增长AnimationController 没 dispose在 dispose 里调用 controller.dispose()

5.3 实操心得:那些文档里不会写的细节

第一个心得是关于Random的。如果你在paint方法里用Random()生成节点,那每帧都会生成新的随机数,节点位置会疯狂跳动。这个错误很隐蔽,因为代码看起来没问题,但效果就是不对。记住,凡是“位置”“大小”这种不应该每帧变的东西,都要在初始化时算好。

第二个心得是关于颜色透明度的。withOpacity这个方法在 Flutter 2.0 时代是没问题的,但如果你把透明度设成 0,Flutter 还是会执行绘制操作,只是画出来看不见。所以如果某条线的透明度算出来接近 0,最好直接continue跳过,别浪费绘制调用。

第三个心得是关于RepaintBoundary的。如果你的动效是嵌在一个复杂页面里的,建议给CustomPaint外面包一层RepaintBoundary,这样它的重绘不会影响到页面其他部分。这个优化在动效单独展示的时候看不出差别,但一旦页面变复杂,效果就很明显了。

第四个心得是关于测试设备的。这个动效在高端机上跑得飞起,但在一些老设备上可能会掉帧。我的建议是,节点数量不要贪多,35 个左右就够了,视觉效果和性能之间要取平衡。如果你发现某台设备上卡,先把节点数降到 20 试试,如果还卡,那就是别的地方有问题了。

6. 这个效果还能怎么玩:几个可扩展的方向

6.1 手势交互:让蛛网跟着手指动

现在的蛛网是自动呼吸的,如果你想让用户能跟它互动,可以加一个GestureDetector,把手指的位置传给CustomPainter,然后让离手指最近的节点产生位移,连线也跟着重新计算。这样用户拖动手指的时候,蛛网会被“扯”动,手感很奇妙。

实现的关键是把手指位置作为一个新的参数传给 Painter,然后在shouldRepaint里也比较这个参数。节点的位移可以用简单的弹簧模型,手指离开后节点慢慢弹回原位。

6.2 主题切换:白天与黑夜两套配色

现在的配色是深色背景加白色线条,适合夜间模式。如果你想要白天模式,可以把背景换成浅灰,线条换成深蓝,粒子的透明度调低一些。配色方案可以抽成一个配置类,通过Theme或者InheritedWidget传进来,这样切换主题的时候不用改绘制逻辑。

6.3 性能监控:用 DevTools 看帧率

如果你不确定自己的优化有没有效果,可以用 Flutter DevTools 的性能面板看帧率。正常情况下应该稳定在 60fps,如果掉到 50 以下,就说明有优化空间。重点看Raster和UI两个线程的时间,如果UI线程时间长,说明是 Dart 层的计算太重;如果Raster线程时间长,说明是绘制调用太多。

我个人的经验是,这种动效的瓶颈通常在UI线程,因为距离计算和透明度计算都是 Dart 层做的。减少节点数量、避免每帧重新生成数据,是最有效的优化手段。

6.4 导出为图片或视频

如果你想把某一帧导出成图片,可以用RepaintBoundary的toImage方法。把CustomPaint包在RepaintBoundary里,拿到GlobalKey,然后调用boundary.toImage()就能得到ui.Image,再转成 PNG 字节流保存。这个功能在做分享图的时候很有用。

导出视频就复杂一些,需要逐帧截图然后合成,Flutter 本身没有内置的视频编码能力,得借助原生或者第三方库。这个方向我还没深入折腾,等以后有需求了再研究。

最后再分享一个小技巧:如果你觉得手动调坐标太痛苦,可以先用 Figma 或者 Sketch 把太空人画出来,然后量出各个部位的相对坐标,再换算成代码。这样比在代码里瞎试快得多,而且比例更准。我后来做类似效果都是先画图再写代码,效率至少翻一倍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询