
个人主页:ujainu
文章目录
-
- 引言
- [一、为什么选择 CustomPainter?](#一、为什么选择 CustomPainter?)
- 二、轨迹系统:动态拖尾的实现
-
- [1. 轨迹数据结构](#1. 轨迹数据结构)
- [2. 轨迹更新策略](#2. 轨迹更新策略)
- 三、相机跟随:平滑追踪小球
-
- [1. 相机偏移计算](#1. 相机偏移计算)
- [2. Canvas 坐标系变换](#2. Canvas 坐标系变换)
- 四、动态圆环颜色:增强视觉反馈
- [五、性能优化:避免 CustomPainter 频繁 rebuild](#五、性能优化:避免 CustomPainter 频繁 rebuild)
-
- 问题重现
- [✅ 正确方案:复用 Painter 实例](#✅ 正确方案:复用 Painter 实例)
- 结语
引言
在前两篇中,我们完成了游戏的基础架构 与核心物理逻辑 。但一个优秀的游戏,不仅要有严谨的玩法,更需要沉浸式的视觉体验 。本篇将聚焦于 Flutter 的 CustomPainter 高级用法,通过手绘方式实现三大关键视觉特效:
- ✅ 动态拖尾(Trail):小球飞行时留下渐隐轨迹;
- ✅ 相机跟随(Camera Follow):画面平滑追踪小球移动;
- ✅ 随机圆环配色:增强关卡视觉丰富度。
更重要的是,我们将探讨 如何避免 CustomPainter 频繁重建导致的性能问题,确保在 OpenHarmony 等资源受限设备上依然保持 60fps 流畅运行。
💡 技术栈 :纯 Dart + Flutter SDK,不依赖任何第三方图形库,轻量高效,适合嵌入式与分布式场景。
一、为什么选择 CustomPainter?
许多开发者误以为 Flutter 只适合 UI 应用,不适合游戏。实际上,CustomPainter + AnimationController 构成了一个极其高效的 2D 渲染管线:
- 直接操作 Canvas:绕过 Widget 树,减少内存分配;
- GPU 加速绘制:Skia 引擎底层优化;
- 完全控制帧率 :与
TickerProvider同步,避免掉帧。
但若使用不当,CustomPainter 也会成为性能瓶颈。例如:
dart
// ❌ 错误示范:每次 setState 都重建 painter
CustomPaint(painter: GamePainter(position: _ballPosition))
这会导致 painter 对象频繁创建,触发不必要的 shouldRepaint 计算。
✅ 正确姿势 :复用同一个 CustomPainter 实例,仅更新其内部数据引用。
二、轨迹系统:动态拖尾的实现
1. 轨迹数据结构
我们用一个固定长度的 List<Offset> 存储小球历史位置,并配合透明度衰减:
dart
class GamePainter extends CustomPainter {
final List<Offset> trail; // 轨迹点
final Offset ballPosition;
final List<Circle> circles;
final Offset cameraOffset;
GamePainter({
required this.trail,
required this.ballPosition,
required this.circles,
required this.cameraOffset,
});
@override
void paint(Canvas canvas, Size size) {
// 先应用相机偏移
canvas.save();
canvas.translate(-cameraOffset.dx, -cameraOffset.dy);
// 绘制轨迹(从旧到新)
for (int i = 0; i < trail.length; i++) {
final alpha = (255 * (i / trail.length)).toInt(); // 越新越亮
final color = Color.fromARGB(alpha, 255, 255, 0); // 黄色渐隐
canvas.drawCircle(trail[i], 6, Paint()..color = color);
}
// 绘制小球(最亮)
canvas.drawCircle(ballPosition, 10, Paint()..color = Colors.yellow);
canvas.restore(); // 恢复坐标系
}
}
2. 轨迹更新策略
在 _gameLoop 中,每帧将当前位置加入轨迹队列,并限制最大长度:
dart
List<Offset> _trail = [];
void _gameLoop() {
if (_isPlaying) {
// ... 更新 _ballPosition
// 添加轨迹点
_trail.add(_ballPosition);
if (_trail.length > 20) _trail.removeAt(0); // 保留最近 20 帧
}
// 更新相机
_updateCamera();
if (mounted) setState(() {});
}
🎨 视觉技巧:
- 使用
Color.fromARGB(alpha, r, g, b)动态控制透明度;- 轨迹半径略小于小球,形成"光晕"效果;
- 队列 FIFO 管理,内存恒定。
三、相机跟随:平滑追踪小球
1. 相机偏移计算
理想情况下,相机应始终将小球置于屏幕中央。但若直接赋值,会显得生硬。我们使用 线性插值(Lerp) 实现平滑过渡:
dart
Offset _cameraOffset = Offset.zero;
void _updateCamera() {
final target = _ballPosition - MediaQuery.sizeOf(context) / 2;
_cameraOffset = Offset.lerp(_cameraOffset, target, 0.1)!; // 10% 追踪速度
}
Offset.lerp(a, b, t):返回a到b的插值点,t ∈ [0,1];t = 0.1表示每帧向目标靠近 10%,形成缓动效果;!因为lerp可能返回 null(但此处不会)。
2. Canvas 坐标系变换
在 paint 方法开头应用相机偏移:
dart
canvas.save();
canvas.translate(-_cameraOffset.dx, -_cameraOffset.dy);
// 所有绘制都在世界坐标系下进行
// ...
canvas.restore();
📌 关键理解:
translate(-dx, -dy)相当于"移动画布",使物体看起来向(dx, dy)移动;- 必须用
save()/restore()隔离变换,避免影响 UI 元素(如分数文本)。
四、动态圆环颜色:增强视觉反馈
静态白色圆环容易审美疲劳。我们为每个新生成的圆环赋予随机但协调的颜色:
dart
Color _randomColor() {
final random = math.Random();
return Color.fromARGB(
255,
100 + random.nextInt(155),
100 + random.nextInt(155),
200 + random.nextInt(55), // 偏蓝紫,保持美感
);
}
// 在 Circle 类中增加 color 字段
class Circle {
final Offset center;
final double radius;
final Color color;
Circle({required this.center, required this.radius, required this.color});
}
// 生成时
_circles.add(Circle(
center: newCenter,
radius: 80,
color: _randomColor(),
));
并在绘制时使用:
dart
for (final circle in circles) {
paint.color = circle.color;
canvas.drawCircle(circle.center, circle.radius, paint);
}
🎨 设计建议:
- 避免使用纯红/绿(可能与 UI 冲突);
- 限制饱和度,防止刺眼;
- 当前圆环仍用红色高亮,确保玩法清晰。
五、性能优化:避免 CustomPainter 频繁 rebuild
这是本篇的核心工程实践。
问题重现
若这样写:
dart
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: GamePainter(
trail: _trail,
ballPosition: _ballPosition,
circles: _circles,
cameraOffset: _cameraOffset,
),
);
}
每次 setState 都会创建新的 GamePainter 对象,即使数据未变。这会导致:
shouldRepaint被频繁调用;- 对象分配增加 GC 压力;
- 在低端设备上帧率下降。
✅ 正确方案:复用 Painter 实例
- 将
GamePainter作为 State 成员变量; - 仅更新其内部数据引用;
- 重写
shouldRepaint进行精准比较。
dart
class _GameScreenState extends State<GameScreen> with TickerProviderStateMixin {
late final GamePainter _painter = GamePainter(); // 单例
@override
Widget build(BuildContext context) {
// 更新 painter 数据(非重建对象)
_painter
..trail = _trail
..ballPosition = _ballPosition
..circles = _circles
..cameraOffset = _cameraOffset;
return GestureDetector(
onTapDown: _launchBall,
child: Scaffold(
backgroundColor: Colors.black,
body: CustomPaint(painter: _painter),
),
);
}
}
同时,GamePainter 改为可变属性:
dart
import 'dart:math' as math;
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '轨道跳跃 Pro',
debugShowCheckedModeBanner: false,
theme: ThemeData.dark(),
home: GameScreen(),
);
}
}
class Circle {
final Offset center;
final double radius;
final Color color;
Circle({required this.center, required this.radius, required this.color});
}
class GamePainter extends CustomPainter {
List<Offset> trail = [];
Offset ballPosition = Offset.zero;
List<Circle> circles = [];
Offset cameraOffset = Offset.zero;
int score = 0;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 4;
// 应用相机偏移
canvas.save();
canvas.translate(-cameraOffset.dx, -cameraOffset.dy);
// 绘制轨迹
for (int i = 0; i < trail.length; i++) {
final alpha = (255 * (i / (trail.length + 1))).toInt();
final color = Color.fromARGB(alpha, 255, 255, 0);
canvas.drawCircle(trail[i], 6, Paint()..color = color);
}
// 绘制小球
canvas.drawCircle(ballPosition, 10, Paint()..color = Colors.yellow);
// 绘制圆环
for (final circle in circles) {
paint.color = circle.color;
canvas.drawCircle(circle.center, circle.radius, paint);
}
canvas.restore();
// 绘制分数
final textPainter = TextPainter(
text: TextSpan(text: 'Score: $score', style: const TextStyle(color: Colors.white, fontSize: 24)),
textDirection: TextDirection.ltr,
)..layout();
textPainter.paint(canvas, const Offset(20, 50));
}
@override
bool shouldRepaint(covariant GamePainter oldDelegate) {
return trail != oldDelegate.trail ||
ballPosition != oldDelegate.ballPosition ||
circles != oldDelegate.circles ||
cameraOffset != oldDelegate.cameraOffset ||
score != oldDelegate.score;
}
}
class GameScreen extends StatefulWidget {
@override
State<GameScreen> createState() => _GameScreenState();
}
class _GameScreenState extends State<GameScreen> with TickerProviderStateMixin {
late AnimationController _controller;
late final GamePainter _painter = GamePainter();
List<Circle> _circles = [];
Offset _ballPosition = Offset.zero;
Offset _ballVelocity = Offset.zero;
Offset _cameraOffset = Offset.zero;
List<Offset> _trail = [];
bool _isPlaying = false;
int _score = 0;
Timer? _timeoutTimer;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 16))
..repeat()
..addListener(_gameLoop);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_initGame();
}
void _initGame() {
final size = MediaQuery.sizeOf(context);
final baseRadius = 80.0;
final spacing = 150.0;
final centerY = size.height * 0.7;
_circles = List.generate(4, (i) {
final x = size.width / 2 + (i - 1) * spacing;
return Circle(
center: Offset(x, centerY),
radius: baseRadius,
color: _randomColor(),
);
});
_ballPosition = _circles.first.center;
_ballVelocity = Offset.zero;
_cameraOffset = Offset.zero;
_trail.clear();
_isPlaying = false;
_score = 0;
}
Color _randomColor() {
final r = math.Random();
return Color.fromARGB(255, 100 + r.nextInt(155), 100 + r.nextInt(155), 200 + r.nextInt(55));
}
void _addNextCircle(Size size) {
final last = _circles.last;
final newCenter = Offset(last.center.dx + 150, size.height * 0.7);
_circles.add(Circle(center: newCenter, radius: 80, color: _randomColor()));
}
void _launchBall(TapDownDetails details) {
if (_isPlaying) return;
_timeoutTimer?.cancel();
final touch = details.globalPosition;
final current = _circles.first;
final toTouch = touch - current.center;
final angle = math.atan2(toTouch.dy, toTouch.dx);
final tangentAngle = angle + math.pi / 2;
const speed = 400.0;
_ballVelocity = Offset(
speed * math.cos(tangentAngle),
speed * math.sin(tangentAngle),
);
_isPlaying = true;
}
void _updateCamera(Size size) {
final target = _ballPosition - Offset(size.width / 2, size.height / 2);
_cameraOffset = Offset.lerp(_cameraOffset, target, 0.1)!;
final maxOffsetX = size.width * 0.5;
final maxOffsetY = size.height * 0.5;
_cameraOffset = Offset(
_cameraOffset.dx.clamp(-maxOffsetX, maxOffsetX),
_cameraOffset.dy.clamp(-maxOffsetY, maxOffsetY),
);
}
void _gameLoop() {
final size = MediaQuery.sizeOf(context);
if (_isPlaying) {
final dt = 0.016;
_ballPosition += _ballVelocity * dt;
_trail.add(_ballPosition);
if (_trail.length > 20) _trail.removeAt(0);
if (_ballPosition.dx < -100 || _ballPosition.dx > size.width + 100 ||
_ballPosition.dy < -100 || _ballPosition.dy > size.height + 100) {
_endGame();
return;
}
if (_circles.length > 1) {
final next = _circles[1];
final distance = (_ballPosition - next.center).distance;
if (distance <= next.radius) {
final toCenter = next.center - _ballPosition;
final dot = _ballVelocity.dx * toCenter.dx + _ballVelocity.dy * toCenter.dy;
if (dot >= 0) {
_circles.removeAt(0);
_ballPosition = _circles.first.center;
_ballVelocity = Offset.zero;
_isPlaying = false;
_score++;
_addNextCircle(size);
_startTimeout();
}
}
}
}
_updateCamera(size);
_painter
..trail = _trail
..ballPosition = _ballPosition
..circles = _circles
..cameraOffset = _cameraOffset
..score = _score;
if (mounted) setState(() {});
}
void _startTimeout() {
_timeoutTimer?.cancel();
_timeoutTimer = Timer(const Duration(seconds: 10), () {
if (mounted) _endGame();
});
}
void _endGame() {
_controller.stop();
_timeoutTimer?.cancel();
if (!mounted) return;
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: const Text('游戏结束'),
content: Text('得分: $_score'),
actions: [
TextButton(
onPressed: () {
Navigator.of(ctx).pop();
_initGame();
},
child: const Text('再玩一次'),
),
],
),
);
}
@override
void dispose() {
_controller.dispose();
_timeoutTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: _launchBall,
child: Scaffold(
backgroundColor: Colors.black,
body: LayoutBuilder(
builder: (context, constraints) {
final size = Size(constraints.maxWidth, constraints.maxHeight);
return CustomPaint(
painter: _painter,
size: size,
);
},
),
),
);
}
}
运行界面:

结语
本文通过 CustomPainter 实现了三大核心视觉特效,并重点解决了性能优化 这一工程痛点。这些技术不仅适用于小游戏,也可用于数据可视化、动画引导等场景。下一期,我们将集成 OpenHarmony 分布式能力,实现多设备协同游戏!
欢迎加入开源鸿蒙跨平台社区: https://openharmonycrossplatform.csdn.net