Flutter 列表性能优化

0 阅读13分钟

列表卡顿这事,写 Flutter 的基本都遇到过。ListView 滚两下掉帧、图文列表进屏就卡、长列表内存滚着滚着就涨上去了。从 1.x 时代到现在,这类文章写了不少,但里面很多经验已经不好使了:

  • 有的代码在新的 SDK 下编译都过不了(就比如"继承 RenderSliverList 重写渲染"那套);
  • 有的原理本来就讲歪了(比如"加了 const 就能防重绘"这个说法);
  • 有的参数官方已经废弃改名(cacheExtent,现在改叫 scrollCacheExtent)。

本文按 Flutter 3.47把列表性能优化重新捋一遍。

先说一个前提,3.47 开始 Material / Cupertino 从 SDK 里拆出来成了 material_ui / cupertino_ui 两个独立包(1.0 已上架,想迁移的话 dart fix --apply --code=migrate_design_widgets 能自动改 import),不过核心 SDK 里带的 flutter/material.dart 还能正常用,真正要淘汰要等到看下一个版本。所以下面的例子就还是基于 material.dart。另外 Impeller 现在移动端、桌面端都默认了,测出来的滚动表现跟 Skia 会不一样,后面说性能的时候就按这个背景来理解。

一、先别急着优化,想清楚卡在哪

动手前得先有个预算的概念:

  • 60Hz 的屏一帧 16.67ms,可现在的手机基本都是 120Hz 高刷,预算直接砍到 8.33ms 左右。中端机也普遍默认开高刷了,所以网上那些"稳定 60fps 就行"的说法已经过时了。
  • 而且一帧里不光是 Dart 在做 widget 构建和布局,光栅化(Impeller/Raster 线程)也占时间,超了就掉帧。

列表卡顿的来源,其实就四类:

  1. 建得多——ListView(children: [...]) 把上千项一次性构造出来,起手就卡;
  2. 算得慢——item 嵌套五六层、又不给固定高度,每滚进来一个都得重新测量布局;
  3. 画得勤——item 里有持续动画或倒计时,每跳一帧整个可见区跟着重绘;
  4. 占得多——图片不加缓存上限、几千条数据一次全灌进来。

排查主要靠 DevTools 的 Performance 面板。不过有两件事注意:第一,一定要用 flutter run --profile 跑 Profile 模式,debug 模式那数据基本没参考价值(JIT 加一堆断言,开销大得离谱,新手最容易在这上面反复踩);第二,模拟器的帧率别当真,最终以真机为准。看到掉帧以后,点开单帧看耗时是花在 build、layout 还是 raster,再回上面四类去定位。

二、先把三个最常见的坑填了

换个写法:别再用 ListView(children:)

ListView(children: [...]) 会把子项一次性全建出来,这种属于不用多解释的低级错误。改成 ListView.builder 按需构建,是第一步,但很多人就停在这步了——其实还有一件顺手就能做的事:给固定高度。

高度都一样的话,直接给个 itemExtent,滚动时就不用逐个测量每个子项到底多高,这一步省得最多。

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(
      home: Scaffold(
        appBar: AppBar(title: const Text('ListView.builder 懒加载')),
        // 反例:一次性构建 1000 个,初始化就卡
        // body: ListView(
        //   children: List.generate(1000, (i) => ItemTile(index: i)),
        // ),
        body: ListView.builder(
          itemCount: 1000,
          // 高度固定直接给,省掉逐项测量
          itemExtent: 52,
          itemBuilder: (context, index) => ItemTile(index: index),
        ),
      ),
    );
  }
}
​
class ItemTile extends StatelessWidget {
  final int index;
  const ItemTile({super.key, required this.index});
​
  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      child: Align(
        alignment: Alignment.centerLeft,
        child: Text('Item ${index + 1}', style: const TextStyle(fontSize: 16)),
      ),
    );
  }
}

子项高度不一样的话,itemExtent 就用不了,可以看两个替代:

  • prototypeItem:传一个样板 widget,让所有子项按它的高度来。适合那些"结构一样、内容长短不同,但高度被约束死"的 item;
  • itemExtentBuilder(3.35 引入,内部走 SliverVariedExtentList):干脆按 index 直接返回每个具体高度,测量都省了。签名是 (int index, SliverLayoutDimensions dimensions) => double?:
