列表掉帧时,先别急着怪 Widget

很多团队看到 Flutter 列表页卡一下,第一反应都是 item 太复杂、图片太大,或者 ListViewSliver 没写对。真实 Android 项目里,另一个很常见但不够显眼的元凶是接口解析:主 isolate 先做 UTF-8 解码,再做 jsonDecode,接着做模型映射、字段兜底和分页合并。列表还没开始真正渲染,UI 线程已经被这一段 Dart 计算占满,结果就是首屏进入卡、下拉刷新卡、切后台再回来也卡。

这篇文章写给已经有 Flutter 列表页、分页接口和基础 profile 排查能力的团队。目标不是把所有解析都机械丢进 isolate,而是把判断顺序收口成一条可复用流程:什么时候该怀疑接口解析,什么时候继续留在主 isolate,Repository 边界怎么拆,如何用日志、测试和指标验证改动确实减少了卡顿,以及失败时应该退回到什么更保守的方案。

适用场景:先确认问题在“接口解析”,不在“渲染树”

先说结论:只有当你能把问题缩到“数据一到,本页就掉帧”,才值得动 isolate。适用场景通常有四类。第一类是首页或推荐流的首包明显偏大,接口返回经常超过 100KB,甚至带多层嵌套、富文本片段和冗余字段。第二类是列表骨架屏已经很轻,图片先全部替换成纯色占位后仍然会卡。第三类是进入列表页时不卡,点击“刷新”或切换筛选条件时更容易卡,这往往意味着卡顿发生在解析和合并状态,而不是首帧布局。第四类是性能叠加图里 UI 图先红,而 GPU 图并没有持续爆红,说明主要矛盾在 Dart 代码,而不是绘制复杂度。

真正开始改代码前,先跑一遍最小排查步骤。把应用用 profile 模式拉起来,打开 Performance Overlay 或 DevTools Performance view,再录一段“进入列表页 -> 首包返回 -> 首次可交互”的时间线。如果红条集中出现在接口完成后的那一小段,而且 CPU profiler 里 jsonDecode、模型工厂或列表映射占比很高,就已经足够说明这篇文章的方向是对的。相反,如果 GPU 图更红、滚动时才卡、或者图片解码线程爆满,那就该优先查渲染和资源,而不是 isolate。

文中的命令和 API 按 2026-08 可查到的稳定版 Flutter 与 Dart 文档组织,正文示例以 Flutter stable 3.47、Dart 3.13、Android 14 或更高版本的 profile 调试场景为基线。你不一定要升级到完全相同的版本,但至少要确认项目里已经可以稳定使用 Isolate.runcompute、DevTools Performance view 和 CPU profiler。

步骤一:先把基线采集做对,不要只靠“手感”

排查大接口响应导致的列表卡顿,最怕的是只凭肉眼说“好像快了一点”。更稳的做法是同时留三类证据:时间线、应用日志和最小测试。时间线用来确认 UI 线程是否还在掉帧;日志用来记录接口大小、解析耗时和分页合并耗时;测试用来保证你把解析搬出去以后,没有把空字段、坏数据或分页顺序一起搬坏。

可以先保留一层最轻的测量包装。这里不需要一开始就接完整埋点系统,用 dart:developerStopwatch 已经够了。重点不是把日志打得很花,而是保证你每次刷新列表时,都能看到“请求结束”“开始解析”“解析完成”“状态提交”这几个节点。只要耗时能稳定重现,后面的优化就不会漂。

import 'dart:developer' as dev;

Future<T> traceStep<T>(String label, Future<T> Function() action) async {
  final task = dev.TimelineTask();
  final watch = Stopwatch()..start();
  task.start(label);
  try {
    return await action();
  } finally {
    watch.stop();
    task.finish(arguments: {'elapsedMs': watch.elapsedMilliseconds});
    dev.log('$label ${watch.elapsedMilliseconds}ms', name: 'FeedRepository');
  }
}

如果你当前连“这一包到底有多大”都不知道,先别急着上 isolate。很多项目的问题根本不是并发,而是接口字段没有裁掉、服务端默认把未使用的嵌套结构一起返回、或者客户端在收到分页数据后又做了多次 map -> copyWith -> sort。这些问题即使搬到后台 isolate,也只是把浪费换个地方跑。

步骤二:目录和边界先定清楚,别在页面里临时起线程

