Flutter 渐变文本四种方案深度解析

从源码、性能和使用场景解析 Flutter 中渐变文本的四种实现方案:ShaderMask、TextStyle.foreground、自定义 GradientShaderMask 和 Canvas

Flutter&Dart渐变文本

方案总览

方案核心组件一句话
1ShaderMask + BlendModeShaderMask Widget用渐变覆盖已绘制的文字,通过混合模式控制可见区域
2TextStyle.foregroundPaint()..shader直接在文字字形的绘制阶段使用渐变 Paint
3自定义 GradientText 组件封装 ShaderMask 并调节 blendMode在方案一的基础上优化边缘锯齿
4CustomPaint / CanvasCustomPainter + TextPainter完全手绘,最灵活但也最重

方案一:ShaderMask + BlendMode.srcATop

使用场景

适用于 styled_widget 链式风格的项目,可以保留 .textColor().fontSize() 等扩展方法不变。日常开发最常用。

代码示例

const colors = [Color(0xFFFFE3AF), Color(0xFFFFFFFF)];

ShaderMask(
  shaderCallback: (Rect bounds) =>
    linearGradient(90, colors).createShader(Offset.zero & bounds.size),
  blendMode: BlendMode.srcATop,
  child: Text('Weekly VIP')
    .fontSize(14.sp)
    .fontWeight(FontWeight.w600)
    .textColor(Colors.white),
);

源码

核心在 RenderShaderMask.paint(),位于 flutter/lib/src/rendering/proxy_box.dart

class RenderShaderMask extends RenderProxyBox {
  final ShaderCallback _shaderCallback;
  final BlendMode _blendMode;

  @override
  void paint(PaintingContext context, Offset offset) {
    if (child != null) {
      // 1. 将子元素绘制到离屏 OpacityLayer
      final Layer? layer = context.pushOpacity(offset, 255, super.paint);

      if (layer != null) {
        // 2. 用渐变 shader + 混合模式"过滤"离屏 layer
        context.pushShaderMask(
          _shaderCallback(size),
          offset & size,
          _blendMode,
          layer,
          // filterQuality, isComplex 等参数
        );
      }
    }
  }
}

渲染流程:

1. 子元素(Text)被绘制到离屏 OpacityLayer

2. 这个 layer 作为"目标像素 D"

3. 渐变 shader 作为"源像素 S"

4. S + D → srcATop 混合 → 最终颜色

srcATop 的数学公式result = S × D.alpha + D × (1 − S.alpha)

示例(文字 = 目标 D,渐变 = 源 S):
D.alpha = 1.0 处 → result = S × 1.0 + D × 0 = S(显示渐变色)
D.alpha = 0.0 处 → result = S × 0.0 + D × 1.0 = D(显示原背景)

性能特征

步骤开销
离屏 layer 分配GPU 显存分配,约 screenWidth × fontSize × 4 字节
子元素绘制与普通 Text 相同
blend 操作GPU 单条指令,几乎不计
shaderCallback每次 paint() 都会调用(脏标记触发)

优劣

  • 不改子元素结构,兼容 styled_widget 链式风格
  • 渐变范围自动适配文字 bounds
  • 额外一次离屏渲染
  • srcATop 在亚像素抗锯齿边缘可能偏暗

方案二:TextStyle.foreground

使用场景

需要最高渲染质量的场景:品牌标题、大号文字、需要锐利边缘的渐变动画文字。是 Flutter 框架层面最”正确”的渐变文字做法。

代码示例

final shader = LinearGradient(
  begin: Alignment.centerLeft,
  end: Alignment.centerRight,
  colors: const [Color(0xFFFFE3AF), Color(0xFFFFFFFF)],
).createShader(const Rect.fromLTWH(0, 0, 200, 30));

Text(
  'Weekly VIP',
  style: TextStyle(
    fontSize: 14,
    fontWeight: FontWeight.w600,
    foreground: Paint()..shader = shader,
  ),
);

如果不知道文字尺寸,可以用 TextPainter 预计算:

final painter = TextPainter(
  text: TextSpan(text: 'Weekly VIP', style: baseStyle),
  textDirection: TextDirection.ltr,
)..layout();

final shader = gradient.createShader(Offset.zero & painter.size);

源码

Flutter 的文字渲染走 lib/painting/text_painter.dart。关键路径:

void paint(Canvas canvas, Offset offset) {
  // ...
  final Paint paint = _text!.style?.foreground ?? Paint()..color = _text!.style!.color!;
  // ...
  final List<ui.GlyphInfo> glyphs = ...;  // 字形信息

  canvas.drawGlyphs(
    glyphs,
    positions,
    offset & bounds,
    paint,  // ★ 渐变 Paint 直接作用于字形
  );
}

