理解ListView.builder懒加载的问题

案例

代码

import 'package:flutter/material.dart';
import 'package:pull_to_refresh_flutter3/pull_to_refresh_flutter3.dart';
import 'dart:io';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(home: RefreshDemoPage());
  }
}

class RefreshDemoPage extends StatefulWidget {
  const RefreshDemoPage({super.key});

  @override
  State<RefreshDemoPage> createState() => _RefreshDemoPageState();
}

class _RefreshDemoPageState extends State<RefreshDemoPage> {
  final RefreshController _refreshController = RefreshController();

  List<int> items = List.generate(20, (i) => i);

  Future<void> _onRefresh() async {
    await Future.delayed(const Duration(seconds: 2));
    setState(() {
      items = List.generate(20, (i) => i);
    });
    _refreshController.refreshCompleted();
  }

  Future<void> _onLoading() async {
    await Future.delayed(const Duration(seconds: 2));
    setState(() {
      items.addAll(List.generate(10, (i) => items.length + i));
      debugPrint('---------加载了10个新元素,items 长度 ${items.length}---------');
    });
    _refreshController.loadComplete();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('模拟第15项构建很慢')),
      body: SmartRefresher(
        controller: _refreshController,
        enablePullDown: true,
        enablePullUp: true,
        onRefresh: _onRefresh,
        onLoading: _onLoading,
        header: const WaterDropHeader(),
        footer: const ClassicFooter(),
        child: ListView.builder(
          itemCount: items.length,
          itemBuilder: (context, index) {
            debugPrint('开始构建 item $index, items 长度 ${items.length}');

            if (index == 15) {
              // ⚠️ 故意卡住主线程 10 秒钟
              sleep(const Duration(seconds: 10));
            }

            return Container(
              height: 80,
              alignment: Alignment.center,
              color: index == 15 ? Colors.red[200] : Colors.blue[100],
              child: Text('Item $index', style: const TextStyle(fontSize: 20)),
            );
          },
        ),
      ),
    );
  }
}

程序刚启动

日志如下:

Restarted application in 1,837ms.
I/flutter ( 8681): 开始构建 item 0, items 长度 20
I/flutter ( 8681): 开始构建 item 1, items 长度 20
I/flutter ( 8681): 开始构建 item 2, items 长度 20
I/flutter ( 8681): 开始构建 item 3, items 长度 20
I/flutter ( 8681): 开始构建 item 4, items 长度 20
I/flutter ( 8681): 开始构建 item 5, items 长度 20
I/flutter ( 8681): 开始构建 item 6, items 长度 20
I/flutter ( 8681): 开始构建 item 7, items 长度 20
I/flutter ( 8681): 开始构建 item 8, items 长度 20
I/flutter ( 8681): 开始构建 item 9, items 长度 20
I/flutter ( 8681): 开始构建 item 10, items 长度 20
I/flutter ( 8681): 开始构建 item 11, items 长度 20
I/flutter ( 8681): 开始构建 item 0, items 长度 20
I/flutter ( 8681): 开始构建 item 1, items 长度 20
I/flutter ( 8681): 开始构建 item 2, items 长度 20
I/flutter ( 8681): 开始构建 item 3, items 长度 20
I/flutter ( 8681): 开始构建 item 4, items 长度 20
I/flutter ( 8681): 开始构建 item 5, items 长度 20
I/flutter ( 8681): 开始构建 item 6, items 长度 20
I/flutter ( 8681): 开始构建 item 7, items 长度 20
I/flutter ( 8681): 开始构建 item 8, items 长度 20
I/flutter ( 8681): 开始构建 item 9, items 长度 20
I/flutter ( 8681): 开始构建 item 10, items 长度 20
I/flutter ( 8681): 开始构建 item 11, items 长度 20

预构建

在屏幕上,我只能看到Item7,但是日志显示已经渲染到item11了。

这是因为Flutter 的渲染机制会「预构建屏幕外的子项

  • ListView.builder 是懒加载的,但会在可视区域上下都「多渲染一点」
  • 这样做是为了让用户滚动更平滑
  • 所以虽然屏幕只显示到 item 7,但实际上item 8~11 已经预先构建了,只是暂时不在屏幕上显示。

这个「提前构建」的数量叫 cacheExtent(缓存范围)

  • 默认值是 250.0 像素
  • 意味着距离当前可视区 250 像素以内的 item 也会被提前构建

测量构建

这是 Flutter 在首次渲染时的布局测量过程造成的:

  1. 第一次构建时,Flutter 不知道 ListView 需要多高
  2. 它会 先快速试着构建一遍所有可见项(甚至多构建一些),计算它们的尺寸
  3. 知道尺寸后,再 进行一次真正的布局渲染,因此你会看到打印了两轮

这个行为是 SliverList 的特性:首次 layout 时会进行一次「测量构建」,测完再来一遍正式构建。

限制缓存范围

可以手动限制缓存范围,例如:

child: ListView.builder(
  itemCount: items.length,
  cacheExtent: 0, // 禁止预加载
  itemBuilder: ...
)

这样首次就只会构建屏幕上真正可见的那些 item 了。

拉到15个Item

会停在item7这里,要等着预构建完成,才会允许向下滚动。

加载的时候