这类优化最容易写坏的地方,是开发者直接在 Widget 或 Controller 里临时塞一个 compute(),然后把 BuildContexthttp.Response、缓存实例甚至插件对象一起传过去。这样短期看像是“把卡顿搬走了”,长期却会把边界搞乱:测试不好写,错误不好接,后续分页、重试和本地缓存也没地方扩展。

更稳的目录做法,是把“原始响应 -> 领域模型”的责任收进 data 层,让页面只处理状态切换。下面这个结构足够应付大多数列表页:

lib/
  features/feed/data/feed_repository.dart
  features/feed/data/feed_parser.dart
  features/feed/domain/feed_item.dart
  features/feed/presentation/feed_controller.dart

feed_repository.dart 只负责请求、阈值判断和调用解析器;feed_parser.dart 只做纯函数解析,不碰 UI、不读 rootBundle、不调插件;feed_controller.dart 只负责触发加载、丢弃过期结果和提交状态。这样做的好处是,后面你无论要从 Isolate.run 换回主 isolate,还是把大包阈值从 120KB 改成 256KB,改动都会集中在 Repository,不会把页面层一起扯乱。

步骤三:关键实现是把“解码 + 映射”一起移走,而不是只搬半截

很多文章只演示把 jsonDecode(String) 放进 compute()。这对入门案例已经够用,但在真实列表页里经常还差半步:如果你先在主 isolate 上拿到 response.body,实际上 UTF-8 解码已经发生过一次了;如果响应体很大,这一段成本本身就可能让 UI 图冒红。更稳的办法是直接使用 bodyBytes,把字节数组和模型映射一起交给后台 isolate。

import 'dart:convert';
import 'dart:isolate';
import 'dart:typed_data';

import 'package:http/http.dart' as http;

class FeedRepository {
  FeedRepository(this._client);

  final http.Client _client;
  static const int inlineBytesLimit = 120 * 1024;

  Future<List<FeedItem>> fetchFirstPage() async {
    final response = await _client.get(
      Uri.parse('https://api.example.com/feed?page=1'),
    );

    if (response.statusCode != 200) {
      throw FeedRequestException(response.statusCode);
    }

    final bytes = response.bodyBytes;
    if (bytes.length < inlineBytesLimit) {
      return parseFeedItems(bytes);
    }

    final transferable = TransferableTypedData.fromList([bytes]);
    return Isolate.run(() => parseFeedItemsFromTransferable(transferable));
  }
}

List<FeedItem> parseFeedItems(Uint8List bytes) {
  final jsonText = utf8.decode(bytes);
  final root = jsonDecode(jsonText) as Map<String, Object?>;
  final rawItems = (root['items'] as List<Object?>? ?? const [])
      .cast<Map<String, Object?>>();

  return rawItems
      .map(FeedItem.fromJson)
      .toList(growable: false);
}

List<FeedItem> parseFeedItemsFromTransferable(
  TransferableTypedData transferable,
) {
  final buffer = transferable.materialize();
  return parseFeedItems(buffer.asUint8List());
}

这里有三个实现细节值得保留。第一,用阈值判断而不是全量搬运。小接口响应的解析成本可能还不如起一个 isolate 的管理成本,盲目全搬只会让低端机和高端机都多做一次调度。第二,把 TransferableTypedData 作为大包路径,只在确实够大时使用,避免每次都引入额外复杂度。第三,解析函数一定保持纯函数化,这样单元测试才容易补,回滚也容易做。

如果你的项目已经在用 compute(),也不必为了形式强行重写。Flutter 文档已经说明,在移动端和桌面端,compute(fun, message) 可以看作 Isolate.run(() => fun(message)) 的等价工作流。差异更多体现在你要传什么消息、是否想把 UTF-8 解码也搬出去,以及当前团队更容易维护哪种写法。就这篇文章的场景来说,列表接口的大包解析更适合 byte-based 路径,因为它能把“字符串化之前”的成本也一起隔离。

步骤四:页面层别等旧结果回写,过期请求要主动丢

把解析丢到后台 isolate 以后,另一个经常暴露的问题是“旧结果回写”。用户先切筛选 A,再切筛选 B;A 的请求慢一点,但解析已经在后台跑;等 B 的结果先回来并渲染成功后,A 又晚到一步把状态覆盖,页面就会出现列表闪回、滚动位置错乱,甚至看起来像随机 bug。这个问题不是 isolate 特有,但 isolate 让耗时更分散,所以更容易被放大。

页面层至少要保留一个请求版本号,旧请求回来时直接丢弃,不再提交状态。这样即使后台解析已经开始,也不会把过期数据重新写进列表状态。