ListView.builder(
  itemCount: items.length,
  // 图文卡片 104,纯文字行 52,按内容给
  itemExtentBuilder: (index, _) => items[index].hasCover ? 104 : 52,
  itemBuilder: (context, index) => FeedTile(item: items[index]),
)

这里有个反直觉的坑得提醒:itemExtent 给的值必须跟 item 实际布局出来的一致,不然会被强行拉伸或裁掉。别说 itemExtent: 52 配一个实际占 80 高的 item,那俩直接打架,最新版本还专门加了 itemExtent 和约束的校验断言,报错会提示。

item 这回别套了

一个"图标 + 两行字"的 entry,有人能整出 Container 套 Container 套 Column 的四五层结构来。每一层嵌套都是一次多余的 layout 传递,滚上一百个 item,光这就有几百次多余计算。

能不手搓就不手搓,现成的组合件直接用:

class FeedTile extends StatelessWidget {
  final String title;
  final String subtitle;
  const FeedTile({super.key, required this.title, required this.subtitle});
​
  @override
  Widget build(BuildContext context) {
    return ListTile(
      leading: const Icon(Icons.article_outlined, color: Colors.blue),
      title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
      subtitle: Text(subtitle, maxLines: 1, overflow: TextOverflow.ellipsis),
    );
  }
}

顺手给几个减负的小习惯:只要背景色就用 ColoredBox,别随手 Container(Container 会带上 padding/margin/decoration 那一堆合并判断);留白用 SizedBox 或者 Padding 的 EdgeInsets,别为了点间距又套一层;ListTile、Card 这些现成组合件内部布局已经优化过,别自己再造轮子。

顺便再解释一个流传挺广的误区:卡顿的源头并不是"SizedBox 比 Container 轻"这么细,真正花销的是层数带来的布局传递,少一层才是实打实省,纠结用哪个组件反而本末倒置。

聊两句 const,它不是你想的那样

不是所有的:"子组件加了 const 构造函数,父组件重绘时子组件就不重绘了。"这话是错的。const 构造函数只是必要前提,真正起作用的是 const 字面量。

原理很简单:用 const 字面量建出来的 widget,Dart 会做规范化(canonicalize),每次 build 拿到的都是同一个实例。Element 做 diff 的时候发现新旧 widget 是 identical,直接跳过整棵子树——不 rebuild、不 relayout、也不 repaint。但如果你只是把构造函数写成 const,调用处没写 const,照样每次 new 一个,一点便宜占不到。

直观验证:

class ConstDemo extends StatefulWidget {
  const ConstDemo({super.key});
  @override
  State<ConstDemo> createState() => _ConstDemoState();
}
​
class _ConstDemoState extends State<ConstDemo> {
  int _count = 0;
​
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('父组件 rebuild 次数:$_count'),
        actions: [
          IconButton(
            icon: const Icon(Icons.refresh),
            onPressed: () => setState(() => _count++),
          ),
        ],
      ),
      body: Column(
        children: const [
          // const 字面量:父组件 setState 时这两项会跳过 rebuild
          _StaticLeaf(),
          _StaticLeaf(),
        ],
      ),
    );
  }
}
​
class _StaticLeaf extends StatelessWidget {
  const _StaticLeaf();
​
  @override
  Widget build(BuildContext context) {
    debugPrint('StaticLeaf build'); // 整个生命周期只该打印一次
    return const Padding(
      padding: EdgeInsets.all(16),
      child: Text('静态子树'),
    );
  }
}

刷新按钮点多少次,控制台都不会多打一条 log。把 children: const [...] 里的 const 去掉再试,每 setState 一次它就打一次。

但这对列表来说意义没想的那么大。ListView.builder 的 itemBuilder 里,ItemTile(index: index) 这种带着动态参数的调用本来就不是 const 表达式,item 滚进屏幕时该重新 build 就得 build——这是正常开销,const 救不了,也不用救。const 真正用得上的是那些死的静态子树:列表里的 icon、分割线、装饰性文字、空态页,放进 const,父级怎么 rebuild 都跟它们没关系。附带的好处是这些规范化实例不产生垃圾对象,几千个 item 滚一天也少些 GC 压力。

