恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Flutter自定义绘制实战:旋转太空人与蛛网动效实现
首页
资讯中心
/
Flutter自定义绘制实战:旋转太空人与蛛网动效实现
Flutter自定义绘制实战:旋转太空人与蛛网动效实现
发布时间:2026/10/18 1:46:06
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里创建两个AnimationControllerlate 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监听控制器把动画值传给CustomPaintoverride 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 ListOffset nodes; final ListParticle 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 时代是没问题的但如果你把透明度设成 0Flutter 还是会执行绘制操作只是画出来看不见。所以如果某条线的透明度算出来接近 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 把太空人画出来然后量出各个部位的相对坐标再换算成代码。这样比在代码里瞎试快得多而且比例更准。我后来做类似效果都是先画图再写代码效率至少翻一倍。