当视觉稿出现上千粒子、连续噪声、液态遮罩或随指针变化的光场,继续堆叠 Container、Transform 与 Opacity 往往会让 Widget 结构失去语义。反过来,把一切塞进 shader 也会把布局、命中测试和可访问性一起丢掉。更稳的分工是:普通 Widget 负责结构与语义,CustomPainter 负责二维几何,Fragment Shader 在明确边界内计算像素颜色。
本文面向熟悉 Canvas 基本绘制、希望制作交互视觉的 Flutter 开发者。前置条件是理解 Listenable、坐标系和 RepaintBoundary。我们不会给出未经测量的帧率或“GPU 一定更快”结论,而是建立选择与降级方法。
版本快照与能力边界
截至 2026-07-16,Flutter SDK 归档列出的稳定版是 Flutter 3.44.6 / Dart 3.12.2,Beta 是 Flutter 3.47.0-0.1.pre / Dart 3.13 beta。本仓库本机基线为 3.41.9 / 3.11.5。本文采用两套稳定基线均有的 CustomPainter、FragmentProgram.fromAsset、FragmentShader.setFloat 与 Paint.shader;没有使用 Beta API。文中的 shader 资产和 Dart binder 已进入 examples 并通过 Flutter 工具链测试,文章其余架构论述仍按 source-reviewed 标记。
官方 Fragment Shader 指南说明,普通自定义片元着色器可用于 Skia 与 Impeller;但 ImageFilter.shader 只支持 Impeller,其他后端会抛错。Flutter 当前只暴露 fragment shader,不支持自定义 vertex shader,并限制 UBO/SSBO、sampler 类型和部分 GLSL 能力。设计效果前先看目标后端,不能用桌面模拟器成功替代平台矩阵。
视觉资产记录:封面(1600 × 900)与文中职责图(1200 × 680)均为 WEB/SUN 于 2026-07-16 创作的程序化 SVG;来源/许可为本项目原创自有资产,未使用第三方图片。
CustomPainter 是绘制委托,不是小型游戏引擎
CustomPainter API要求实现 paint 与 shouldRepaint,并可选提供 hitTest、semanticsBuilder 与 shouldRebuildSemantics。paint(Canvas, Size) 的 Canvas 已设置好左上角原点和可用尺寸;Painter 应根据输入状态记录绘制命令,而不是在 paint 内请求网络、解码图片或创建长期异步任务。
最高效的动态重绘入口通常不是在动画每一帧调用页面 setState,而是把 Listenable 传给 CustomPainter(repaint: ...)。这样 RenderCustomPaint 可以直接请求 paint,跳过 Widget 构建和布局。注意这只减少上游工作:路径生成、shader uniform 更新与大面积绘制仍然占用帧预算。
图 1(1200 × 680):交互状态通过 Listenable 触发重绘,Canvas 定几何,FragmentShader 算颜色。原创程序化 SVG,WEB/SUN,2026-07-16。