和方案一的本质区别:

方案一:先画文字 → 离屏 → 混合渐变
方案二:渐变作为"墨水" → 直接画字形

canvas.drawGlyphs() 是 Skia/Impeller 的原生方法。每个 glyph 的像素直接用传入的 Paint 着色。当 Paint.shader 是一个渐变时,GPU 在光栅化阶段就完成了渐变映射没有额外的合成步骤

性能

步骤开销
离屏 layer不需要
GPU blend不需要
Shader 创建一次(可缓存,不在 build 中创建)
Glyph 光栅化与普通 Text 完全相同

所有方案中性能最优。

优劣

  • 零额外 GPU 开销,渲染质量最高
  • 无离屏 layer,无边缘锯齿
  • 渐变范围需提前指定(宽高固定),动态尺寸需用 TextPainter 预计算
  • 不能和 .textColor() 并用(color 会被 foreground 覆盖)

方案三:封装 GradientText 组件

使用场景

想保留方案一的调用简洁性,同时消除 srcATop 的边缘锯齿问题。本质是方案一的封装变体,靠调节 blendMode 改善抗锯齿效果。

代码示例

class GradientText extends StatelessWidget {
  final Widget child;
  final List<Color> colors;
  final double angleDeg;

  const GradientText({
    super.key,
    required this.child,
    required this.colors,
    this.angleDeg = 90,
  });

  @override
  Widget build(BuildContext context) {
    return ShaderMask(
      shaderCallback: (Rect bounds) =>
        linearGradient(angleDeg, colors).createShader(bounds),
      blendMode: BlendMode.srcIn,  // ← 替代 srcATop
      child: child,
    );
  }
}

源码

和方案一完全相同的渲染路径,唯一区别是 blendMode

BlendMode混合公式
srcATopresult = src × dst.alpha + dst × (1 − src.alpha)
srcInresult = src × dst.alpha

在亚像素抗锯齿区域(alpha = 0.3 ∼ 0.7):

alpha = 0.5 位置:
  srcATop: result = gradient × 0.5 + textColor × 0.5  → 偏暗
  srcIn:   result = gradient × 0.5                     → 纯渐变,边缘更锐

srcIn 不引入原文字颜色,所以边缘不会有暗色残留。视觉效果更干净。

优劣

  • 调用简洁(单行封装)
  • 边缘锯齿比 srcATop
  • 仍然有离屏 layer
  • 本质上是方案一的微调,非独立渲染路径

方案四:CustomPaint / Canvas 手动绘制

使用场景

前三者做不到的需求:沿贝塞尔曲线的文字渐变、逐字独立渐变、多段文字不同渐变方向、渐变随滚动动态变化等。

代码示例

class CanvasGradientText extends StatelessWidget {
  final String text;

  const CanvasGradientText(this.text, {super.key});

  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      size: Size(200, 30),
      painter: _GradientTextPainter(text),
    );
  }
}

class _GradientTextPainter extends CustomPainter {
  final String text;

  _GradientTextPainter(this.text);

  @override
  void paint(Canvas canvas, Size size) {
    final shader = LinearGradient(
      colors: const [Color(0xFFFFE3AF), Color(0xFFFFFFFF)],
    ).createShader(Offset.zero & size);

    final tp = TextPainter(
      text: TextSpan(
        text: text,
        style: TextStyle(
          fontSize: 14,
          fontWeight: FontWeight.w600,
          foreground: Paint()..shader = shader,
        ),
      ),
      textDirection: TextDirection.ltr,
    )..layout(maxWidth: size.width);

    tp.paint(canvas, Offset.zero);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

注意:上面例子中渐变最终仍然通过 TextStyle.foreground 传入 TextPainter——它本质上是方案二的 Canvas 版本。真正需要方案四的场景是不使用 TextPainter 的文字绘制,例如:

  • 将 SVG path 转换为 Canvas path,用 canvas.drawPath(path, gradientPaint) 绘制渐变文字轮廓
  • 逐字形遍历,每个字形用不同的渐变 Paint
  • 文字沿路径排列,同时每个像素的渐变角度跟随路径切线方向

优劣

  • 自由度高,无布局限制
  • 丧失 Text 的语义、换行、溢出省略、无障碍支持
  • 需要手动处理 shouldRepaint、尺寸计算、文字方向
  • 非特殊需求不推荐