当视觉稿出现上千粒子、连续噪声、液态遮罩或随指针变化的光场,继续堆叠 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。本文采用两套稳定基线均有的 CustomPainterFragmentProgram.fromAssetFragmentShader.setFloatPaint.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要求实现 paintshouldRepaint,并可选提供 hitTest、semanticsBuilder 与 shouldRebuildSemanticspaint(Canvas, Size) 的 Canvas 已设置好左上角原点和可用尺寸;Painter 应根据输入状态记录绘制命令,而不是在 paint 内请求网络、解码图片或创建长期异步任务。

最高效的动态重绘入口通常不是在动画每一帧调用页面 setState,而是把 Listenable 传给 CustomPainter(repaint: ...)。这样 RenderCustomPaint 可以直接请求 paint,跳过 Widget 构建和布局。注意这只减少上游工作:路径生成、shader uniform 更新与大面积绘制仍然占用帧预算。

CustomPainter 与 FragmentShader 的边界

图 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.yamlflutter.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