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
相关产品推荐
相关产品推荐