图 2(1600 × 900):绘制与着色协作的编辑式视觉隐喻,不作为 API 结构图。OpenAI Image Gen × WEB/SUN,2026-07-16;项目内原创生成资产。
一个 Painter 应当有可比较的输入。若旧委托与新委托的几何、颜色和资源完全一致,shouldRepaint 返回 false;若使用同一 repaint 对象驱动每帧,委托实例本身甚至不必每帧替换。粗暴地永远返回 true 可能正确但放弃了框架判断机会,永远 false 则会产生过时画面。
Canvas 状态也需要成对管理。save / restore 适合把平移、旋转和裁剪限制在局部;saveLayer 则可能建立离屏缓冲,不应当作普通 save 使用。CustomPainter.paint 文档提醒错误配对会影响同一 Canvas 后续绘制,因为多个 Painter 可能共享底层 Canvas。一个实用规则是让每个局部变换在同一代码块内完成 save、操作和 restore,并在调试阶段用断言或小函数封装。
CustomPainter 也不保证获得独占画布。若要用 BlendMode.dstOut 打洞,混合结果可能影响先前由其他 Widget 画出的内容;必要时才使用 saveLayer 隔离,并测量额外成本。另一种更便宜的方案可能是直接构造反向 Path 或只画需要的边缘。视觉结果相同,不代表合成路径相同。
Shader 资产从构建期进入运行时
自定义 .frag 文件要声明在 pubspec.yaml 的 flutter.shaders 中。Flutter 工具在构建时把它编译为目标后端格式并打包;运行时用 FragmentProgram.fromAsset 取得程序,再由 fragmentShader() 创建带 uniform 状态的实例。官方指南与 FragmentShader API都建议复用 Program 和可复用的 Shader,避免每帧重新分配 uniform buffer 与 sampler。
flutter:
shaders:
- shaders/field.frag
#version 460 core
#include <flutter/runtime_effect.glsl>
uniform vec2 uSize;
uniform vec2 uPointer;
uniform float uTime;
uniform float uStrength;
out vec4 fragColor;
void main() {
vec2 uv = FlutterFragCoord().xy / uSize;
vec2 pointer = uPointer / uSize;
float distanceToPointer = distance(uv, pointer);
float ring = 0.5 + 0.5 * cos(distanceToPointer * 42.0 - uTime * 2.0);
float alpha = (1.0 - smoothstep(0.08, 0.52, distanceToPointer)) * uStrength;
vec3 color = mix(vec3(0.137, 0.341, 0.851), vec3(0.941, 0.353, 0.157), ring);
fragColor = vec4(color * alpha, alpha);
}
这段 shader 只演示 uniform 布局与局部光环,不代表经过视觉或性能验收。生产中还要处理零尺寸、宽高比、颜色空间、预乘 alpha 和输入越界。
Uniform 是协议,顺序错误不会替你报业务错
在稳定交集 API 中,setFloat(index, value) 的 index 按非 sampler uniform 的声明顺序展开,vec2 占两个连续 float;sampler 使用独立的索引空间。只要 Dart 与 GLSL 的顺序发生漂移,代码可能仍编译,却把 width 写进 pointer.x,得到难以理解的画面。因此不要在 paint 中散落魔法数字,集中维护绑定函数并逐项注释。
void bindField(
ui.FragmentShader shader,
Size size,
Offset pointer,
double time,
double strength,
) {
shader
..setFloat(0, size.width)
..setFloat(1, size.height)
..setFloat(2, pointer.dx)
..setFloat(3, pointer.dy)
..setFloat(4, time)
..setFloat(5, strength);
}
class FieldPainter extends CustomPainter {
FieldPainter({
required Listenable repaint,
required this.shader,
required this.readState,
}) : super(repaint: repaint);
final ui.FragmentShader shader;
final FieldState Function() readState;
@override
void paint(Canvas canvas, Size size) {
if (size.isEmpty) return;
final state = readState();
bindField(shader, size, state.pointer, state.time, state.strength);
canvas.drawRect(Offset.zero & size, Paint()..shader = shader);
}
@override
bool shouldRepaint(FieldPainter oldDelegate) => oldDelegate.shader != shader;
}
这里让 Canvas 的 drawRect 定义 shader 运行的几何区域;每个覆盖到的 fragment 才计算颜色。若效果只占头像周围,就画紧凑 Rect 或 Path,不要默认全屏 drawRect。像素覆盖面积、shader 分支与纹理采样数量都可能影响 Raster 成本。
交互状态、资源生命周期与语义
指针事件应先转换到 Painter 的局部坐标,并在离开、取消和尺寸变化时钳制或复位。不要让 shader 直接承担手势识别;GestureDetector、MouseRegion 与 Focus 仍属于 Widget 层。键盘用户可以用方向键移动焦点,减少动态效果用户可以看到静态采样,触摸则与 Pointer Events 共享同一状态模型。
FragmentProgram 可在页面进入前异步加载,加载期间展示同构的 Canvas 渐变或静态 SVG,不要用全屏 loader 阻挡内容。Shader 持有原生资源;不用后调用 dispose,并确保路由退出后停止 Ticker。热重载、AppLifecycle 和尺寸变化也要测试,避免旧资源仍被监听器引用。
CustomPaint 默认不会自动描述所画内容。若画面只是装饰,设置 ExcludeSemantics 或保持语义树简洁;若它表示数据或可操作对象,就用语义 Widget 覆盖交互区域,必要时实现 semanticsBuilder。不要试图让读屏器从像素推断意义。
给视觉程序建立可复核测试面
Shader 的输入空间往往比截图更重要。为 time、pointer、strength、size 建立边界表:零尺寸直接跳过;pointer 可以钳制到画布或明确允许越界;strength 的有效范围稳定;time 很大时避免精度导致跳变。绑定层可以在纯 Dart 测试中验证 uniform 顺序,GLSL 则通过固定输入生成 Golden。
Golden 测试要固定逻辑尺寸、devicePixelRatio、字体和随机 seed,并为允许的后端差异设置审慎阈值。若同一像素必须完全一致,跨 GPU 的浮点与采样细节可能让测试脆弱;更合理的是同时测试结构不变量,例如中心透明度大于边缘、颜色通道在合法范围、静态回退与动态版表达同一信息。
交互测试要覆盖按下、拖出、取消、键盘移动和 reduced motion。性能证据则单独在 profile 真机采集:记录设备、系统、Flutter 版本、后端、分辨率、DPR、采样时长和参数。Golden 通过不代表帧预算通过,帧率稳定也不代表语义与降级正确。
资源失败是正常分支,而非只在 catch 中打印错误。Program 加载失败后切换到 Canvas 回退并停止等待;App 进入后台时冻结时间,回来后选择续播还是重置;画布离屏时停止 ticker。这样“无 GPU 效果”仍是一张完整页面,而不是坏掉的加载态。
性能判断:先减少工作,再选择后端
Flutter 性能最佳实践提醒关注 saveLayer、过度透明、裁剪和内在尺寸布局等成本。Shader 并不会让这些工作自动消失;一个全屏多次采样的模糊可能比若干 Canvas 路径更贵。应在 profile 模式记录 UI 与 Raster 帧,分别对比:纯 Widget、CustomPainter、shader 版本;固定测试设备、分辨率、刷新率和操作序列。
优先做结构优化:离屏暂停 Ticker;交互静止时不持续重绘;限制 devicePixelRatio 或离屏纹理尺寸;复用 Path、Paint、Program 和 Shader;把静态复杂背景与动态局部效果分开;避免在 paint 中分配大列表。只有证据显示绘制仍是瓶颈,再降低采样次数、分支复杂度或视觉覆盖。
常见失败与降级
| 失败 | 为什么发生 | 可用降级 |
|---|---|---|
| Shader 资产加载失败 | 路径未声明、构建产物缺失 | Canvas 线性/径向渐变 |
| 后端不支持 ImageFilter.shader | 该路径仅 Impeller | Paint.shader 直接绘制或静态图 |
| uniform 顺序漂移 | Dart 与 GLSL 协议分叉 | 集中 binder、截图/像素测试 |
| 每帧 new Program/Shader | 原生资源反复创建 | 页面级缓存,退出时 dispose |
| 全屏透明模糊持续运行 | 像素覆盖和采样过大 | 缩小区域,静止时冻结 |
| Canvas 内容无语义 | 像素不进入语义树 | HTML/Widget 等价控件与文本说明 |
结论
CustomPainter 与 Fragment Shader 不是彼此替代:Painter 决定何时重绘、画在哪个几何区域,shader 决定区域内每个片元的颜色。Widget 层继续承担布局、手势、焦点、语义和静态回退。把这三层分开,才能既保留“炫”的视觉,也保留可测试、可关闭、可降级的产品结构。
好的效果从最小覆盖开始:先用 Canvas 证明几何,再用 shader 替换确实需要逐像素计算的部分;每一层都留静态替代,并用真机 profile 证据决定是否复杂化。GPU 是工具,不是免单券。
Sources
- Flutter — Flutter SDK archive,访问于 2026-07-16。
- Flutter API — CustomPainter class,访问于 2026-07-16。
- Flutter API — CustomPainter paint method,访问于 2026-07-16。
- Flutter — Writing and using fragment shaders,访问于 2026-07-16。
- Flutter API — FragmentShader class,访问于 2026-07-16。
- Flutter — Performance best practices,访问于 2026-07-16。