ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Flutter自定义绘制:从零实现设备搜索雷达动画组件

Flutter自定义绘制:从零实现设备搜索雷达动画组件 设想一个场景你打开某款蓝牙耳机的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 CustomPainter2.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.0duration决定了走完全程需要多长时间。你可以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. 代码实现从零搭一个可直接复用的SearchRadarWidget4.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 StateSearchRadarWidget createState() _SearchRadarWidgetState(); } class _SearchRadarWidgetState extends StateSearchRadarWidget 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变到1startAngle正好从-π/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不是无脑返回trueCustomPainter的设计里有一个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模式下能稳定60fpsDebug模式只有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里设备搜索动画的配色、尺寸、环数往往不同。我建议在构造参数里至少暴露这些参数类型默认值说明sizedouble240画布直径colorColor0xFF4FC3F7主题色ringCountint3波纹数量durationDuration1600ms单周期时长strokeWidthdouble2.0波纹线宽scanAngledouble2.5扫描扇形弧度调用方可以按自己的品牌色生成不同副本而无需改动内部逻辑。我一般会把组件放进项目的components目录配上默认参数后全局复用。一个组件在三个项目里反复用每次只改几行参数这种一次做好、到处可用的收益远大于在页面上临时写死一堆动画逻辑。6.3 关于成功态和失败态的一点预告这篇只实现了搜索中的动态但真实业务最终都会走向搜索完成。我在设计状态枚举时提前把success和fail放了进来为的就是给后续留出扩展位。目前的规划是成功态用一圈从内向外扩散的实心色块收拢成对勾失败态则让波纹以收拢方式退场配合震动感。这些内容我准备放在系列第二篇讲。为什么不在这一篇里一起做完因为设备搜索的成功态和失败态本质上属于转场过渡动画它涉及的不是单一组件而是搜索动画与结果列表、错误提示之间的联动。把搜索中的循环动画和结束后的一次性过渡动画混在一个组件里反而会让组件职责变重。拆开做系列组件边界反而清晰。做完这个组件再回头看最大的体会是设备搜索动画的价值不在特效本身而在于它是否让用户安心——知道设备在被找、知道找的范围在扩大、知道整个过程有始有终。你在实际项目里接入任何搜索动画时也不妨先问自己一句这个动画有没有让用户读懂系统正在干什么如果答案是肯定的哪怕波纹只有两圈、扫描线只是一个点那它也是好动画反之特效再炫也只是空转。我很建议拿到代码后先跑起来然后逐个改参数看效果——动画这东西看一百篇文章不如自己改一遍数值来得深刻。
返回列表