Flutter项目交付上线后,通常会收到一类反馈:首页列表快速滑动时会突然掉帧,详情页转场有明显的白屏停顿,甚至在低端Android机上连简单的弹出动画都显得迟缓。这类问题如果只靠感觉去猜“哪里卡就改哪里”,很难根治。我刚接手一个Flutter项目时也走过这条路,后来把整个性能优化过程梳理成了一套方法论:先搞清楚渲染链路,再逐层排查代码、图片、列表、渲染开销,最后用工具量化每一次改动。这篇文章就是这套实践经验的完整记录,覆盖Flutter UI性能优化的核心原理、可落地的优化手段、常见性能问题的定位思路,适合刚入门的Flutter开发者,也适合已经写了半年以上Flutter但面对卡顿问题仍然无从下手的人。
1. 先弄清楚:你的 Flutter 页面为什么会卡
1.1 从 UI 线程和 Raster 线程说起
Flutter 的渲染模式和其他跨端方案不一样,它不走原生控件,而是自绘整个界面。你写的 Widget 最终会被转换成 Element、RenderObject,再经过 build、layout、paint 三个阶段,生成一棵 Layer Tree,最后由底层的渲染引擎(Skia 或 Impeller)把图层合成并输出到屏幕。
这里要记住两个关键角色。
UI 线程负责执行 Dart 代码,包括 build、布局、绘制指令的生成。Raster 线程(也叫 GPU 线程,在部分平台上是独立线程)负责把 UI 线程生成的 Layer Tree 真正合成、栅格化,变成屏幕上可见的像素。帧率是否稳定,取决于这两个线程谁先成为瓶颈。
每一帧的预算大约是 16.6 毫秒(60FPS)。如果 UI 线程在一帧里执行 build 和 layout 的时间超过了 16ms,帧就掉定了;如果 UI 线程很快,但 Raster 线程合成太慢,同样会造成掉帧。这两种掉帧现象在体验上很像,但排查方向完全不同。
打个比方:UI 线程是后厨里备菜的厨师,Raster 线程是炒菜和上菜的厨师。备菜慢了,后面只能干等;炒菜太慢,菜备得再快也端不出去。所以做性能优化的第一步,就是先判断到底是备菜环节慢,还是炒菜环节慢。判断方法很简单,在 Profile 模式下打开 Performance Overlay,观察上面那根柱子和下面那根柱子:上面代表 UI 线程耗时,下面代表 Raster 线程耗时。哪根柱子频繁超过绿线,瓶颈就在哪一端。
1.2 常见瓶颈分布
我把这些年遇到过的 Flutter UI 性能问题归了下类,大致是这么几类:
| 瓶颈类型 | 常见现象 | 发生阶段 |
|---|---|---|
| Widget 过度重建 | 滑动列表时整个页面刷新,CPU 占用飙升 | UI 线程 build |
| 布局嵌套过深 | 页面打开慢,低端机明显 | UI 线程 layout |
| 图片解码耗时 | 滚动到某个位置时突然卡一下 | UI 线程 / IO |
| 过度绘制与复杂效果 | 阴影、模糊、裁剪区域过大导致 GPU 负载高 | Raster 线程 |
| PlatformView 混用 | 嵌入原生地图或 WebView 时触摸、滚动掉帧 | 双端协调 |
| 内存问题 | 图片不断累加,GC 频繁,偶发卡顿 | 内存 / UI 线程 |
这六类基本覆盖了我在实际项目中遇到过的绝大多数性能问题。下面几个章节就是针对这些瓶颈逐层拆解的处理方法,每一层都有具体到代码的实践方案。
2. 代码层先做减法,比什么骚操作都管用
很多性能问题根本还没到渲染那一步,就是在 build 阶段把不该做的工作重复做了。代码层的优化是最容易见效、也最容易被忽视的部分。
2.1 const 构造与永不变化的子 Widget
Flutter 里一套很基础但经常被忽视的优化手段是 const 构造函数。如果 Widget 在构建时所有参数都是编译期常量,它就可以被复用,不需要在每次重建时重新创建实例和重新计算布局。
举个实际例子。一个列表 item 里有个头像和标题,头像链接固定,标题来自数据模型。如果你遇到的是不需要依赖外部状态的子组件,加 const 就能直接避免重复实例化:
// 不推荐:每次 build 都创建新实例 Widget _buildHeader() { return Container( padding: EdgeInsets.all(12), child: Row( children: [ Icon(Icons.account_circle), SizedBox(width: 8), Text('访客'), ], ), ); } // 推荐:所有参数都是常量,直接复用同一个实例 const _header = Padding( padding: EdgeInsets.all(12), child: Row( children: [ Icon(Icons.account_circle), SizedBox(width: 8), Text('访客'), ], ), );注意,Icon、SizedBox、Text 这些在参数确定后其实都可以是 const,关键是把 const 关键字写到最外层构造上,这样整棵子树都会被复用。我在团队里 Code Review 时最常说的一句话:凡是括号里不依赖作用域变量的 Widget,一律先加 const 试试,编译器会告诉你行不行。
依赖外部状态的场景怎么办?比如标题来自数据模型,你当然不能把它整个变成 const,但你仍可以把不依赖状态的子组件提取成单独的 const Widget,让它们不跟随父级重建。
2.2 把大 build 拆成小 Widget,控制 setState 的范围
另一个高频问题:一个页面里只有一个小组件需要更新,却调用了整页的 setState。这不是代码错了,但确实是性能杀手。重建范围越大,同一帧里 build 的节点就越多,Thread UI 阶段耗时自然上涨。
正确做法是让状态尽量接近使用它的 Widget。比如页面底部有一个数字倒计时显示,你只需要把倒计时这个区域独立成一个 StatefulWidget,让 setState 只作用在它自己身上:
// 不推荐:整个页面都参与重建 class CountdownPage extends StatefulWidget { ... } // 推荐:把需要频繁更新的部分拆小 class _CountdownWidget extends StatefulWidget { @override _CountdownWidgetState createState() => _CountdownWidgetState(); } class _CountdownWidgetState extends State<_CountdownWidget> { int _seconds = 60; void _tick() { setState(() => _seconds--); } @override Widget build(BuildContext context) { return Text('$_seconds s'); } }这里有个非常常见的陷阱:在 build 方法里写内联闭包或者直接 new 一个对象,也会导致子树重建时所有子 Widget 全部跟着变。例如 GestureDetector 的 onTap 每次 build 都生成一个新闭包,如果它包裹了一大段耗时复杂的子组件,这些子组件本来可以复用,但因为闭包引用变了,Element 比对时发现新实例,就只能重新构建。把这种子组件提取出来,用 const 固定住,或者传入匿名的状态管理方案时尽量使用 selector 级别的更新,都能避免这个问题。
另一个跟动画强相关的场景是 AnimatedBuilder。它允许你只重建动画相关的那部分子树,但前提是“不依赖动画”的子树得放在 builder 外面,或者放在 child 参数里:
AnimatedBuilder( animation: _controller, // 把不变化的 child 传进来,不要放在 builder 里构建 child: const _StaticContent(), builder: (context, child) { return Transform.translate( offset: Offset(0, _controller.value * 100), child: child, ); }, )这个细节很多人会忽略,把静态内容放在 builder 内部,结果每一帧都在重建那段根本不需要变化的内容。这个习惯养成之后,页面反复 rebuild 也不会产生无谓开销。
3. 图片加载与解码,往往是卡顿的重灾区
代码层的 setState 优化做到了,如果列表滚动到某个位置还是会顿一下,大概率是图片的问题。图片在 Flutter 里的开销被很多人低估了:一张几 MB 的高清图,加载时不仅要读文件,还要解码成原始像素大小的位图,这个操作非常消耗 CPU,而且内存占用跟图片的宽高成正比。
3.1 图片解码是怎么烧 CPU 的
Flutter 加载一张本地或网络图片时,从显式调用到最终显示,大体会经过这几个环节:
- 从磁盘或者网络读取文件字节流;
- 解析图片头部信息,获取宽高、格式;
- 解码成原始像素数据。一张 3840x2160 的 4K 图,解码后大约需要 3840 * 2160 * 4 字节,也就是大约 33MB 内存;如果走 GPU 纹理再做一次上传,开销还会更高;
- 缩放到实际显示尺寸;
- 上传 GPU 纹理,交给 Raster 线程绘制。
很多项目直接把高分辨率原图丢给 Image.network 或者 AssetImage,没有指定显示尺寸和缓存策略,结果就是:图片在屏幕上只占 200x200 的区域,却在内存里解码了整张 4K 图。白白浪费几十 MB 内存,滑动时还频繁触发 GC,表现就是一阵一阵的卡顿。
所以最基础的一步是调整解码尺寸,让解码出来的位图跟实际显示尺寸差不多。在 Flutter 里可以直接用 cacheWidth 参数控制:
// 按实际显示尺寸解码,避免解码全尺寸原图 Image.network( url, width: 200, height: 200, cacheWidth: 200, // 解码时直接缩放到 200 宽 cacheHeight: 200, fit: BoxFit.cover, );这里有个细节:cacheWidth 和 cacheHeight 必须保持跟显示比例接近的宽高,否则会被拉伸变形。如果你不确定外层容器尺寸,可以先用 LayoutBuilder 拿到的宽度算好再传入。实测一个列表场景,原来图片区域秒级卡顿,加上 cacheWidth 后滚动帧率立刻稳定了,内存占用直接下降一大截。
3.2 一套实操落地的图片优化方案
在真实项目里,单纯给每个 Image 加 cacheWidth 只是第一步。一套完整方案通常包含下面这些动作:
- 统一封装远程图片组件,在里面自动传入 cacheWidth,避免团队每个人各写各的;
- 列表滚动场景使用“提前解码策略”,比如在滚动停止或者某个 item 出现前预取下一屏的图片,避免“滑图时现加载”的抖动;
- 常驻背景图不要用普通 Image,可以预加载到内存后复用。背景图如果只在页面打开时显示一次,用 precacheImage 提前加载,避免页面转场开始时产生解码延迟;
- 纯色背景或者简单渐变尽量用 Container 的 decoration 绘制,不要用一张大图代替,省下 IO 和解码开销。
下面这段是列表里配合 cacheWidth 和占位图的基础写法:
// 一个通用的网络图片组件 class AppNetworkImage extends StatelessWidget { final String url; const AppNetworkImage({super.key, required this.url}); @override Widget build(BuildContext context) { return Image.network( url, fit: BoxFit.cover, cacheWidth: 200, // 根据实际尺寸动态计算 loadingBuilder: (context, child, progress) { if (progress == null) return child; return Container(color: Colors.black12); }, errorBuilder: (context, error, stack) => Container(color: Colors.grey), ); } }实际项目里有时还要面对“图片加载完那一下还是卡”的问题。这种卡顿通常不是解码问题,而是解码后上传 GPU 纹理的那一瞬间开销太大,尤其是大尺寸半透明 PNG。解决方案有两个方向:一是继续压缩解码尺寸到显示尺寸;二是尽量使用 JPEG 而非带透明通道的 PNG,因为透明通道意味着 GPU 那边要比普通不透明纹理多处理一层混合。
4. 列表与滚动场景:把每一帧的支出都花在刀刃上
列表是 Flutter 里最高频的性能场景。一个页面从打开到稳定,90% 的时间都耗在列表的构建和滚动上。优化好列表,整个 App 的卡顿体验能改善大半。
4.1 ListView 懒加载与 itemExtent
Flutter 的 ListView.builder 本身就是懒加载机制的,它只构建当前视口内可见的 item,滚动时复用滑出屏幕的 item。但很多人用得不够仔细,默认的 ListView 不带 builder,直接 Children 列表传入,这就等于把所有 item 一下子全构建了,列表一长性能就崩。
正确的用法是:
// 不推荐:一次性构建全部子项 ListView( children: List.generate(1000, (index) => _buildItem(index)), ) // 推荐:按需构建 ListView.builder( itemCount: 1000, itemBuilder: (context, index) => _buildItem(index), )如果列表里每个 item 的高度都固定,强烈建议加上 itemExtent 或者 prototypeItem。这个参数的作用是告诉 Flutter 列表的每一项高度是多少,这样布局阶段不需要逐个测量 item,可以直接跳算出滚动位置和可见范围,Scroll 的 Layout 开销能省掉很大一部分。
ListView.builder( itemCount: 1000, itemExtent: 80, // 每个 item 高度固定 80 itemBuilder: (context, index) => _buildItem(index), )实测一个消息列表,item 高度固定后,滚动时 UI 线程的 layout 耗时有接近一半的下降。如果一个列表里 item 高度不固定,尽量在设计稿阶段就直接采用固定高度卡片,既省性能又让视觉更整齐。
4.2 滚动过程中的隐藏损耗
列表滚动时除了 item 构建本身,还有几个隐藏损耗很容易被忽略。
第一个是滚动回调。如果一个页面监听 ScrollController 的滚动事件,然后在回调里调用了 setState,那整个页面都会跟着频繁重建。哪怕是只更新一个进度条或者返回顶部按钮的透明度,也应该把这个小组件独立出来,只让它自己在滚动回调里重建,而不是让页面级的 State 参与到每帧的更新里。
// 不推荐:滚动时整页重建 controller.addListener(() { setState(() => _showBackTop = controller.offset > 300); }); // 推荐:用一个独立组件包裹 class _BackTopButton extends StatefulWidget { final ScrollController controller; ... } class _BackTopButtonState extends State<_BackTopButton> { bool _show = false; @override void initState() { super.initState(); widget.controller.addListener(() { final show = widget.controller.offset > 300; if (show != _show) { setState(() => _show = show); } }); } ... }第二个是每个 item 里如果有图片,不要在 itemBuilder 里直接写一个大段复杂布局的嵌套闭包。尽量让 item 自身是一个 static 的 Widget 子类,所有子项都用 const 构造,只有数据变化的部分接受参数。这样滚动复用 item 时,Element 的 diff 成本最小。
第三个是 KeepAlive 的取舍。Flutter 默认会给超出视口的 item 做 keepAlive,方便快速滑回来时不用重新构建,但 keepAlive 的 item 会占更多内存。如果一个列表 item 本身很轻、构建很快,反而没必要把每个都 keepAlive,可以用 addAutomaticKeepAlives: false 关掉自动 keepAlive,减少内存占用。反过来,如果 item 构建成本高(比如包含大型图片、复杂动画),建议保持默认,否则滑回来时会有明显的 build 停顿。
还有一点跟滚动性能有关:在滚动期间避免做耗时同步操作。例如根据滚动位置实时裁剪圆角图片、实时刷新大量阴影,这些都是 GPU 和 CPU 的重复劳动,正常业务里完全可以通过缓存或者延迟更新来优化。
5. 渲染层:RepaintBoundary、阴影与动画性能
代码和列表都优化过了,还有一个经常被忽略的地方是实际绘制阶段的开销。很多页面看起来不复杂,但 GPU 负载很高,就是因为渲染层有些地方没有做隔离,或者使用了昂贵的绘制效果。
5.1 RepaintBoundary:隔离重绘,但别滥用
RepaintBoundary 是一个非常有用的性能工具。它会把子树隔离开,当内部的 RenderObject 需要重绘时,不会强制整页 Layer 树全部重新绘制,只会重绘自己那一块。
典型场景是列表里有一个独立的动画组件,比如一个一直旋转的加载图标。如果它没有被 RepaintBoundary 包裹,它每一帧的变化都会导致整个页面重绘。包上之后,重绘范围被限制在图标这个区域内,页面其他部分完全不受影响:
RepaintBoundary( child: _RotatingIcon(), )但要注意,RepaintBoundary 不是越多越好。每多一个 RepaintBoundary,就多一层独立合成层,意味着多一份 GPU 合成开销,Layer 树也会更庞大。如果一个页面大量滥用 RepaintBoundary,Raster 线程的合成耗时反而会上升,性能变差。
我的习惯是只在两类地方使用:一类是确实存在持续动画、需要隔离重绘的子树;另一类是那种“变一下就要重算整页布局或整个列表”的重型子树,比如定时更新的顶部 Banner 区域加一个,保证它的刷新不影响列表主体。
5.2 阴影、模糊与裁剪:能省则省的 GPU 开销
渲染层面最容易被忽视的 GPU 性能杀手,是阴影、模糊和裁剪效果。
Flutter 里给 Container 加 BoxShadow 是非常方便的操作,但别忘了每一层阴影都是额外的绘制工作。BoxShadow 在部分平台会触发多个绘制 pass,特别是阴影区域覆盖大范围、颜色透明度低的时候,GPU 开销直线上升。实测一个卡片列表,原本每张卡片都有多层阴影,Raster 线程一帧的耗时几乎全部耗在阴影合成上。把阴影改成只给最外层的容器加一圈,子容器只保留透明边框或渐变,帧耗时降了超过一半。
// 避免多层 BoxShadow 叠加 Container( decoration: BoxDecoration( boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.2), blurRadius: 8, offset: Offset(0, 2), ), // 不要随意添加第二层、第三层 shadow ], ), )模糊效果 ImageFilter.blur 或 BackdropFilter 更是重量级,它会在合成阶段对整个背景区域做一次模糊处理,GPU 成本几乎等同于在当前帧再渲染一遍背景层。如果业务里对模糊效果不是特别依赖,建议用半透明遮罩加静态模糊图片代替。实在要用 BackdropFilter,需要保证作用范围尽量小,避免覆盖整个屏幕。
裁剪(Clip)也是一样。ClipRRect、ClipPath 会改变图层的边界,配合透明度变化时非常容易产生离屏渲染。列表卡片需要圆角?直接用包含圆角的 BorderRadius 布局,而不是外面套一层 ClipRRect。特别是 Scroll 列表里的 item,应尽量避免动态裁剪,裁剪区域越小越好。
5.3 动画优化的最后一点细节
Flutter 动画性能优化的核心,是更新范围最小化。下面几条是我在项目里实际踩过坑后总结出来的:
- 不要用 setState 驱动动画帧。动画每一帧都在变化,如果 setState 更新的是整个页面 State,相当于每帧都重建整个页面。正确的做法是用 AnimationController + AnimatedBuilder,或者直接使用 AnimatedContainer、AnimatedOpacity、AnimatedPositioned 这类隐式动画组件。
- 隐式动画组件内部也是在做局部更新的,但注意如果一个页面里有十几个同时运行的隐式动画,建议统一改用显式动画控制器,避免多个独立 Ticker 叠加造成帧率波动。
- 动画里尽量操作 transform、opacity,不要操作布局属性,例如 width、height、padding。变换和透明度走渲染合成路线,性能高;后者会触发重新布局,开销大得多。
- 动画结束后把 controller 释放掉,尤其是页面有多个 Tab 或者频繁在页面间切换时,残留的 Ticker 会持续占用 UI 线程,造成后台掉帧。
6. 量化与分析工具实战
前面这些优化手段,最终都要靠数据验证。如果只凭“感觉流畅了一点”来验收,很容易被误导。Flutter 的性能优化必须建立在量化数据上。
6.1 Flutter DevTools 的常用性能面板
Flutter 自带一套 DevTools,运行项目时按 Shift+F12(或者打开命令行提示里的链接)就能启动。最常用的三个面板:
- Performance:显示逐帧的 Build、Layout、Paint、Raster 耗时。点击任意一帧,可以展开每个阶段的具体耗时分布。很多周期性卡顿在这里一眼就能看出来是不是某个操作恰好对应的帧耗时暴涨。
- Timeline:按时间线展示 UI Thread 和 Raster Thread 事件,适合定位某次具体操作前后发生了什么。
- Memory:监控内存总量和 GC 频率。内存曲线如果频繁出现尖刺且伴随掉帧,多半跟图片缓存、列表缓存、泄漏相关。
另外还有一个非常直观的工具叫 Performance Overlay。通过下面这段代码可以把它显示在 App 上:
class PerformanceOverlayDemo extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( showPerformanceOverlay: true, // 打开性能浮层 home: HomePage(), ); } }开启后屏幕上方会出现两条柱子,上面是 UI 线程耗时,下面是 Raster 线程耗时。绿线表示安全范围,柱子超过绿线就意味着这一帧可能掉帧。这个工具适合在开发阶段快速感知页面是否有明显压力,但别在全量版本里打开。
这里要特别强调一个测试环境问题:很多 Flutter 开发者喜欢在模拟器上做性能测试,但模拟器的渲染走的是宿主 GPU,跟真机差别很大。尤其是 iOS 模拟器和 Android 模拟器对 GPU 的加速策略不同,可能出现模拟器上流畅、真机上卡顿,或者反过来。性能测试必须固定到真机,而且尽量固定到一台低端设备上,这样才能放大问题。
6.2 在真机上建立自己的性能基线
我自己做 Flutter 性能优化时,会先定一个基线流程:
- 选择一台固定的中低端 Android 真机作为基准设备;
- 准备一张独立的测试页面,包含列表、图片、切换动画等实际线上场景;
- 在 Profile 模式下打开 Performance Overlay,记录页面打开、滚动 10 秒、切换 Tab 三次这三组操作的 Build 耗时、Raster 耗时和每帧耗时数据;
- 优化完代码之后,重复同样的操作流程,对比数据。
Profile 模式和 Debug 模式的性能数据是两回事。Debug 模式因为开启了很多检查,build 阶段耗时会被放大几倍,不能用来评估真实性能;Release 模式的物理设备性能又太好,很多问题会被掩盖。Profile 模式是最接近线上体验的测试模式,分析性能问题第一件事就是切换到 Profile 模式,同时把 DebugShowBaseline 之类的东西关掉。
数据记录建议用一个简单的表格,类似:
| 测试项 | 优化前 Build 耗时 | 优化前 Raster 耗时 | 优化后 Build 耗时 | 优化后 Raster 耗时 |
|---|---|---|---|---|
| 首页首屏 | 45ms | 30ms | 18ms | 12ms |
| 列表滚动 10s | 22ms(频繁峰值) | 35ms | 12ms | 15ms |
| 详情页切转 | 60ms | 28ms | 25ms | 14ms |
这个表看起来简单,但它能把“我觉得卡”变成“具体哪一帧慢了、慢在哪一阶段、优化后到底降了多少”,对性能问题跟踪和团队协作都非常有用。
7. 常见问题与排查技巧实录
7.1 常见问题速查表
我把实践中高频碰到的问题整理成了一张速查表,遇到类似场景可以直接对照排查:
| 现象 | 可能原因 | 排查方法 | 处理建议 |
|---|---|---|---|
| 列表快速滑动频繁掉帧 | item 构建成本高、图片解码频繁 | Performance Overlay 观察 UI 线程耗时峰值;定位峰值对应的滚动位置 | item 拆小、加 itemExtent、图片加 cacheWidth、列表图片统一预解码 |
| 页面切换时白屏卡顿 | 目标页面 build 重、图片解码阻塞 | Timeline 查看点击事件到首帧渲染耗时 | 页面关键图片 precacheImage,离屏渲染前提前解码;复杂页面拆分首屏内容 |
| 动画卡顿,持续掉帧 | setState 驱动全局动画、阴影模糊复杂 | 打开 Performance Overlay 看 Raster 柱 | 改用局部 AnimatedBuilder;简化阴影模糊;动画尽量操作 transform/opacity |
| 低端机 App 内存暴涨 | 图片没有限制解码尺寸、缓存无上限 | DevTools Memory 观察曲线高峰 | 全局统一图片组件限制解码尺寸,配置图片缓存策略 |
| 嵌入原生 Map/WebView 后卡 | PlatformView 合成和触摸抢线程 | 切换原生视图时量 Raster 和 UI 线程占用 | 尽量避免高频更新原生视图区域,或将其放入独立页面,减少跟 Flutter 内容混排 |
| 滚动时页面闪烁/白块 | RepaintBoundary 隔离不足或 item 复用状态残留 | 检查滚动中整页重绘还是 item 局部重绘 | 给复杂 item 加 RepaintBoundary,使用 Key 确保 item 复用正确 |
| 刷新列表后卡顿 | 列表 setState 全量数据更新,没有用局部更新 | 检查是否有大面积 ValueNotifier/ Provider 变量更新大范围 widget | 使用 Selector 或拆分独立 StatefulWidget 缩小更新范围 |
这张表不追求覆盖所有情况,重点是给排查提供一个方向入口,基本能帮你在现场节约一到两个小时。
7.2 排查记录实例
讲一个实际排查过程。某次项目中,一个加载用户动态的页面在滑动到大量图文混排位置时,经常持续掉帧到 30FPS 以下。一开始怀疑是 item 内文字渲染复杂,后来发现每次掉帧位置都集中在有封面图的那几个 item 上。
通过 Timeline 记录定位,发现掉帧那一帧的 UI 线程耗时很小,反而是 Raster 线程耗时接近 80ms。再看图片,每张封面图都是服务端返回的大尺寸原图,客户端直接丢进 Image.network,导致每次都解码全尺寸高清图并上传 GPU 纹理。处理方案是给封面图组件加上 cacheWidth,尺寸匹配卡片实际宽度,同时预解码首屏后三张图片。改动后同样的滚动场景 Raster 耗时降到 22ms 左右,列表帧率恢复到满帧。
这个案例比较典型,因为问题根因不在 UI 线程的代码逻辑,而在于图片纹理上传。如果一开始就从“列表 item 优化”和“setState 范围”入手,改了半天也不一定见效。这说明排查性能问题不能只盯着 build 阶段,一定要同时观察 Raster 线程耗时。
综合来看,Flutter UI 性能优化不是某一个单一技巧能解决问题,而是沿着渲染链路逐层找出瓶颈,再针对性地调整。做优化时最好先建立量化基线再动手,改一个点测一次数据,不要凭感觉遍地撒网。我在实际项目里最后养成的习惯是:每合并一个性能相关改动,顺手在基线设备上重跑一次同样的用例,把 Build 和 Raster 耗时同步到测试记录里。这样一来,性能回退很难在版本迭代中悄悄发生。
如果看完这篇文章你也准备做一次性能优化,我建议第一步把 Flutter DevTools 打开,在 Profile 模式下走一遍你们 App 最卡的那个页面。先分清是 UI 线程还是 Raster 线程的瓶颈,再对症下药,你会发现很多之前“感觉无解”的卡顿问题,其实都有非常清晰的解决路径。