You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter如何基于StreamBuilder与Firestore实现分页懒加载

Firestore 配合 StreamBuilder 实现分页懒加载

你现在全量拉取+嵌套ListView的写法本身就有两个性能问题:一是一次性拉取全量文档请求慢、内存占用高,二是内层ListView开了shrinkWrap会一次性构建所有列表项,哪怕没滑到屏幕里也会渲染,自然容易卡顿。
直接按下面的逻辑改,每次固定拉10条,滑到底自动加载下一页:

  • 核心用Firestore的startAfterDocument做分页游标,每次查询只拿指定数量的文档
  • 加ScrollController监听滚动位置,快滑到底的时候提前触发下一页加载,加锁避免重复请求
  • 本地维护已加载的文档列表、分页游标、加载状态,用StreamController把数据推给StreamBuilder
  • 去掉冗余的嵌套ListView,改用ListView.builder做列表项的懒构建,只渲染屏幕内可见的元素,进一步降低内存占用

完整改造代码

class FeedJob extends StatefulWidget {
  FeedJob({Key? key}) : super(key: key);

  @override
  _FeedJobState createState() => _FeedJobState();
}

class _FeedJobState extends State<FeedJob> {
  // 每页加载条数
  static const int _pageSize = 10;
  // 滚动监听控制器
  final ScrollController _scrollController = ScrollController();
  // 本地维护已加载的feed文档
  List<DocumentSnapshot> _feedList = [];
  // 分页游标:最后一条已加载的文档
  DocumentSnapshot? _lastDoc;
  // 是否还有更多数据可加载
  bool _hasMore = true;
  // 加载锁,防止重复触发请求
  bool _isLoading = false;
  // 流控制器,给StreamBuilder推送数据
  final _streamController = StreamController<List<DocumentSnapshot>>.broadcast();

  // 加载第一页
  Future<void> _loadFirstPage() async {
    _isLoading = true;
    try {
      final firstPage = await FirebaseFirestore.instance
          .collection("job_feed")
          .orderBy("time", descending: true)
          .limit(_pageSize)
          .get();
      _feedList = firstPage.docs;
      _lastDoc = _feedList.isNotEmpty ? _feedList.last : null;
      _hasMore = firstPage.docs.length == _pageSize;
      _streamController.add(_feedList);
    } catch (err) {
      _streamController.addError(err);
    } finally {
      _isLoading = false;
    }
  }

  // 加载下一页
  Future<void> _loadNextPage() async {
    if (!_hasMore || _isLoading || _lastDoc == null) return;
    _isLoading = true;
    try {
      final nextPage = await FirebaseFirestore.instance
          .collection("job_feed")
          .orderBy("time", descending: true)
          .startAfterDocument(_lastDoc!)
          .limit(_pageSize)
          .get();
      _feedList.addAll(nextPage.docs);
      _lastDoc = nextPage.docs.isNotEmpty ? nextPage.docs.last : null;
      _hasMore = nextPage.docs.length == _pageSize;
      _streamController.add(_feedList);
    } catch (err) {
      _streamController.addError(err);
    } finally {
      _isLoading = false;
      if (mounted) setState(() {});
    }
  }

  @override
  void initState() {
    super.initState();
    // 监听滚动,距离底部200px时提前触发加载,体验更流畅
    _scrollController.addListener(() {
      final maxScroll = _scrollController.position.maxScrollExtent;
      final currentScroll = _scrollController.position.pixels;
      if (currentScroll >= maxScroll - 200) {
        _loadNextPage();
      }
    });
    // 首屏加载第一页
    _loadFirstPage();
  }

  @override
  void dispose() {
    _scrollController.dispose();
    _streamController.close();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: StreamBuilder<List<DocumentSnapshot>>(
        stream: _streamController.stream,
        builder: (context, snapshot) {
          // 首屏加载状态
          if (!snapshot.hasData && !snapshot.hasError) {
            return const Center(child: CircularProgressIndicator());
          }
          // 加载错误状态
          if (snapshot.hasError) {
            return const Center(child: Text("加载失败,请稍后重试"));
          }
          final docs = snapshot.data!;
          return Column(
            children: [
              const TiTle(title: "Feeds"),
              Expanded(
                child: ListView.builder(
                  controller: _scrollController,
                  // 列表长度=已加载数据+1个底部状态位
                  itemCount: docs.length + 1,
                  itemBuilder: (context, index) {
                    // 渲染底部状态
                    if (index == docs.length) {
                      if (!_hasMore) {
                        return const Padding(
                          padding: EdgeInsets.all(16),
                          child: Center(
                            child: Text("没有更多内容了", style: TextStyle(color: Colors.grey, fontSize: 12)),
                          ),
                        );
                      }
                      return const Padding(
                        padding: EdgeInsets.all(16),
                        child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
                      );
                    }
                    // 渲染feed卡片
                    final item = docs[index];
                    return Padding(
                      padding: const EdgeInsets.all(8),
                      child: Card(
                        child: Column(
                          children: [
                            ListTile(
                              leading: Container(
                                width: 40,
                                height: 40,
                                alignment: Alignment.topCenter,
                                decoration: BoxDecoration(
                                    image: DecorationImage(
                                        alignment: Alignment.topCenter,
                                        fit: BoxFit.cover,
                                        image: CachedNetworkImageProvider(item.get('photo'))),
                                    color: Colors.blue.withOpacity(.2),
                                    borderRadius: const BorderRadius.all(Radius.circular(20))),
                              ),
                              trailing: Column(
                                children: [
                                  const Icon(Icons.comment_rounded, size: 15, color: Colors.grey),
                                  Text("comment".tr, style: const TextStyle(fontSize: 8, color: Colors.grey))
                                ],
                              ),
                              title: Text(item.get('name'), style: const TextStyle(color: Colors.black, fontSize: 10, fontWeight: FontWeight.bold)),
                              subtitle: Text(item.get('time').toString(), style: const TextStyle(fontSize: 8, color: Colors.grey)),
                            ),
                            Padding(
                              padding: const EdgeInsets.only(left: 5, right: 8, bottom: 15),
                              child: Text(
                                item.get('description'),
                                textAlign: TextAlign.justify,
                              ),
                            )
                          ],
                        ),
                      ),
                    );
                  },
                ),
              ),
            ],
          );
        },
      ),
    );
  }
}

补充说明

  • 如果需要做下拉刷新,只要在ListView外层套RefreshIndicator,刷新时重置_feedList、_lastDoc、_hasMore状态,重新调用_loadFirstPage()即可。
  • 如果你需要实时同步已加载文档的变更(比如已加载的feed被编辑、删除),可以把第一页的单次get请求换成snapshots()监听,后续分页加载的文档单独追加到列表即可,不需要监听全量集合。
  • 不要在可滚动组件里嵌套shrinkWrap的ListView/GridView,这种写法会一次性渲染所有子项,完全失去列表懒加载的性能优势,是Flutter列表卡顿的常见诱因。

内容的提问来源于stack exchange,提问作者bricend96

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.09.03 02:39:23