想让你的 Flutter UI 更上一层楼吗?

别靠给组件堆动画来营造"手工打造"。更有效的做法是把像素活交给 GPU,让界面既顺滑、又有表现力,同时几乎不占用 CPU。

Shaders 是我们知道存在却不常亲手用的东西。但它们恰恰是让界面"活起来"的秘密武器:流动的背景、玻璃质感的表面、像素级的失真,还有仿佛在呼吸的动画。为了便于照搬落地,我给出一个可直接复制的 Flutter 屏幕示例,改一改就能用在你的项目里。这样你不是在"看" Shaders,而是在真正"用"它。你会发现,少量代码就能解锁由 GPU 驱动的视觉效果,对"普通 UI"的认知也会被刷新。

关注我的微信公众号:OpenFlutter

🗺️ 快速概览:本文将涵盖的内容

  • FragmentProgram 是什么以及何时使用它。
  • 编写一个小型的片段着色器(GLSL)及其存放位置。
  • 使用 Dart 中的 FragmentProgramFragmentShader 进行加载与使用。
  • 经济高效地更新 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.shaderFragmentShader 是通过 Paint.shader 在绘制画布时使用的(也可以通过 ShaderMaskCustomPainter 等使用)。

总结: 只需加载一次 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.yamlshaders: 部分,可以确保 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) 性能最佳实践(实用建议)

  • 重用对象: 重复创建 FragmentProgramFragmentShader 的开销很大;应该重用它们,每帧只更新 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

✅ 着色器发布前的简短核对清单

  1. 着色器文件声明在 pubspec.yamlshaders: 下。
  2. Profile/Release 版本中构建,并在目标设备上测试。
  3. 重用 FragmentProgramFragmentShader;每帧只更新 Uniforms
  4. 如果动画是关键部分,添加回退视觉效果减少动态效果的选项。
  5. 添加一个 CI 冒烟测试,确保可以加载/实例化每个片段程序。

🚀 实践指南:应该如何做

将着色器写成小型的 GLSL 片段程序,在 pubspec.yamlshaders: 下注册它们,然后使用 FragmentProgram.fromAsset 加载一次 ,创建 FragmentShader 实例,接着每帧通过 setFloat (以及用于纹理的 setImageSampler)来更新 Uniforms。

务必重用 着色器对象,在 Profile/Release 版本中进行性能分析,并纳入 CI 检查,以避免着色器编译/加载问题在运行时给你带来意外。


🎨 设计理念:以意图驱动,而非炫技

着色器是工程师工具箱里的带点艺术的工具。它把视觉复杂度交给 GPU,让你做出清晰、独特的 UI 细节------但能力越大,责任也越大:

  • 测试:确保功能稳定。
  • 尊重用户:考虑动态效果和可访问性。
  • 保持小步迭代:逐步添加效果。

只要用得好, 着色器能以很小的工程投入,成倍提升整个应用的用户体验打磨(UX polish)。

相关推荐
崔庆才丨静觅4 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60614 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了4 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅5 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅5 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅5 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment5 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅6 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊6 小时前
jwt介绍
前端
爱敲代码的小鱼6 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax