时隔多年,我重拾“代码雨”特效。两年前投稿被拒,当时无法实现理想效果;如今经验积累,让我能解答当时的疑惑并完善方案。
“欲买桂花同载酒,终不似,少年游。”
对比当初的实现,现在的优化思路更清晰:将字符预渲染成位图,用 GPU 贴图和滤镜上色,避免每帧重复布局与矢量绘制。 (实际很流畅,但是没搞懂怎么上传视频)
1. 性能瓶颈与优化思路 关于ui.Image和Picture以及TextPainter的区别
多次构建 TextPainter 或 PictureRecorder,确实能渲染文字,但每帧都要走完整个布局和矢量绘制流程;而将文字预渲染为位图(ui.Image),再用 drawImage 贴图,才能真正做到轻量高效。
原始方案缺陷
-
每帧构建 TextPainter / PictureRecorder
-
每次都要测量、换行、布局
-
即使只绘制一个‘1’,背后仍然有大量layout操作
-
生成新的对象,引发垃圾回收
-
-
CPU 端矢量渲染
-
不利用 GPU 纹理加速
-
字符密度高时帧率骤降(7~20FPS)
-
优化方案核心
-
提前渲染为
ui.Image-
把每个字符渲染一次成位图
-
缓存后重复使用
-
-
使用
drawImage贴图-
GPU 纹理直渲染
-
免去布局与矢量绘制
-
-
动态上色靠
ColorFilter-
对贴图做颜色滤镜
-
无需重绘文字
-
2. 渲染方案对比
方案对比与性能优势
| 比较项 | 原始方案 (TextPainter / Picture) | 优化方案 (ui.Image + drawImage) | 性能优势 |
|---|---|---|---|
| 每帧绘制方式 | 每帧构建 TextPainter 或 Picture | 直接 drawImage | 大幅减少 layout & paint 开销 |
| GPU 渲染 | 主要依赖 CPU 矢量绘制 | 利用 GPU 纹理缓存 | 真正硬件加速 |
| 帧绘制延迟 | 存在文字测量与换行 | 无需额外布局 | 节省毫秒级调用 |
| 对象分配 | 每帧可能创建 PictureRecorder、TextPainter 等 | 一次性生成 ui.Image,重复复用 | 避免频繁 GC,降低卡顿 |
| 动态上色 | 通过重绘文字改变颜色 | 使用 ColorFilter GPU 滤镜上色 | 数倍渲染速率提升 |
| 高密度字符支持 | 掉帧严重(7–20 FPS) | 流畅稳定(55–60 FPS) | 轻松承载 500+ 字符的高负载场景 |
性能对比与类比
| 测试项 | 原始方案(TextPainter/Picture) | 优化方案(Image) | 类比说明 |
|---|---|---|---|
| 每帧最大字符绘制数 | 100~200 | >500 可持续 | 就像每帧手写文字 vs 贴上预刻好的印章 |
| 帧率 | 7~20 FPS | 55~60 FPS | 写字慢、排队测量 vs 贴图快速、即贴即用 |
| Flutter DevTools 分析 | 布局耗时高、帧 jank 明显 | 渲染流畅、GPU 利用高 | 矢量指令逐条执行 vs GPU 纹理一次加载并复用 |
-
效果一目了然:优化后每帧可承载更多字符,帧率提升 3~8 倍,且抖动(jank)大幅减少。
-
原理直观:原方案相当于“每帧都要重新写字”,优化后则“提前刻好章,再用印章印刷”,配合 GPU 滤镜动态上色,整体性能飞跃。
雨滴模型 RainDrop 详解
雨滴本质上是一串字符随同下落的“动画单元”,我们用一个简单的类来表示它的状态与行为:
-
位置 (
x,y):决定雨滴在画布上的横纵坐标。 -
速度 (
speed):控制雨滴下落的速率。 -
帧计数 (
frameCount):记录雨滴已经“显示”了多少帧,用来决定当前要渲染多少行字符。 -
启动偏移 (
frameOffset):随机错开每个雨滴的开始帧,避免所有雨滴同时“从头开始”,形成更自然的错落感。
举例说明
假设雨滴字符串为
123456789,frameOffset = 2时,雨滴先“跳过”前两帧,从字符12开始显示;如果
frameOffset = 4,则前四帧内看不到该雨滴,第四帧时“一次性”从1234开始下落。
这种设计让不同雨滴拥有不同的“启动时机”,画面更具层次感和随机性。
/// 单个雨滴的数据模型
class RainDrop {
double x; // 当前横坐标
double y; // 当前纵坐标
double speed; // 下落速度(像素/帧)
int frameCount = 0; // 已经过的帧数,用于控制字符串长度
int frameOffset; // 启动偏移(跳过多少帧才开始绘制)
RainDrop({
required this.x,
required this.y,
required this.speed,
required this.frameOffset,
});
}
-
初始化时,你可以为每个
RainDrop随机生成不同的x,y,speed,frameOffset,这样每滴雨下落的起始位置、速度和同步时机都各不相同。 -
在每一帧的更新逻辑里,先
frameCount++,再根据frameCount - frameOffset来决定当前应渲染多少个字符;如果frameCount < frameOffset,则该滴雨暂时不渲染。 -
当
y超过画布高度时,重置该雨滴的x、y、speed、frameCount(并重新随机frameOffset),让它“从头”再落一次。
这样,就能实现层次丰富、错落有致的“字符雨”动画效果。
代码雨的雨滴管理器 RainManager
RainManager 负责初始化与更新所有雨滴的状态,是整个“代码雨”动画的核心控制器。
-
初始化
-
根据
dropCount随机生成每个RainDrop:-
x,y:初始横纵坐标 -
speed:下落速度 -
frameOffset:启动延迟帧数(随机错峰)
-
-
-
属性说明
-
dropCount:雨滴总数 -
charSet:雨滴使用的字符集 -
drops:存放所有RainDrop实例 -
size:画布尺寸,用于判断雨滴是否越界 -
random:随机数生成器,用于初始化与重置
-
-
更新逻辑 (
update)-
drop.y += drop.speed;—— 每帧向下移动 -
drop.frameCount++;—— 增加帧计数,用于控制可见字符长度 -
若
drop.y > size.height(雨滴越出底部):-
随机重置
drop.x -
将
drop.y复位为 0 -
随机重置
drop.speed -
将
drop.frameCount设为frameOffset,重新错峰启动
-
-
class RainManager {
final int dropCount; // 雨滴数量
final String charSet; // 雨滴字符集
final List<RainDrop> drops = [];
Size size = Size.zero; // 画布尺寸
final Random random = Random();
RainManager({required this.charSet, this.dropCount = 25}) {
for (int i = 0; i < dropCount; i++) {
drops.add(RainDrop(
x: random.nextDouble() * size.width, // 随机横坐标
y: random.nextDouble() * size.height, // 随机纵坐标
speed: 1 + random.nextDouble() * 2, // 随机速度
frameOffset: random.nextInt(5), // 随机启动延迟
));
}
}
/// 设置画布尺寸,需在 initState 中调用
void setSize(Size newSize) => size = newSize;
/// 每帧调用:更新所有雨滴的位置与状态
void update() {
for (final drop in drops) {
drop.y += drop.speed;
drop.frameCount++;
// 超出底部后重置,重新随机位置/速度,并错峰启动
if (drop.y > size.height) {
drop.x = random.nextDouble() * size.width;
drop.y = 0;
drop.speed = 1 + random.nextDouble() * 2;
drop.frameCount = drop.frameOffset;
}
}
}
}
这样,RainManager 在每一帧都会根据速度更新雨滴位置,并在越界时智能重置,保证字符雨持续、流畅地循环下落。
雨滴绘制器 RainPainter
RainPainter 的职责是遍历 RainManager 中的所有 RainDrop,根据每个雨滴的帧状态动态绘制对应数量的字符,并使用 GPU 滤镜实现动画上色。
这里就体现出滤镜上色的优点了,即使像这样去改变颜色,帧率也不会受到很大的影响
-
字符行数
final count = (drop.frameCount - drop.frameOffset).clamp(0, manager.charSet.length);-
frameOffset帧内不绘制; -
随着
frameCount增加,可见字符行数逐步增长;
-
-
逐行绘制
for (int i = 0; i < count; i++) { final char = manager.charSet[i % manager.charSet.length]; final image = imageCache[char]; if (image == null) continue; // … 上色与贴图 … } -
动态上色
final hue = (drop.x + drop.y + drop.frameCount * 5) % 360; final brightness = (1.0 - i * 0.1).clamp(0.3, 1.0); final color = HSVColor.fromAHSV(1, hue, 0.8, brightness).toColor(); final paint = Paint() ..colorFilter = ColorFilter.mode(color, BlendMode.srcIn); -
贴图绘制
canvas.drawImage( image, Offset(drop.x, drop.y + i * charSize), paint, );
完整实现如下:
class RainPainter extends CustomPainter {
final RainManager manager;
final Animation<double> repaint;
final double charSize;
final Map<String, ui.Image> imageCache;
RainPainter({
required this.manager,
required this.repaint,
required this.charSize,
required this.imageCache,
}) : super(repaint: repaint);
@override
void paint(Canvas canvas, Size size) {
for (final drop in manager.drops) {
// 计算当前应显示的字符行数
final count = (drop.frameCount - drop.frameOffset)
.clamp(0, manager.charSet.length);
for (int i = 0; i < count; i++) {
final char = manager.charSet[i % manager.charSet.length];
final image = imageCache[char];
if (image == null) continue;
// 根据位置和帧数计算色相与亮度
final hue = (drop.x + drop.y + drop.frameCount * 5) % 360;
final brightness = (1.0 - i * 0.1).clamp(0.3, 1.0);
final color = HSVColor.fromAHSV(1, hue, 0.8, brightness).toColor();
// 使用 GPU 滤镜动态上色
final paint = Paint()
..colorFilter = ColorFilter.mode(color, BlendMode.srcIn);
// 在 (x, y + i * 字符高度) 位置贴图
canvas.drawImage(
image,
Offset(drop.x, drop.y + i * charSize),
paint,
);
}
}
}
@override
bool shouldRepaint(covariant RainPainter old) => true;
}
这样,每一帧都能高效地渲染出“错落有致、色彩动态变化”的字符雨特效。
缓存贴图
为了避免每帧都重绘文字,我们将每个字符预渲染成 ui.Image 并缓存,后续直接贴图使用。
-
初始化缓存
-
遍历
charSet中的每个字符,调用_renderCharImage将字符渲染为ui.Image,存入_charImageCache。 把charSet的单个字符拆开拿去绘制贴图 例如 我爱你 拆成我 爱 你 分别进行绘制,第一次进行 我 绘制 第二次进行 爱 第三次进行 你 -
完成后将
_ready设为true,开始绘制。
Future<void> _initImageCache() async { for (final c in _rainManager.charSet.characters) { _charImageCache[c] = await _renderCharImage(c, charSize); } setState(() => _ready = true); } -
-
渲染单字符到
ui.Image-
使用
ui.PictureRecorder录制一次 Canvas 操作; -
利用
TextPainter在录制画布上绘制文字; -
结束录制后调用
picture.toImage将矢量指令转换为位图。
Future<ui.Image> _renderCharImage(String char, double fontSize) async { final recorder = ui.PictureRecorder();//录屏器,可以录制 Canvas 的所有绘图操作 final canvas = Canvas(recorder); final tp = TextPainter( text: TextSpan( text: char, style: TextStyle( fontSize: fontSize, color: Colors.white, // 白色文字,后续用 ColorFilter 着色 fontWeight: FontWeight.bold, ), ), textDirection: TextDirection.ltr, )..layout(); tp.paint(canvas, Offset.zero); final picture = recorder.endRecording(); // 把矢量绘制指令渲染成栅格化位图 return await picture.toImage( tp.width.ceil(), tp.height.ceil(), ); } -
-
原理:
-
ui.PictureRecorder+Canvas:录制一次绘图命令; -
TextPainter:执行文字排版与绘制; -
picture.toImage(width, height):将录制的矢量命令异步转成ui.Image位图。 简单来说,这行代码就是把矢量“画板”转换成一张具体的、可在屏幕上或在其它 API(比如 toByteData 导出 PNG)中使用的位图图片。
-
-
优点:
-
一次渲染,多次复用,避免每帧重做排版;
-
GPU 贴图:后续绘制只需
drawImage,可配合ColorFilter动态上色,性能极佳。
-
源码
import 'dart:ui' as ui;
import 'dart:math';
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: Scaffold(body: RainApp())));
///优化构建方法 从绘制变成贴图更好的释放性能
class RainApp extends StatefulWidget {
const RainApp({super.key});
@override
State<RainApp> createState() => _RainAppState();
}
class _RainAppState extends State<RainApp> with SingleTickerProviderStateMixin {
//动画控制器
late final AnimationController _controller;
//初始化管理器
final RainManager _rainManager = RainManager(charSet: '♡', dropCount:400);
//
final double charSize = 20.0;
//存储缓存贴图
final Map<String, ui.Image> _charImageCache = {};
//初始化贴图完成
bool _ready = false;
@override
void initState() {
super.initState();
//在这里初始化画布大小
_rainManager.setSize(ui.window.physicalSize / ui.window.devicePixelRatio);
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 303), // ≈30FPS
)
..addListener(() {
_rainManager.update();
})
..repeat();
// 初始化字符图像缓存
_initImageCache();
}
// 初始化贴图
Future<void> _initImageCache() async {
//把charSet的单个字符拆开拿去绘制贴图 例如 我爱你 拆成我 爱 你 分别进行绘制,第一次进行 我 绘制 第二次进行 爱 第三次进行 你
for (final c in _rainManager.charSet.characters) {
_charImageCache[c] = await _renderCharImage(c, charSize);
}
setState(() => _ready = true);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!_ready) {
return const Center(child: CircularProgressIndicator());
}
return CustomPaint(
isComplex: true,//优化绘制
foregroundPainter: RainPainter(
manager: _rainManager,
repaint: _controller,
charSize: charSize,
imageCache: _charImageCache,
),
child: Container(color: Colors.black),
);
}
/// 将单个字符渲染为 ui.Image 传入需要渲染的char和大小
Future<ui.Image> _renderCharImage(String char, double fontSize) async {
final recorder = ui.PictureRecorder();//录屏器,可以录制 Canvas 的所有绘图操作 开始录像
final canvas = Canvas(recorder);
final tp = TextPainter(
text: TextSpan(
text: char,
style: TextStyle(
fontSize: fontSize,
color: Colors.white, // 用白色图像,后期 ColorFilter 着色
fontWeight: FontWeight.bold,
),
),
textDirection: TextDirection.ltr,
)..layout();
tp.paint(canvas, Offset.zero);
final picture = recorder.endRecording();//结束录像
return await picture.toImage(tp.width.ceil(), tp.height.ceil());//把一个 ui.Picture(也就是一组矢量绘制命令)转成一张栅格化的 ui.Image ///toImage(width, height):异步地将这组指令以指定的像素宽度和高度渲染成一张位图。
///tp.width.ceil() / tp.height.ceil():把宽高向上取整,确保传给 toImage 的参数是整数。
///await:toImage 返回一个 Future<Image>,需要 await 才能拿到真正的 ui.Image 对象。
///简单来说,这行代码就是把矢量“画板”转换成一张具体的、可在屏幕上或在其它 API(比如 toByteData 导出 PNG)中使用的位图图片。
}
}
class RainPainter extends CustomPainter {
final RainManager manager;
final Animation<double> repaint;
final double charSize;
final Map<String, ui.Image> imageCache;
RainPainter({
required this.manager,
required this.repaint,
required this.charSize,
required this.imageCache,
}) : super(repaint: repaint);
@override
void paint(Canvas canvas, Size size) {
for (final drop in manager.drops) {
//count设置一个雨滴有多少文字
final count = drop.frameCount.clamp(1, 1
);
//然后进行遍历
for (int i = 0; i < count; i++) {
final char = manager.charSet[i % manager.charSet.length];
final image = imageCache[char];
if (image == null) continue;
//颜色
final hue = (drop.x + drop.y + drop.frameCount * 5) % 360;
final brightness = 1.0 - i * 0.1;
final color = HSVColor.fromAHSV(
1.0,
hue,
0.8,
brightness.clamp(0.3, 1.0),
).toColor();
final paint = Paint()
..colorFilter = ColorFilter.mode(color, BlendMode.srcIn);
canvas.drawImage(image, Offset(drop.x, drop.y + i * charSize), paint);
}
}
}
@override
bool shouldRepaint(covariant RainPainter oldDelegate) => true;
}
class RainManager {
final int dropCount;
final String charSet;
final List<RainDrop> drops = [];
Size size = Size.zero;
final Random random = Random();
RainManager({required this.charSet, this.dropCount = 30}) {
for (int i = 0; i < dropCount; i++) {
drops.add(RainDrop(
x: random.nextDouble() * 400,
y: random.nextDouble() * 100,
speed: 1 + random.nextDouble() * 2,
frameOffset: random.nextInt(5),
));
}
}
void setSize(Size s) => size = s;
void update() {
for (final drop in drops) {
drop.y += drop.speed;
drop.frameCount++;//每帧 frameCount++,表示雨滴“已经经过了多少帧”; 在绘制时,控制这个雨滴要显示几行字符(例如字符雨长度);如果没有这个变量,所有雨滴就会固定显示某一长度,不会“下落增长”。
if (drop.y > size.height) {
drop.x = random.nextDouble() * size.width;
drop.y = 0;
drop.speed = 1 + random.nextDouble() * 2;
drop.frameCount = drop.frameOffset;//drop.frameCount = drop.frameOffset; // 每次重置起始帧
}
}
}
}
class RainDrop {
double x;
double y;
double speed;
int frameCount = 0;//控制字符雨的“下落长度”
int frameOffset;//控制动画的错峰延迟 表示该雨滴在初始化或重置后,等待多少帧才开始增长显示字符; 用来避免所有雨滴同时开始增长,避免画面“机械同步”;是一种动画“随机错峰”的处理方式,使画面更加自然。
RainDrop({
required this.x,
required this.y,
required this.speed,
required this.frameOffset,
});
}