Flutter 渐变文本四种方案深度解析
从源码、性能和使用场景解析 Flutter 中渐变文本的四种实现方案:ShaderMask、TextStyle.foreground、自定义 GradientShaderMask 和 Canvas
方案总览
| 方案 | 核心组件 | 一句话 | |
|---|---|---|---|
| 1 | ShaderMask + BlendMode | ShaderMask Widget | 用渐变覆盖已绘制的文字,通过混合模式控制可见区域 |
| 2 | TextStyle.foreground | Paint()..shader | 直接在文字字形的绘制阶段使用渐变 Paint |
| 3 | 自定义 GradientText 组件 | 封装 ShaderMask 并调节 blendMode | 在方案一的基础上优化边缘锯齿 |
| 4 | CustomPaint / Canvas | CustomPainter + 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 | 混合公式 |
|---|---|
srcATop | result = src × dst.alpha + dst × (1 − src.alpha) |
srcIn | result = 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、尺寸计算、文字方向 - 非特殊需求不推荐