别靠给组件堆动画来营造"手工打造"。更有效的做法是把像素活交给 GPU,让界面既顺滑、又有表现力,同时几乎不占用 CPU。
Shaders 是我们知道存在却不常亲手用的东西。但它们恰恰是让界面"活起来"的秘密武器:流动的背景、玻璃质感的表面、像素级的失真,还有仿佛在呼吸的动画。为了便于照搬落地,我给出一个可直接复制的 Flutter 屏幕示例,改一改就能用在你的项目里。这样你不是在"看" Shaders,而是在真正"用"它。你会发现,少量代码就能解锁由 GPU 驱动的视觉效果,对"普通 UI"的认知也会被刷新。
关注我的微信公众号:OpenFlutter
🗺️ 快速概览:本文将涵盖的内容
- FragmentProgram 是什么以及何时使用它。
- 编写一个小型的片段着色器(GLSL)及其存放位置。
- 使用 Dart 中的 FragmentProgram 和 FragmentShader 进行加载与使用。
- 经济高效地更新 Uniforms 并重用着色器对象。
- 调试、常见陷阱、以及 CI/资源管理方面的建议。
- 带截图的最终示例:如何实现它?
- 安全发布的核对清单。
💡 为什么要使用 Shaders?(简短回答)
把像素处理交给 GPU,会直接带来这些好处:
- 低成本地运行 各种逐像素效果(如模糊、扭曲、光照)。
- 生成难以通过基于组件(Widget-based)绘图实现的流畅 60/120fps 视觉效果。
- 将视觉逻辑集中在一个单独的着色器中,让 GPU 可以进行大规模并行执行。
在实际项目里,我用小型 Shaders 替换了不少依赖 CPU 的动画和开销较大的 Canvas 循环:帧率更稳更顺,同时减轻了 CPU 争用,尤其对中端设备很关键。
🧠 核心心智模型:FragmentProgram → FragmentShader → Paint.shader
理解这三个关键概念是使用 Shaders 的基础:
- FragmentProgram :你加载的已编译着色器资源 (Asset)。可以将其理解为着色器的二进制文件。
- FragmentShader :程序的一个配置实例 ,携带着它的 Uniforms(即每次绘制时传入的参数)。你可以从一个
FragmentProgram创建出多个FragmentShader实例。 - Paint.shader :
FragmentShader是通过Paint.shader在绘制画布时使用的(也可以通过ShaderMask、CustomPainter等使用)。
总结: 只需加载一次 Program ,然后重复使用它,并在每帧更新 Shader 实例上的 Uniforms。
1)编写一个微小的着色器 (GLSL) --- shaders/wave.frag
首先,创建一个着色器文件。一个使用 Flutter 运行时辅助函数的最小化示例如下:
c
// shaders/wave.frag
// 引入坐标映射的辅助函数(可选)
#include "flutter/runtime_effect.glsl";
uniform float u_width;
uniform float u_height;
uniform float u_time; // 秒
half4 main(vec2 fragCoord) {
vec2 uv = fragCoord / vec2(u_width, u_height);
float wave = 0.5 + 0.5 * sin(uv.x * 12.0 + u_time * 2.0);
vec3 base = vec3(0.12, 0.6, 0.9);
vec3 color = mix(base * 0.8, base, wave);
return half4(color, 1.0);
}
📝 Shaders 使用要点和配置说明
📤 Uniforms(着色器参数)
- 着色器会接收到一些 Uniforms 参数 (
u_width,u_height,u_time),这些参数将由 Dart 代码设置和传入。
📦 GLSL 导入与工具链
- 根据你使用的 Flutter 工具链,可能需要添加
#include "flutter/runtime_effect.glsl"来引入坐标辅助函数。 - (关于确切的引用路径,请查阅官方文档或示例。)
📂 资源配置的关键区别
- 务必 将着色器文件添加到你的
pubspec.yaml文件的shaders:下方,而不是assets:下方。
yaml
shaders:
- shaders/wave.frag
🚨 2) 在 Dart 中加载和使用 Shaders
🛣️ 着色器路径的配置(关键注意事项)
将着色器路径放在
pubspec.yaml的shaders:部分,可以确保 Flutter 的构建系统将它们编译成 FragmentProgram 所期望的格式。忽略这一步是导致"它无法运行"的最常见错误。
🖌️ 易于复制粘贴的 CustomPainter 示例
以下是一个可以直接复制粘贴使用的 CustomPainter 示例,演示了如何在 Dart 中加载并使用着色器:
dart
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
class WavePainter extends CustomPainter {
final ui.FragmentShader shader;
final double time;
WavePainter({ required this.shader, required this.time });
@override
void paint(Canvas canvas, Size size) {
// 按着色器中声明的顺序设置 uniforms(采样器跳过)
shader.setFloat(0, size.width); // u_width 宽度
shader.setFloat(1, size.height); // u_height 高度
shader.setFloat(2, time); // u_time 时间
final paint = Paint()..shader = shader;
canvas.drawRect(Offset.zero & size, paint);
}
@override
bool shouldRepaint(covariant WavePainter old) => time != old.time;
}
如何准备和连接着色器:
dart
// 在你的 StatefulWidget 中的某处
ui.FragmentProgram? _program;
ui.FragmentShader? _shader;
double _time = 0.0;
late final Ticker _ticker;
@override
void initState() {
super.initState();
_loadShader();
_ticker = Ticker((elapsed) {
setState(() => _time = elapsed.inMilliseconds / 1000.0);
})..start();
}
Future<void> _loadShader() async {
_program = await ui.FragmentProgram.fromAsset('shaders/wave.frag');
// 创建 FragmentShader 实例------跨帧复用(更新 uniforms 更快)
_shader = _program!.fragmentShader();
}
@override
void dispose() {
_ticker.dispose();
super.dispose();
}
🔑 关键要点总结
- FragmentProgram.fromAsset 是异步 的;只需加载一次(例如,在应用启动或屏幕挂载时)。
- 使用
fragmentShader()创建一个FragmentShader实例。应该重用该实例 ,并每帧调用setFloat来更新 Uniforms,而不是每帧都重新创建新的 Shader 对象。 setFloat(index, value)根据指定的索引(索引顺序遵循着色器中的 Uniform 声明,跳过 Samplers)设置浮点型 Uniform。
3) Uniforms、Samplers 和纹理
- 浮点型 / 向量 (
vec2/vec3/vec4): 通过重复调用setFloat来设置。对于向量类型,需按顺序设置每个分量。 - 图像采样器 (Image samplers): 使用
setImageSampler(在FragmentShader上)将纹理绑定到采样器 Uniform------这对于处理捕获的图像或纹理的特效非常有用。尽可能重用纹理以避免内存分配。
4) 着色器的存放位置和构建方式
- 在
pubspec.yaml中使用shaders:标签,确保 Flutter 通过impellerc工具链将其编译成FragmentProgram所需的运行时格式。 - 如果错误地放在
assets:下,加载器可能会失败并给出误导性的错误。务必在本地和持续集成(CI)环境中测试构建。 - 在调试模式下,着色器编辑通常支持热重载(工具链会重新编译),迭代周期很快------但始终要在 Profile/Release 版本上进行健全性检查。
5) 性能最佳实践(实用建议)
- 重用对象: 重复创建
FragmentProgram和FragmentShader的开销很大;应该重用它们,每帧只更新 Uniforms。 - 最小化 Uniform 更新: 仅打包每帧会发生变化的数据(例如,时间、触摸坐标)。
- 避免大纹理: 大型图像采样器会占用内存和纹理上传时间;尽可能进行降采样。
- 绘制优化: 使用
shouldRepaint和状态检查来避免不必要的重绘(这是经典的CustomPainter规范)。 - 测试设备: 在中端设备上进行测试------高端硬件上的描述性基准测试可能具有误导性。
- 性能分析: 在 Profile 模式(而非 Debug 模式)下进行分析,以查看真实的 GPU/CPU 行为。Flutter 文档中指出了不同模式之间的差异。
6) 调试和常见陷阱
-
"资源不包含有效的着色器数据" (Asset does not contain valid shader data):
- 通常是因为着色器未被包含在
shaders:下或工具链未对其进行编译;检查构建日志和pubspec。(这个错误非常常见且容易令人困惑。)
- 通常是因为着色器未被包含在
-
Uniform 顺序问题:
setFloat的整数索引取决于着色器中 Uniform 的声明顺序(跳过 Samplers)。如果值看起来不对,请检查你的索引映射。 -
热重载异常: 着色器在 Debug 模式下会重新编译,但请务必确认其在 Profile/Release 模式下的行为。
-
平台差异: GPU 驱动程序和操作系统版本可能会影响着色器能力。测试你所支持的 Android 和 iOS 设备。
7) 可访问性和用户体验 (UX) 考虑
着色器是视觉效果------不要将关键内容隐藏在效果之中:
- 始终为重要信息提供文本等效内容。
- 避免使用纯粹由着色器驱动的颜色对比度来传达状态。
- 如果着色器包含动画,请提供一种让用户减少动态效果的方式(尊重系统"减少动态效果"的偏好设置)。
8) 测试和持续集成 (CI) 提示
- 在 CI 中包含
shaders:路径,并运行一个构建步骤来验证FragmentProgram.fromAsset能否加载每个已编译的着色器(一个小型冒烟测试)。尽早捕获"未编译"的问题。 - 检查大小影响: Shaders 会增加微小的二进制数据块;在 CI 中跟踪应用大小。
- 视觉回归: 截取关键帧快照(例如,使用 Golden Tests)以检测视觉效果上的回归。
🖼️ 最终示例
着色器可以实时通过数学方式生成波浪、渐变、扭曲、涟漪和有机运动等效果。

