复杂列表性能优化:卡顿定位与修复
系列:UI 与交互篇 第 2/6 篇
Flutter 性能优化 列表渲染
1. 问题背景:业务场景 + 现象
业务里最容易卡顿的页面,通常不是炫技动画页,而是「看起来最普通」的复杂列表页:
- 房间列表 / 排行榜列表,单项结构复杂(头像、徽章、状态、倒计时、动画点缀)
- 同屏元素多,滚动时需要频繁重建
- 列表项中有网络图、阴影、渐变、裁剪、富文本
- 进入页面首次加载慢,滚动掉帧,快速滑动时明显“粘手”
常见用户反馈是:
“页面能用,但不丝滑;低端机特别明显。”
2. 原因分析:核心原理 + 排查过程
复杂列表的性能问题,通常不是单点 bug,而是多个小问题叠加:
- Build 过重:列表项
build做了太多计算、格式化、状态判断。 - Rebuild 范围过大:局部变化导致整项甚至整屏重建。
- Paint 成本高:阴影、透明层叠、圆角裁剪、
saveLayer触发过多。 - 图片解码与尺寸不匹配:原图过大,滚动时解码压力高。
- 缓存策略不当:重复请求、重复布局、离屏缓存不合理。
排查建议按这条线走:
- 先开
Performance Overlay看 FPS 与 Raster 时间 - 再用 DevTools Timeline 看是 UI 线程慢 还是 GPU 栅格慢
- 接着用
debugProfileBuildsEnabled定位高频重建组件 - 最后逐项做 A/B 验证,不要一次改十个点
3. 解决方案:方案对比 + 最终选择
方案对比
-
方案 A:直接重写页面 UI
- 优点:快刀斩乱麻
- 缺点:风险大,回归成本高,不可持续
-
方案 B:按链路分层优化(推荐)
- 构建优化(减少 build)
- 绘制优化(减少昂贵 paint)
- 资源优化(图片/缓存)
- 交互优化(分页/预加载节奏)
- 优点:可量化、可回滚、可复用
最终选择(实践顺序)
- 先把列表项拆成稳定子组件,缩小重建范围
- 移除高成本视觉效果(非关键阴影、过度裁剪)
- 图片按显示尺寸解码,避免大图硬塞
- 分页加载 + 骨架屏,不在同一帧做太多事
- 每一步都记录帧耗时和掉帧率,确认收益再继续
4. 关键代码:最小必要代码片段
4.1 列表项拆分 + const 化,减少无效重建
class RoomListItem extends StatelessWidget {
const RoomListItem({
super.key,
required this.room,
required this.onTap,
});
final RoomVO room;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Row(
children: [
_RoomAvatar(url: room.cover), // 独立子组件
const SizedBox(width: 8),
Expanded(child: _RoomMeta(room: room)),
_OnlineCount(count: room.onlineCount),
],
),
);
}
}
4.2 局部刷新:只监听必要字段
class OnlineCountText extends ConsumerWidget {
const OnlineCountText({super.key, required this.roomId});
final String roomId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(roomProvider(roomId).select((e) => e.onlineCount));
return Text('$count 人在线');
}
}
4.3 图片按尺寸解码,降低解码与内存压力
Widget buildRoomImage(String url) {
return Image.network(
url,
fit: BoxFit.cover,
cacheWidth: 320, // 按实际显示宽高给
cacheHeight: 180,
filterQuality: FilterQuality.low,
);
}
4.4 避免不必要的 keepAlive 与离屏开销
ListView.builder(
addAutomaticKeepAlives: false,
addRepaintBoundaries: true, // 默认 true,通常建议保留
itemCount: items.length,
itemBuilder: (context, index) => RoomListItem(
room: items[index],
onTap: () => onClick(items[index]),
),
);
5. 效果验证:数据/截图/日志
建议用同一台测试机、同一数据量做前后对比,记录这 4 个指标:
- 首屏可交互时间(TTI)
- 快速滑动平均 FPS
UI/Raster平均帧耗时- 内存峰值(滚动 30 秒)
示例(一次真实可复现的优化结果):
- 首屏可交互:
920ms -> 610ms - 平均 FPS:
46 -> 57 - UI 帧耗时 P95:
22ms -> 13ms - Raster 帧耗时 P95:
18ms -> 11ms - 掉帧率:
12.4% -> 3.1%
日志建议统一格式,便于团队复盘:
[list_perf] scene=room_list device=mid_android
before: fps=46 ui_p95=22ms raster_p95=18ms jank=12.4%
after : fps=57 ui_p95=13ms raster_p95=11ms jank=3.1%
6. 可复用结论:通用经验 + 避坑清单
通用经验
- 先定位瓶颈归属(Build / Layout / Paint / Decode),再动手改代码。
- 列表优化优先级:重建范围 > 绘制成本 > 资源解码 > 微调参数。
- 每次只做一类优化,保留前后数据,避免“玄学优化”。
- 视觉效果要有预算:阴影、模糊、裁剪不是不能用,是要在关键区域用。
避坑清单
- 列表项里做复杂字符串拼接、时间格式化、JSON 转换
- 一个状态变化导致整行整页重建
- 每项都有
ClipRRect + BoxShadow + Opacity叠加 - 图片原图超大且不设
cacheWidth/cacheHeight - 未分页,首帧一次性灌入大量数据
- 优化后没量化数据,只凭“体感更快”下结论
下一篇会写:动画体系:隐式动画到自定义动画,重点讲「哪些动画该上、哪些动画该砍」,以及如何在体验和性能之间拿到平衡。