Flutter 代码雨实现(矩阵雨)

446 阅读11分钟

时隔多年,我重拾“代码雨”特效。两年前投稿被拒,当时无法实现理想效果;如今经验积累,让我能解答当时的疑惑并完善方案。

“欲买桂花同载酒,终不似,少年游。”

对比当初的实现,现在的优化思路更清晰:将字符预渲染成位图,用 GPU 贴图和滤镜上色,避免每帧重复布局与矢量绘制。 (实际很流畅,但是没搞懂怎么上传视频)

PixPin_2025-05-28_14-18-32.gif

1. 性能瓶颈与优化思路 关于ui.Image和Picture以及TextPainter的区别

多次构建 TextPainter 或 PictureRecorder,确实能渲染文字,但每帧都要走完整个布局和矢量绘制流程;而将文字预渲染为位图(ui.Image),再用 drawImage 贴图,才能真正做到轻量高效。

原始方案缺陷

  • 每帧构建 TextPainter / PictureRecorder

    • 每次都要测量、换行、布局

    • 即使只绘制一个‘1’,背后仍然有大量layout操作

    • 生成新的对象,引发垃圾回收

  • CPU 端矢量渲染

    • 不利用 GPU 纹理加速

    • 字符密度高时帧率骤降(7~20FPS)

优化方案核心

  1. 提前渲染为 ui.Image

    • 把每个字符渲染一次成位图

    • 缓存后重复使用

  2. 使用 drawImage 贴图

    • GPU 纹理直渲染

    • 免去布局与矢量绘制

  3. 动态上色靠 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 FPS55~60 FPS写字慢、排队测量 vs 贴图快速、即贴即用
Flutter DevTools 分析布局耗时高、帧 jank 明显渲染流畅、GPU 利用高矢量指令逐条执行 vs GPU 纹理一次加载并复用
  • 效果一目了然:优化后每帧可承载更多字符,帧率提升 3~8 倍,且抖动(jank)大幅减少。

  • 原理直观:原方案相当于“每帧都要重新写字”,优化后则“提前刻好章,再用印章印刷”,配合 GPU 滤镜动态上色,整体性能飞跃。


雨滴模型 RainDrop 详解

雨滴本质上是一串字符随同下落的“动画单元”,我们用一个简单的类来表示它的状态与行为:

  • 位置 (x, y):决定雨滴在画布上的横纵坐标。

  • 速度 (speed):控制雨滴下落的速率。

  • 帧计数 (frameCount):记录雨滴已经“显示”了多少帧,用来决定当前要渲染多少行字符。

  • 启动偏移 (frameOffset):随机错开每个雨滴的开始帧,避免所有雨滴同时“从头开始”,形成更自然的错落感。

举例说明

  • 假设雨滴字符串为 123456789frameOffset = 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)

    1. drop.y += drop.speed; —— 每帧向下移动

    2. drop.frameCount++; —— 增加帧计数,用于控制可见字符长度

    3. 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 并缓存,后续直接贴图使用。

  1. 初始化缓存

    • 遍历 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);
    }
    
  2. 渲染单字符到 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,  
  });  
}