至于是不是每个 const 都值得手工去补?没必要,让 IDE 的自动 const 插入(quick fix)处理就行,别把"代码里到处是 const"当成成果。

三、图文、分页、动画这三件具体的事

图文列表:升级到 4.x,再管管图片缓存

图文列表是最容易掉帧的场景之一, cached_network_image ——现在已经 4.x 了(要求 Flutter ≥ 3.44):

dependencies:
  cached_network_image: ^4.0.2
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
​
void main() {
  // 全局内存图片缓存上限。默认 1000 张 / 100MB,图文列表建议主动收紧
  PaintingBinding.instance.imageCache.maximumSize = 300;
  PaintingBinding.instance.imageCache.maximumSizeBytes = 64 << 20; // 64MB
  runApp(const MyApp());
}
​
class ImageTile extends StatelessWidget {
  final String coverUrl;
  final String title;
  const ImageTile({super.key, required this.coverUrl, required this.title});
​
  @override
  Widget build(BuildContext context) {
    return ListTile(
      leading: ClipRRect(
        borderRadius: BorderRadius.circular(8),
        child: CachedNetworkImage(
          width: 72,
          height: 72,
          fit: BoxFit.cover,
          imageUrl: coverUrl,
          // 占位和失败态必须给,不然滚动时一片空白比卡顿还难看
          placeholder: (context, url) => const ColoredBox(color: Color(0xFFEEEEEE)),
          errorWidget: (context, url, error) => const Icon(Icons.broken_image_outlined),
          // 命中磁盘缓存时做一个 200ms 淡入,视觉上抹平解码延迟
          fadeInDuration: const Duration(milliseconds: 200),
        ),
      ),
      title: Text(title, maxLines: 2, overflow: TextOverflow.ellipsis),
    );
  }
}

有两个容易被漏掉的点。

第一,图片内存暴涨的根子往往在内存缓存。ImageCache 默认大概能放 100MB / 1000 张,注意这是解码后的位图,一张 2K 原图解码出来就是十几 MB,没几张就顶满了,之后就是不停踢旧图、重新解码,又是掉帧又是费流量。上面代码收紧上限是一方面,更管用的是服务端配合裁剪尺寸——列表里 72dp 的缩略图,别让接口返回 2K 原图。这一条是我在实际项目里觉得收益最大的,比换加载库管用。

第二,要是列表特别长、图特别多,你对缓存查找那一下的性能较真,社区有个 cached_network_image_ce 分叉,底层把 sqflite 换成了 hive,缓存命中查询快一个量级,API 完全兼容,可以关注。当然原版 4.x 日常用足够。

分页 + 预加载:别再一次性灌千条数据

数据量一大,靠渲染层硬扛是扛不住的,得从数据源头分页。老文那套"ScrollController 监听 + 到阈值就预加载"的思路没过时,就是写法能更现代点:

// Dart 3 records 当轻量 DTO,比 Map<String, String> 强类型也好读
typedef FeedItem = ({String title, String cover});
​
class FeedPage extends StatefulWidget {
  const FeedPage({super.key});
  @override
  State<FeedPage> createState() => _FeedPageState();
}
​
class _FeedPageState extends State<FeedPage> {
  static const _pageSize = 20;
​
  final _items = <FeedItem>[];
  final _controller = ScrollController();
  int _page = 1;
  bool _loading = false;
  bool _hasMore = true;
​
  @override
  void initState() {
    super.initState();
    _loadMore();
    _controller.addListener(() {
      // extentAfter 是剩余可滚动距离,比 maxScrollExtent - pixels 直观
      if (_controller.position.extentAfter < 300) _loadMore();
    });
  }
​
  Future<void> _loadMore() async {
    if (_loading || !_hasMore) return;
    _loading = true;
    try {
      final batch = await _fetchPage(_page);
      if (!mounted) return;
      setState(() {
        _items.addAll(batch);
        _page++;
        _hasMore = batch.length == _pageSize;
      });
    } catch (e) {
      // 失败得留重试的口子,别让 _loading 复位后静默空转
      debugPrint('分页加载失败: $e');
    } finally {
      _loading = false;
    }
  }
​
  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
​
  // 模拟接口,真实项目换你的网络层
  Future<List<FeedItem>> _fetchPage(int page) async {
    await Future<void>.delayed(const Duration(milliseconds: 600));
    return List.generate(
      page > 5 ? 0 : _pageSize,
      (i) => (title: 'Feed ${(page - 1) * _pageSize + i + 1}', cover: ''),
    );
  }
​
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('分页加载')),
      body: ListView.builder(
        controller: _controller,
        itemCount: _items.length + 1, // 尾部留一个 footer 位
        itemBuilder: (context, index) {
          if (index == _items.length) {
            return Padding(
              padding: const EdgeInsets.symmetric(vertical: 16),
              child: Center(
                child: _hasMore
                    ? const CircularProgressIndicator()
                    : const Text('没有更多了'),
              ),
            );
          }
          return ListTile(title: Text(_items[index].title));
        },
      ),
    );
  }
}

几个具体的点:extentAfter < 300 这个提前量跟着你具体情况调,网络慢就提前到 500–800,别让用户滚到底才看到 footer 转圈;catch 里一定要给失败状况兜底,否则网络抖一下,_loading 复位了数据却没进来,用户就干看着转圈;不想持有 ScrollController 的话,NotificationListener<ScrollNotification> 是等价写法,还能顺便拿到滚动方向做"上拉才加载"。

RepaintBoundary:隔离的是动画,不是点击

给"点击变色"的 item 套 RepaintBoundary,说实话收益很小。先纠正个基础认知:ListView.builder 默认 addRepaintBoundaries: true,也就是说每个 item 外层本来就带了一个 RepaintBoundary。所以那种"一个 item 状态变化带动整个列表重绘"的情形,正常配置下根本不会发生——你套不套,边界都在那儿。

RepaintBoundary 真正该手动加的场景只有一个:item 内部有一小块区域在持续高频重绘(倒计时、呼吸点、旋转的 icon、进度条),你不想让这一帧帧的动画带动整个 item 跟着重绘。把动画那小块单独框起来,重绘就锁死在这个边界里:

class LiveTile extends StatefulWidget {
  final String title;
  const LiveTile({super.key, required this.title});
  @override
  State<LiveTile> createState() => _LiveTileState();
}
​
class _LiveTileState extends State<LiveTile>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller = AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 1200),
  )..repeat(reverse: true);
​
  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
​
  @override
  Widget build(BuildContext context) {
    return ListTile(
      // 只框住动画那块,每帧的 repaint 都困在这一小块
      leading: RepaintBoundary(
        child: FadeTransition(
          opacity: _controller,
          child: const DecoratedBox(
            decoration: BoxDecoration(
              color: Colors.green,
              shape: BoxShape.circle,
            ),
            child: SizedBox(width: 10, height: 10),
          ),
        ),
      ),
      // 右边静态内容完全不受动画影响
      title: Text(widget.title),
      subtitle: const Text('直播中 · 静态区域不参与重绘'),
    );
  }
}

想验证的话很简单:把 RepaintBoundary 去掉,用 DevTools 的 repaint 彩虹图看滚动时的重绘范围,整个 item 都会跟着动画闪,加上之后就只剩那个 10×10 的小绿点在闪。

但别滥用。每个边界都要占一块 layer 内存,给每个 icon 都框一层反而亏。原则就那么一条:只隔离高频变化的区域,静态布局交给默认边界就行。

网格列表:SliverGrid 不是"更快",是"能混排"

有个传得很广的错误说法——"SliverGrid 比 GridView 快"。真相是 GridView.builder 内部就是个 SliverGrid,单独用根本没差别。你该上 CustomScrollView + SliverGrid 的唯一理由是:顶部 Banner、中间网格、底下列表这种混排结构,sliver 全家桶能在同一个滚动视图里统一复用,而不是各滚各的嵌套滚动。