-
着色器应用区域:
- 在你生成的截图中,最上方的区域 ------那个色彩鲜艳、波浪起伏、充满流动感的背景 (位于"Total Balance"的上方)------正是使用 Fragment Shader 实现的部分。
-
中部和底部区域:非着色器实现(刻意为之):
- 中部和底部区域并非 基于着色器实现的------这是出于设计目的。
步骤 1:添加着色器文件
- 创建文件:
shaders/wave_header.frag
c
// shaders/wave_header.frag
#include <flutter/runtime_effect.glsl>
uniform float u_width;
uniform float u_height;
uniform float u_time;
half4 main(vec2 fragCoord) {
vec2 uv = fragCoord / vec2(u_width, u_height);
// 基础配色
vec3 c1 = vec3(0.09, 0.15, 0.36);
vec3 c2 = vec3(0.26, 0.20, 0.70);
vec3 c3 = vec3(0.05, 0.60, 0.85);
// 分层波浪
float w1 = sin(uv.x * 6.0 + u_time * 0.9);
float w2 = sin(uv.x * 10.0 - u_time * 1.3 + 2.0);
float waveMix = (w1 + w2) * 0.25 + uv.y;
vec3 color = mix(c2, c3, smoothstep(0.0, 1.0, waveMix));
color = mix(c1, color, 0.8);
return half4(color, 1.0);
}
2. 在 pubspec.yaml 中注册着色器
yaml
flutter:
uses-material-design: true
shaders:
- shaders/wave_header.frag
2. 注册着色器(pubspec.yaml)
注意: 该文件不 应放在
assets:下------它必须 放在shaders:下方。
3. Flutter 屏幕代码 (lib/main.dart)
dart
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Fintech Shader Demo',
theme: ThemeData(useMaterial3: true),
home: const FintechHomeScreen(),
);
}
}
class FintechHomeScreen extends StatefulWidget {
const FintechHomeScreen({super.key});
@override
State<FintechHomeScreen> createState() => _FintechHomeScreenState();
}
class _FintechHomeScreenState extends State<FintechHomeScreen>
with SingleTickerProviderStateMixin {
ui.FragmentProgram? _program;
ui.FragmentShader? _shader;
late final AnimationController _controller;
@override
void initState() {
super.initState();
_loadShader();
_controller = AnimationController.unbounded(vsync: this)
..repeat(period: const Duration(seconds: 10));
}
Future<void> _loadShader() async {
final program =
await ui.FragmentProgram.fromAsset('shaders/wave_header.frag');
setState(() {
_program = program;
_shader = program.fragmentShader();
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
bottomNavigationBar: NavigationBar(
selectedIndex: 0,
destinations: const [
NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
NavigationDestination(icon: Icon(Icons.sync_alt), label: 'Transfer'),
NavigationDestination(icon: Icon(Icons.credit_card), label: 'Cards'),
NavigationDestination(icon: Icon(Icons.more_horiz), label: 'More'),
],
),
body: Column(
children: [
SizedBox(
height: 260,
child: (_program == null || _shader == null)
? const _HeaderFallback()
: AnimatedBuilder(
animation: _controller,
builder: (context, _) {
return CustomPaint(
painter: _HeaderShaderPainter(
shader: _shader!,
time: _controller.lastElapsedDuration?.inMilliseconds
.toDouble() ??
0.0,
),
child: const _HeaderContent(),
);
},
),
),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
children: const [
_AccountsCard(),
SizedBox(height: 16),
_QuickTransferCard(),
],
),
),
],
),
);
}
}
/// 着色器加载时的简易渐变回退
class _HeaderFallback extends StatelessWidget {
const _HeaderFallback();
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFF141E30),
Color(0xFF243B55),
],
),
),
child: const _HeaderContent(),
);
}
}
/// 着色器之上的前景 UI
class _HeaderContent extends StatelessWidget {
const _HeaderContent();
@override
Widget build(BuildContext context) {
return SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Total Balance',
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(color: Colors.white70)),
const SizedBox(height: 8),
Text('$6,280.50',
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w700,
)),
],
),
),
);
}
}
/// 实际绘制着色器的自定义画笔
class _HeaderShaderPainter extends CustomPainter {
final ui.FragmentShader shader;
final double time;
_HeaderShaderPainter({required this.shader, required this.time});
@override
void paint(Canvas canvas, Size size) {
shader.setFloat(0, size.width); // u_width
shader.setFloat(1, size.height); // u_height
shader.setFloat(2, time / 1000.0); // u_time(秒)
final paint = Paint()..shader = shader;
canvas.drawRect(Offset.zero & size, paint);
}
@override
bool shouldRepaint(covariant _HeaderShaderPainter oldDelegate) =>
oldDelegate.time != time;
}
// ===== 前景卡片 =====================================================
class _AccountsCard extends StatelessWidget {
const _AccountsCard();
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Accounts',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
_AccountRow(label: 'Checking', last4: '1234', amount: '$2,150.75'),
const SizedBox(height: 8),
_AccountRow(label: 'Savings', last4: '5678', amount: '$4,129.75'),
],
),
),
);
}
}
class _AccountRow extends StatelessWidget {
final String label;
final String last4;
final String amount;
const _AccountRow({
required this.label,
required this.last4,
required this.amount,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(fontWeight: FontWeight.w500)),
Text('•••• $last4',
style: Theme.of(context).textTheme.bodySmall),
],
),
const Spacer(),
Text(amount,
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(fontWeight: FontWeight.w600)),
],
);
}
}
class _QuickTransferCard extends StatelessWidget {
const _QuickTransferCard();
@override
Widget build(BuildContext context) {
return Card(
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Quick Transfer',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
Row(
children: [
_RoundAction(icon: Icons.north_east, label: 'Send'),
const SizedBox(width: 16),
_RoundAction(icon: Icons.south_west, label: 'Request'),
],
),
],
),
),
);
}
}
class _RoundAction extends StatelessWidget {
final IconData icon;
final String label;
const _RoundAction({required this.icon, required this.label});
@override
Widget build(BuildContext context) {
return Column(
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.08),
shape: BoxShape.circle,
),
child: Icon(icon,
size: 24, color: Theme.of(context).colorScheme.primary),
),
const SizedBox(height: 4),
Text(label, style: Theme.of(context).textTheme.bodySmall),
],
);
}
}
🎯 核心原理(超简述)
- GLSL 着色器: 负责绘制动画波浪背景。
- 加载:
FragmentProgram.fromAsset进行加载,fragmentShader()创建一个可重用的着色器实例。 - 数据传递:
_HeaderShaderPainter设置三个 Uniforms:宽度、高度和时间。 - 前景: 前景元素(余额文本、卡片、按钮、底部导航)是正常的 Flutter UI。
✅ 着色器发布前的简短核对清单
- 着色器文件声明在
pubspec.yaml的shaders:下。 - 在 Profile/Release 版本中构建,并在目标设备上测试。
- 重用
FragmentProgram和FragmentShader;每帧只更新 Uniforms。 - 如果动画是关键部分,添加回退视觉效果 或减少动态效果的选项。
- 添加一个 CI 冒烟测试,确保可以加载/实例化每个片段程序。
🚀 实践指南:应该如何做
将着色器写成小型的 GLSL 片段程序,在 pubspec.yaml 的 shaders: 下注册它们,然后使用 FragmentProgram.fromAsset 加载一次 ,创建 FragmentShader 实例,接着每帧通过 setFloat (以及用于纹理的 setImageSampler)来更新 Uniforms。
务必重用 着色器对象,在 Profile/Release 版本中进行性能分析,并纳入 CI 检查,以避免着色器编译/加载问题在运行时给你带来意外。
🎨 设计理念:以意图驱动,而非炫技
着色器是工程师工具箱里的带点艺术的工具。它把视觉复杂度交给 GPU,让你做出清晰、独特的 UI 细节------但能力越大,责任也越大:
- 测试:确保功能稳定。
- 尊重用户:考虑动态效果和可访问性。
- 保持小步迭代:逐步添加效果。
只要用得好, 着色器能以很小的工程投入,成倍提升整个应用的用户体验打磨(UX polish)。