I/flutter ( 8681): 开始构建 item 12, items 长度 20
I/flutter ( 8681): 开始构建 item 13, items 长度 20
I/flutter ( 8681): 开始构建 item 14, items 长度 20
I/flutter ( 8681): 开始构建 item 15, items 长度 20
I/flutter ( 8681): 开始构建 item 16, items 长度 20
I/flutter ( 8681): 开始构建 item 17, items 长度 20
I/flutter ( 8681): 开始构建 item 18, items 长度 20
I/flutter ( 8681): 开始构建 item 19, items 长度 20
I/flutter ( 8681): ---------加载了10个新元素,items 长度 30---------
I/flutter ( 8681): 开始构建 item 9, items 长度 30
I/flutter ( 8681): 开始构建 item 10, items 长度 30
I/flutter ( 8681): 开始构建 item 11, items 长度 30
I/flutter ( 8681): 开始构建 item 12, items 长度 30
I/flutter ( 8681): 开始构建 item 13, items 长度 30
I/flutter ( 8681): 开始构建 item 14, items 长度 30
I/flutter ( 8681): 开始构建 item 15, items 长度 30
I/flutter ( 8681): 开始构建 item 16, items 长度 30
I/flutter ( 8681): 开始构建 item 17, items 长度 30
I/flutter ( 8681): 开始构建 item 18, items 长度 30
I/flutter ( 8681): 开始构建 item 19, items 长度 30
I/flutter ( 8681): 开始构建 item 20, items 长度 30
I/flutter ( 8681): 开始构建 item 21, items 长度 30
I/flutter ( 8681): 开始构建 item 22, items 长度 30
I/flutter ( 8681): 开始构建 item 23, items 长度 30

为什么不是「整页白屏重构」

由于执行了setState,Flutter会自动在执行Widget build(BuildContext context)方法,进而ListView.builder也从新执行了,重新构建了ListView

但 Flutter 会复用已有元素树,只增量构建新增的 item,不会整页重绘,因此整个页面还停留在item 13item 20的区间。

这是 Flutter 的 「Widget 是描述,Element 是持久化」 的机制

层级 说明 是否会重建
Widget 轻量配置对象(build 返回的) 每次 build 都会新建
Element Widget 的持久化实例,负责管理生命周期和状态 尽量复用,不会频繁销毁
RenderObject 负责绘制和布局的底层对象 尽量复用,不会频繁销毁
  • ListView.builder 本身没有被销毁,只是重新 build 出一份新的 Widget 描述

  • ListView 内部已经存在的 Element / RenderObject 会被复用

  • 只有新增加的那 10 个 item 会被创建新的元素树

  • 所以:页面不会整页闪烁、白屏,只是局部 diff 更新

ListView的缓存区间

ListView.builder 只会懒加载(lazy build)当前屏幕附近的元素。当你滚动到 item13--item20 附近时,屏幕上实际可见的是 item13--item20,屏幕上方的 9--12 早就构建过、但可能被 SliverList 回收了

当调用 setState 更新 items 数组长度从 20 → 30 时:

  • ListView重新计算可视区域的元素索引
  • 它会根据当前滚动位置,重新构建一段「缓存区间」的 item,比如从 item 9 开始,一直构建到 23,确保你向上/向下滚动都有数据可以马上显示。
┌─────────────────────────────────────┐
│            屏幕外(已回收)            │
│ item 0   item 1   ...   item 8       │
├─────────────────────────────────────┤
│         缓存区 (预构建区域)          │
│ item 9   item 10  ...  item 12       │
├─────────────────────────────────────┤
│         可见区域 (当前屏幕)          │
│ item 13  item 20                     │
├─────────────────────────────────────┤
│         缓存区 (预构建区域)          │
│ item 21  item 22  item 23            │
├─────────────────────────────────────┤
│            屏幕外(未构建)            │
│ item 24  ...  item 29                │
└─────────────────────────────────────┘
  • 可见区域:就是当前屏幕能看到的那些 item。

  • 缓存区(预构建区域):在可见区域的前后各有一段不可见区域,框架会提前把这些 item 构建出来并保留在内存中。

    • 向上缓存:保证你往上快速滚动时不会卡顿。
    • 向下缓存:保证你往下快速滚动时不会卡顿。
  • 屏幕外区域:离得太远的 item 不会保留,会被销毁,等再次滚动回来时再重建。

  • 可以通过 cacheExtent 参数来控制缓存区的长度(单位是像素),例如:

    ListView.builder(
      cacheExtent: 500, // 缓存前后各 500 像素范围内的 item
      itemBuilder: ...
    )
    

    注意:cacheExtent 控制的是距离而不是item数量,实际缓存多少个 item 取决于 item 的高度

总结:ListView.builder 的懒加载机制

按需构建(on-demand build)

  • 只会为当前屏幕可见区域 + 缓存区域内的 item 调用 itemBuilder
  • 未滚动到的 item 不会提前构建

缓存区域(cacheExtent)

  • 在可见区域的上下各有一段缓存区域,会预先构建 item。
  • 默认值约为一屏的长度,可以通过 cacheExtent 自定义。

回收与重建(reuse/rebuild)

  • 离开缓存区的 item 会被销毁释放内存。
  • 滚动回来时会重新调用 itemBuilder 重建。

按需增量构建

  • 随着滚动,逐步加载更多 item,不会一次性构建整个列表,提高性能。

无限列表支持

  • 因为不会一次性创建所有 item,所以可以处理无限或超大列表

×

喜欢就点赞,疼爱就打赏