1. 项目概述
今天要分享的是一个在Flutter中实现设备搜索动画效果的实战案例。这个动画效果常见于智能家居、蓝牙设备连接等场景,当App需要搜索周边设备时,通常会展示一个动态的雷达扫描效果或者脉冲波纹扩散动画来增强用户体验。
我在最近的一个智能家居项目中就遇到了这个需求 - 需要让用户在添加新设备时,能够直观地看到App正在搜索周边设备的反馈。经过多次迭代和优化,最终实现了一个流畅且性能良好的搜索动画组件。
2. 核心设计思路
2.1 动画效果分析
典型的设备搜索动画通常包含以下几个视觉元素:
- 雷达扫描效果 - 类似雷达屏幕上的扇形扫描动画
- 脉冲波纹 - 从中心点向外扩散的圆形波纹
- 设备发现提示 - 当检测到设备时的特殊反馈
- 加载指示器 - 表示搜索过程中的等待状态
2.2 技术选型考量
在Flutter中实现这类动画,主要有以下几种方案:
-
使用Flutter内置动画系统
- 优点:性能好,与框架深度集成
- 缺点:复杂动画实现起来代码量较大
-
使用Rive(原Flare)
- 优点:设计师可以参与制作,动画效果丰富
- 缺点:需要额外学习工具,文件体积较大
-
使用Lottie
- 优点:跨平台,动画效果精美
- 缺点:JSON文件解析需要额外性能开销
经过评估,我选择了第一种方案 - 使用Flutter内置的动画系统。主要基于以下考虑:
- 我们的动画需求相对简单,不需要太复杂的效果
- 希望保持最小的依赖和包体积
- 需要更好的性能表现,特别是在低端设备上
3. 实现细节解析
3.1 基础动画结构
首先,我们创建一个StatefulWidget来管理动画状态:
dart复制class DeviceSearchAnimation extends StatefulWidget {
@override
_DeviceSearchAnimationState createState() => _DeviceSearchAnimationState();
}
class _DeviceSearchAnimationState extends State<DeviceSearchAnimation>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 2),
vsync: this,
)..repeat();
_animation = CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return CustomPaint(
painter: RadarPainter(_animation.value),
);
},
);
}
}
3.2 雷达扫描效果实现
雷达扫描效果的核心是自定义一个CustomPainter:
dart复制class RadarPainter extends CustomPainter {
final double animationValue;
RadarPainter(this.animationValue);
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2;
// 绘制背景圆
final backgroundPaint = Paint()
..color = Colors.blue.withOpacity(0.1)
..style = PaintingStyle.fill;
canvas.drawCircle(center, radius, backgroundPaint);
// 绘制扫描扇形
final sweepAngle = 40.0;
final startAngle = 2 * pi * animationValue - sweepAngle / 2;
final sweepPaint = Paint()
..shader = SweepGradient(
colors: [
Colors.blue.withOpacity(0.0),
Colors.blue.withOpacity(0.7),
Colors.blue.withOpacity(0.0),
],
stops: [0.0, 0.5, 1.0],
startAngle: startAngle,
endAngle: startAngle + sweepAngle,
transform: GradientRotation(startAngle),
).createShader(Rect.fromCircle(center: center, radius: radius))
..style = PaintingStyle.fill;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
startAngle,
sweepAngle,
true,
sweepPaint,
);
// 绘制同心圆网格
final gridPaint = Paint()
..color = Colors.white.withOpacity(0.3)
..style = PaintingStyle.stroke
..strokeWidth = 1.0;
for (var i = 1; i <= 3; i++) {
canvas.drawCircle(center, radius * i / 4, gridPaint);
}
}
@override
bool shouldRepaint(covariant RadarPainter oldDelegate) {
return oldDelegate.animationValue != animationValue;
}
}
3.3 脉冲波纹效果实现
为了增强视觉效果,我们可以添加脉冲波纹效果:
dart复制class PulsePainter extends CustomPainter {
final double animationValue;
final int pulseCount;
PulsePainter(this.animationValue, {this.pulseCount = 3});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final maxRadius = size.width / 2;
for (var i = 0; i < pulseCount; i++) {
final progress = (animationValue + i / pulseCount) % 1.0;
final radius = maxRadius * progress;
final opacity = 1.0 - progress;
final paint = Paint()
..color = Colors.blue.withOpacity(opacity * 0.5)
..style = PaintingStyle.stroke
..strokeWidth = 2.0;
canvas.drawCircle(center, radius, paint);
}
}
@override
bool shouldRepaint(covariant PulsePainter oldDelegate) {
return oldDelegate.animationValue != animationValue;
}
}
4. 性能优化技巧
4.1 动画性能考量
在实现这类连续动画时,性能是需要重点考虑的因素。以下是一些优化建议:
-
合理设置动画时长:
- 雷达扫描动画建议2-3秒一个循环
- 脉冲波纹建议1.5-2秒一个循环
-
控制重绘区域:
dart复制@override bool shouldRepaint(covariant RadarPainter oldDelegate) { return oldDelegate.animationValue != animationValue; }确保只在动画值变化时才重绘
-
使用RepaintBoundary:
dart复制
RepaintBoundary( child: CustomPaint( painter: RadarPainter(_animation.value), ), )将动画部件与其他部件隔离,减少不必要的重绘
4.2 内存优化
-
重用Paint对象:
在CustomPainter中,尽可能重用Paint对象而不是每次都创建新的 -
控制动画复杂度:
- 避免在每一帧进行复杂的计算
- 预计算可以提前算好的值
5. 实际应用中的问题与解决方案
5.1 动画卡顿问题
问题现象:
在低端设备上,动画出现明显卡顿
解决方案:
-
减少同时运行的动画数量
-
降低动画的帧率:
dart复制_controller = AnimationController( duration: const Duration(seconds: 2), vsync: this, )..repeat();可以尝试增加duration时间
-
简化CustomPainter中的绘制操作
5.2 设备发现时的反馈
需求:
当搜索到设备时,需要有一个明显的视觉反馈
实现方案:
dart复制// 在发现设备时触发一个缩放动画
ScaleTransition(
scale: Tween(begin: 1.0, end: 1.2).animate(
CurvedAnimation(
parent: _deviceFoundController,
curve: Curves.elasticOut,
),
),
child: CustomPaint(
painter: DeviceIndicatorPainter(),
),
)
5.3 多动画同步
需求:
雷达扫描和脉冲波纹需要协调运行
解决方案:
dart复制// 使用同一个AnimationController驱动多个动画
_scanAnimation = Tween(begin: 0.0, end: 1.0).animate(_controller);
_pulseAnimation = Tween(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(0.0, 0.5, curve: Curves.easeIn),
),
);
6. 完整实现示例
下面是一个整合了所有功能的完整示例:
dart复制class DeviceSearchAnimation extends StatefulWidget {
final bool isSearching;
final int foundDevicesCount;
const DeviceSearchAnimation({
Key? key,
required this.isSearching,
this.foundDevicesCount = 0,
}) : super(key: key);
@override
_DeviceSearchAnimationState createState() => _DeviceSearchAnimationState();
}
class _DeviceSearchAnimationState extends State<DeviceSearchAnimation>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scanAnimation;
late Animation<double> _pulseAnimation;
late AnimationController _deviceFoundController;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 2),
vsync: this,
)..repeat();
_scanAnimation = CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
);
_pulseAnimation = Tween(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(0.0, 0.5, curve: Curves.easeIn),
),
);
_deviceFoundController = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
);
}
@override
void didUpdateWidget(covariant DeviceSearchAnimation oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.foundDevicesCount > oldWidget.foundDevicesCount) {
_deviceFoundController.forward(from: 0.0);
}
}
@override
void dispose() {
_controller.dispose();
_deviceFoundController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.center,
children: [
// 背景和网格
CustomPaint(
painter: RadarBackgroundPainter(),
size: Size.square(200),
),
// 脉冲波纹
AnimatedBuilder(
animation: _pulseAnimation,
builder: (context, child) {
return CustomPaint(
painter: PulsePainter(_pulseAnimation.value),
size: Size.square(200),
);
},
),
// 雷达扫描
AnimatedBuilder(
animation: _scanAnimation,
builder: (context, child) {
return CustomPaint(
painter: RadarPainter(_scanAnimation.value),
size: Size.square(200),
);
},
),
// 设备指示器
if (widget.foundDevicesCount > 0)
ScaleTransition(
scale: Tween(begin: 1.0, end: 1.2).animate(
CurvedAnimation(
parent: _deviceFoundController,
curve: Curves.elasticOut,
),
),
child: CustomPaint(
painter: DeviceIndicatorPainter(
count: widget.foundDevicesCount,
),
size: Size.square(40),
),
),
// 搜索状态文本
Text(
widget.isSearching ? '搜索中...' : '搜索完成',
style: TextStyle(
color: Colors.white,
fontSize: 16,
),
),
],
);
}
}
7. 扩展与改进思路
7.1 主题适配
为了让动画更好地融入不同主题的应用,可以考虑:
-
颜色可配置化:
dart复制class RadarPainter extends CustomPainter { final Color primaryColor; RadarPainter(this.animationValue, {this.primaryColor = Colors.blue}); // 在paint方法中使用primaryColor } -
尺寸自适应:
dart复制LayoutBuilder( builder: (context, constraints) { final size = constraints.maxWidth; return CustomPaint( size: Size.square(size), painter: RadarPainter(_animation.value), ); }, )
7.2 高级效果
如果想进一步提升动画效果,可以考虑:
-
3D透视效果:
使用Transform和Matrix4来实现简单的3D旋转效果 -
粒子效果:
在发现设备时,添加粒子爆发动画 -
声音反馈:
配合动画播放适当的音效
7.3 平台特定优化
对于不同平台,可能需要特别考虑:
-
iOS:
- 使用Cupertino风格的动画曲线
- 遵循人机界面指南的动画时长
-
Android:
- 使用Material Design的动画规范
- 考虑过度绘制问题
8. 测试与验证
8.1 单元测试
为动画组件编写单元测试:
dart复制void main() {
testWidgets('DeviceSearchAnimation test', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: DeviceSearchAnimation(
isSearching: true,
foundDevicesCount: 0,
),
),
),
),
);
// 验证初始状态
expect(find.text('搜索中...'), findsOneWidget);
// 模拟动画一帧
await tester.pump(Duration(milliseconds: 16));
// 验证动画运行中
expect(tester.widget<DeviceSearchAnimation>(find.byType(DeviceSearchAnimation)).isSearching, true);
});
}
8.2 性能测试
使用Flutter的性能工具测试动画的帧率:
dart复制void main() {
testWidgets('Performance test', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: DeviceSearchAnimation(
isSearching: true,
foundDevicesCount: 0,
),
),
),
),
);
// 运行动画并记录性能
final stopwatch = Stopwatch()..start();
for (var i = 0; i < 60; i++) {
await tester.pump(Duration(milliseconds: 16));
}
stopwatch.stop();
print('60 frames rendered in ${stopwatch.elapsedMilliseconds}ms');
});
}
9. 实际项目中的经验分享
在实现这个动画效果的过程中,我积累了一些宝贵的经验:
-
动画同步问题:
最初尝试使用多个AnimationController分别控制不同元素,结果发现很难保持同步。后来改用单个Controller驱动多个Animation,问题迎刃而解。 -
性能陷阱:
在CustomPainter的paint方法中,最初每次都会创建新的Paint对象,导致GC频繁触发。改为重用Paint对象后,性能显著提升。 -
设备适配:
在低端Android设备上,复杂的绘制操作会导致明显卡顿。通过简化绘制逻辑和减少不必要的重绘,最终实现了流畅的动画效果。 -
状态管理:
当搜索到新设备时,需要触发一个额外的动画效果。最初尝试直接在setState中处理,导致动画不流畅。后来改用专门的AnimationController来管理这个效果,体验明显改善。 -
测试的重要性:
动画效果在不同设备和屏幕尺寸上的表现可能会有差异。建立完善的测试用例,包括不同尺寸的设备和不同的DPI设置,可以及早发现问题。