class MixedFeedPage extends StatelessWidget {
  const MixedFeedPage({super.key});
​
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Banner + 网格混排')),
      body: CustomScrollView(
        slivers: [
          // 头部 Banner 跟着一起滚、一起复用
          const SliverToBoxAdapter(child: _Banner()),
          SliverPadding(
            padding: const EdgeInsets.all(12),
            sliver: SliverGrid.builder(
              gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
                crossAxisCount: 2,
                crossAxisSpacing: 12,
                mainAxisSpacing: 12,
                // 宽/高比,1.0 是正方形;比例写反会把 item 裁掉,这坑常见
                childAspectRatio: 1.2,
              ),
              itemCount: 200,
              itemBuilder: (context, index) => GridTile(index: index),
            ),
          ),
        ],
      ),
    );
  }
}
​
class _Banner extends StatelessWidget {
  const _Banner();
  @override
  Widget build(BuildContext context) {
    return Container(
      height: 160,
      margin: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: Colors.blue.shade50,
        borderRadius: BorderRadius.circular(12),
      ),
      alignment: Alignment.center,
      child: const Text('Banner 区域'),
    );
  }
}
​
class GridTile extends StatelessWidget {
  final int index;
  const GridTile({super.key, required this.index});
​
  @override
  Widget build(BuildContext context) {
    return ColoredBox(
      color: Colors.grey.shade100,
      child: Center(child: Text('Grid ${index + 1}')),
    );
  }
}

至于选哪个 delegate,这条值得留:每行固定个数用 SliverGridDelegateWithFixedCrossAxisCount;想让 item 宽度自适应、行数随屏宽变就用 SliverGridDelegateWithMaxCrossAxisExtent——平板横屏的时候这个是真救命的。

四、万条数据那点事,别走弯路

别再寻思继承 RenderSliverList 了

以前继承 SliverList / RenderSliverList 去重写渲染逻辑。里面 context as SliverChildManager 这种强转,在新版 SDK 下编译直接报错(不相关类型强转),childManager 那套内部 API 也早就变了。就算你把编译糊弄过去了,"手动重新 layout 可见子项"的思路本身也是错的——sliver 的懒加载协议本来就不会给不可见子项布局,你再重写一遍属于往坑里跳。这条路真不用走。

十万条纯数据,想稳住 60fps,配置就这么多:

ListView.builder(
  itemCount: 100000,
  // 1. 固定高度,测量开销归零,这对长列表是头号功臣
  itemExtent: 48,
  // 2. 3.41 起 cacheExtent 废弃,改用 scrollCacheExtent 控制预渲染区
  //    长列表调大能少出现滚进来的"白块"
  scrollCacheExtent: ScrollCacheExtent.viewport(1),
  // 3. 全是无状态 item 就不需要保活,省掉每个 item 的 keepalive 开销
  addAutomaticKeepAlives: false,
  // 4. 保持默认 true,item 级重绘边界仍然有用
  addRepaintBoundaries: true,
  itemBuilder: (context, index) => Align(
    alignment: Alignment.centerLeft,
    child: Padding(
      padding: const EdgeInsets.symmetric(horizontal: 16),
      child: Text('Item $index', style: const TextStyle(fontSize: 16)),
    ),
  ),
)

关键还是 itemExtent。高度不固定时,sliver 每次滚动都要对入屏的 item 逐个测量;给了定值,整条布局走固定管线,十万条跟一千条在渲染层没啥区别。

但注意,渲染层扛得住不代表内存扛得住。十万条字符串对象全塞进 List 里照样几十 MB,所以正确组合永远是"分页拿数据 + 固定高度渲染"。scrollCacheExtent 这个参数是 3.41 之后出的(旧的 cacheExtent 已标 @Deprecated),ScrollCacheExtent.viewport(1) 意思就是预渲染一整屏,图多、item 重的列表你想保守点就改成 pixels(300),省内存。

AutomaticKeepAliveClientMixin:它是拿内存换状态

以前逻辑是给"前 10 项和后 10 项"选择性保活,是为了省内存。可保活(keepalive)这机制的本质是多占内存,换取 item 滚出屏幕后状态不丢——你用它是为了保状态,不是为了省内存。真要省内存,方向恰恰相反,是关掉它。

