UI 与交互篇(2/6):复杂列表性能优化:卡顿定位与修复

5 阅读4分钟

复杂列表性能优化:卡顿定位与修复

系列:UI 与交互篇 第 2/6 篇

Flutter 性能优化 列表渲染

1. 问题背景:业务场景 + 现象

业务里最容易卡顿的页面,通常不是炫技动画页,而是「看起来最普通」的复杂列表页:

  • 房间列表 / 排行榜列表,单项结构复杂(头像、徽章、状态、倒计时、动画点缀)
  • 同屏元素多,滚动时需要频繁重建
  • 列表项中有网络图、阴影、渐变、裁剪、富文本
  • 进入页面首次加载慢,滚动掉帧,快速滑动时明显“粘手”

常见用户反馈是:
“页面能用,但不丝滑;低端机特别明显。”


2. 原因分析:核心原理 + 排查过程

复杂列表的性能问题,通常不是单点 bug,而是多个小问题叠加:

  1. Build 过重:列表项 build 做了太多计算、格式化、状态判断。
  2. Rebuild 范围过大:局部变化导致整项甚至整屏重建。
  3. Paint 成本高:阴影、透明层叠、圆角裁剪、saveLayer 触发过多。
  4. 图片解码与尺寸不匹配:原图过大,滚动时解码压力高。
  5. 缓存策略不当:重复请求、重复布局、离屏缓存不合理。

排查建议按这条线走:

  • 先开 Performance Overlay 看 FPS 与 Raster 时间
  • 再用 DevTools Timeline 看是 UI 线程慢 还是 GPU 栅格慢
  • 接着用 debugProfileBuildsEnabled 定位高频重建组件
  • 最后逐项做 A/B 验证,不要一次改十个点

3. 解决方案:方案对比 + 最终选择

方案对比

  • 方案 A:直接重写页面 UI

    • 优点:快刀斩乱麻
    • 缺点:风险大,回归成本高,不可持续
  • 方案 B:按链路分层优化(推荐)

    • 构建优化(减少 build)
    • 绘制优化(减少昂贵 paint)
    • 资源优化(图片/缓存)
    • 交互优化(分页/预加载节奏)
    • 优点:可量化、可回滚、可复用

最终选择(实践顺序)

  1. 先把列表项拆成稳定子组件,缩小重建范围
  2. 移除高成本视觉效果(非关键阴影、过度裁剪)
  3. 图片按显示尺寸解码,避免大图硬塞
  4. 分页加载 + 骨架屏,不在同一帧做太多事
  5. 每一步都记录帧耗时和掉帧率,确认收益再继续

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. 可复用结论:通用经验 + 避坑清单

通用经验

  1. 先定位瓶颈归属(Build / Layout / Paint / Decode),再动手改代码。
  2. 列表优化优先级:重建范围 > 绘制成本 > 资源解码 > 微调参数
  3. 每次只做一类优化,保留前后数据,避免“玄学优化”。
  4. 视觉效果要有预算:阴影、模糊、裁剪不是不能用,是要在关键区域用。

避坑清单

  • 列表项里做复杂字符串拼接、时间格式化、JSON 转换
  • 一个状态变化导致整行整页重建
  • 每项都有 ClipRRect + BoxShadow + Opacity 叠加
  • 图片原图超大且不设 cacheWidth/cacheHeight
  • 未分页,首帧一次性灌入大量数据
  • 优化后没量化数据,只凭“体感更快”下结论

下一篇会写:动画体系:隐式动画到自定义动画,重点讲「哪些动画该上、哪些动画该砍」,以及如何在体验和性能之间拿到平衡。