class FeedController extends ChangeNotifier {
  FeedController(this._repository);

  final FeedRepository _repository;
  int _requestVersion = 0;
  FeedState state = const FeedState.loading();

  Future<void> refresh() async {
    final currentVersion = ++_requestVersion;
    final items = await traceStep('feed.refresh', () {
      return _repository.fetchFirstPage();
    });

    if (currentVersion != _requestVersion) {
      return;
    }

    state = FeedState.loaded(items);
    notifyListeners();
  }
}

这一步也是失败边界的一部分:如果你的业务需要用户切页时“最新请求一定赢”,就做版本号;如果需要“先到先展示,但不覆盖已选条件”,就把筛选参数一起编码进状态键;如果后端会返回巨量历史数据,那应该先缩接口字段和分页粒度,而不是单纯靠客户端线程切换硬扛。

验证:至少留下命令、日志、测试和可解释指标

发布前的验证不要只写一句“滚动更顺了”。更可靠的做法是把命令、日志和验收目标写进团队清单。本文推荐保留下面三条命令:

flutter --version
flutter run --profile
flutter test test/feed/feed_parser_test.dart

flutter run --profile 是为了看接近真实的帧时间和 CPU 行为;调试模式本身会引入额外开销,拿它来判断列表卡顿,结论很容易漂。运行后先在 DevTools 打开 Performance view,再录一段“首包返回后的 3 到 5 秒”。如果优化有效,至少应该满足三个验收目标:第一,列表首次可交互阶段不再连续出现 UI 红条;第二,CPU profiler 里解析函数不再占住用户开始滚动前的主要时间片;第三,Repository 日志能稳定打出响应大小和解析耗时,后续回归时能直接对照。

单元测试别省。因为你把解析从页面层抽出来后,最适合补的就是 parser test。比如空数组、缺字段、异常字段类型、超大响应体和分页合并顺序,都应该有最小样例。下面这类测试就足够实用:

void main() {
  test('parseFeedItems returns typed items', () {
    final bytes = Uint8List.fromList(
      utf8.encode('{"items":[{"id":"1","title":"hello"}]}'),
    );

    final result = parseFeedItems(bytes);
    expect(result.single.id, '1');
    expect(result.single.title, 'hello');
  });
}

如果你已经在做启动阶段或主线程基线,建议把这篇优化和站内这篇相关文章一起看:Flutter Android 冷启动怎么排查:profile 模式、首帧基线与主线程减负清单。启动和列表解析不是同一个问题,但两者都依赖你先把 UI 线程上到底跑了什么看清楚。

避坑:这四类情况最容易把 isolate 用成“复杂但没收益”

第一,Web 不是这篇文章的主战场。Flutter 文档已经明确写到,Dart web 平台不支持真正的 isolates,compute() 在 web 上会在主线程执行,所以不要把移动端的测量结果直接套到 web 上。第二,后台 isolate 不能碰 rootBundledart:ui 和任何 Widget/UI 工作;解析函数里一旦掺进这些调用,错误会很绕。第三,不要把 http.ResponseFuture、数据库连接或插件实例整个传过去,消息图越复杂,序列化和边界问题越多。第四,TransferableTypedData 是单次物化资源,同一份数据不要重复 materialize(),否则后续维护者很难排查为什么只在某条路径炸。

还有一个常见误区是“既然 isolate 能把卡顿挪走,那所有列表都应该默认上”。这通常不成立。小接口、低频页面或只在 Wi-Fi 下偶发的大包场景,很可能根本不值得引入额外线程和测试复杂度。更稳的做法,是先记录响应大小和解析耗时,再根据阈值切换。让优化跟着证据走,而不是跟着焦虑走。

复盘清单:这次改动交付的到底是什么

如果你准备把这类优化落进团队规范,发布前至少复盘六件事:是否确认卡顿主要发生在 UI 线程;是否记录了接口大小、解析耗时和状态提交耗时;是否把解析边界收进 Repository;是否为小包保留了主 isolate 快路径;是否补了 parser 测试和过期请求丢弃策略;以及是否给下一位接手的人留下了“什么时候不要用 isolate”的边界说明。

做到这里,这篇改造的交付物就不是一句“列表顺了”,而是一套能重复执行的排查与实现路径。下一篇如果还要继续进阶,最自然的方向不是再写一篇“列表优化技巧大全”,而是把同一条链路往离线缓存、一致性和分页回放继续推进:也就是列表数据不只要解析得快,还要在失败重试和本地回放时保持行为可解释。