什么时候值得保活?item 是 StatefulWidget,滚回来时你不想让它从头再来:视频/音频列表的播放进度、已加载的封面、用户展开的折叠状态、表单草稿。

class VideoTile extends StatefulWidget {
  final String title;
  const VideoTile({super.key, required this.title});
  @override
  State<VideoTile> createState() => _VideoTileState();
}
​
class _VideoTileState extends State<VideoTile>
    with AutomaticKeepAliveClientMixin {
  // 滚出屏幕也保持活着:播放进度、已解码的第一帧都别丢
  @override
  bool get wantKeepAlive => true;
​
  @override
  Widget build(BuildContext context) {
    super.build(context); // 混入的硬性要求,忘了调它保活根本不生效
    return ListTile(
      leading: const Icon(Icons.play_circle_outline, size: 40),
      title: Text(widget.title),
      subtitle: const Text('播放到 03:12 · 状态已保活'),
    );
  }
​
  @override
  void dispose() {
    // 列表整体销毁时才走到这,停播放器、取消图片流都丢在这
    debugPrint('VideoTile ${widget.title} disposed');
    super.dispose();
  }
}

有两个连带的事得说清楚。ListView.builder 默认 addAutomaticKeepAlives: true,意味着每个带 State 的 item 都默认挂了保活机制,wantKeepAlive 返回 true 才真保,false 就跟普通 item 一样销毁。保活的 item 会攒在缓存区,视频、大图这种一多内存就翘——这时候就在列表级别把 addAutomaticKeepAlives 关掉,或者用 scrollCacheExtent 把缓存区压小,只让真正需要保状态的少数 item 自己开 wantKeepAlive。

再一个,dispose 里的清理(停动画、cancel 图片流、释放播放器)是内存兜底的最后一环。AutomaticKeepAlive 只保证"活着的时候不销毁你",保证不了"销毁的时候你已经把自己收拾干净"。所以这种脏活别依赖框架,自己在 dispose 里干。

五、踩坑速查

这几条是我觉得最值得记住的,按重要性排:

  • 别用 ListView(children:) 铺大数据,换 builder,再尽量给固定高度(itemExtent / itemExtentBuilder)。测量开销才是大头。
  • "const 防重绘"是个误会,只有 const 字面量才 canonicalize;带参数的 itemBuilder 该 build 就 build,const 管的是死静态子树。
  • 图片列表内存涨,一条对的方向:cached_network_image 4.x + 收紧 ImageCache 上限 + 服务端按尺寸裁剪,别让列表返回原图。
  • cacheExtent 已经废了,3.41 起用 scrollCacheExtent: ScrollCacheExtent.viewport(n)。
  • item 里有动画卡,别包整个列表,每个 item 本来就有默认重绘边界,手动边界只框动画那块。
  • 万条数据卡,不用搞底层重写(那套在新版连编译都过不了),itemExtent + 分页 + 关掉 keepalive 够了。
  • 保活是拿内存换状态,想省内存正好相反,能关就关。
  • shrinkWrap: true 慎用——它会让内部列表失去懒加载,先把所有子项量一遍再渲染。嵌套滚动老老实实用 NestedScrollView 或者平铺 sliver。
  • 性能验证别在 debug 模式糊弄,--profile + 真机,模拟器数据不算。

收尾

列表优化抠到底还是那三件事:少建、少算、少画。做法上按顺序来就行。

先把基础的三样做了——builder 懒加载、固定高度、布局精简,大部分列表到这一步就不卡了。真有图文、分页、动画的具体场景再逐个对症下药。万条数据这种,别一上来就寻思底层重写,原生配置拉满加个分页,已经是现在这个生态里的标准做法。

每改完一步,用 Profile 模式跑一遍 DevTools Performance 验证下收效,别凭感觉。另外有个省事的思路:Flutter 大版本升级本身就会带来一批性能变化(比如 Impeller 推开了之后滚动体验明显不一样),所以项目每次升完 SDK,顺手做一轮列表回归。很多你以为是"玄学卡顿"的问题,换个新版大版本就自己好了。