想象一下,你的用户正在沉浸式地刷着信息流,内容精彩纷呈,多巴胺正在分泌,突然——咣当——他们触底了。一个灰色的圈在转动。魔法中断了,体验“出戏”了。
在高质量的 App 中,“无限滚动(Infinite Scroll)”不应该仅仅在逻辑上是无限的,它在体验上也应该是无限的。我们不应该等用户撞了南墙才回头,我们应该预判他们的行为。
今天,我们要干掉那个加载圈。我们将探讨 Flutter 中列表预加载的演进之路:从手动实现的“苦力法”,到生产环境可用的 “低侵入” 方案。
(🐶 我承认有些圈的确能够让人赏心悦目)
1. 经典方案:ScrollController
检测用户是否接近底部的最直观方法,就是去问 ScrollController。
1.1 原始实现(样板代码)
核心逻辑就是简单的数学计算。监听滚动偏移量,如果 当前位置 + 阈值 >= 总长度,就触发加载。
标准代码如下所示。它能跑,但它让你的 Widget 变得乱糟糟:
class MyFeedPage extends StatefulWidget {
@override
_MyFeedPageState createState() => _MyFeedPageState();
}
class _MyFeedPageState extends State<MyFeedPage> {
final ScrollController _controller = ScrollController();
@override
void initState() {
super.initState();
_controller.addListener(() {
// 1. 检查是否触底(预留 600px 的缓冲空间)
if (_controller.position.pixels >=
_controller.position.maxScrollExtent - 600) {
// 2. 调用加载数据的逻辑
_loadMoreData();
}
});
}
@override
void dispose() {
_controller.dispose(); // 别忘了销毁!
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListView.builder(
controller: _controller, // 你必须手动绑定它
itemBuilder: (ctx, i) => ListTile(title: Text("Item $i")),
);
}
}
1.2 重构:使用 Mixin 解耦
上面的代码有什么问题?重复。 如果你有 10 个列表页面,你就得把这套逻辑写 10 遍。
我们可以将其提取到一个 Mixin 中。这样不仅保持了 UI 代码的整洁,还能让任何 StatefulWidget 瞬间获得“分页能力”。
mixin PaginationMixin<T extends StatefulWidget> on State<T> {
final ScrollController scrollController = ScrollController();
void _onScroll() {
// 在这里统一管理阈值逻辑(例如:600px)
if (scrollController.position.extentAfter < 600) {
onLoadMore();
}
}
// 强制使用该 Mixin 的类实现具体的加载逻辑
void onLoadMore();
@override
void initState() {
super.initState();
scrollController.addListener(_onScroll);
}
@override
void dispose() {
scrollController.dispose();
super.dispose();
}
}
现在好多了。但这仍然强迫你的 UI 去持有并绑定一个 ScrollController。如果你是在一个嵌套视图中,很难直接控制 Controller 怎么办?
2. 纯粹方案:ScrollNotification
Flutter 提供了一个冒泡事件系统,叫做 ScrollNotification。这允许我们在不管理 Controller 实例的情况下监听滚动事件。
2.1 原始实现
我们可以用 NotificationListener 包裹列表:
NotificationListener<ScrollNotification>(
onNotification: (notification) {
// 筛选滚动更新事件
if (notification is ScrollUpdateNotification) {
// 检查剩余距离(保持一致的 600px 阈值)
if (notification.metrics.extentAfter < 600) {
_preloadNextPage();
}
}
return false; // 让通知继续向上冒泡
},
child: ListView.builder(...),
)
2.2 重构:组件封装
虽然这种方式很灵活,但把逻辑写在 build 方法里违反了整洁架构原则。它把**布局(Layout)和业务逻辑(Business Logic)**混在一起了。
让我们把它封装成一个专用的 PreloadDetector 组件:
class PreloadDetector extends StatelessWidget {
final Widget child;
final VoidCallback onTrigger;
final double threshold;
const PreloadDetector({
required this.child,
required this.onTrigger,
this.threshold = 600, // 默认为 600px
});
@override
Widget build(BuildContext context) {
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification.metrics.extentAfter < threshold) {
onTrigger();
}
return false;
},
child: child,
);
}
}
很干净。但是,你依然需要手动处理 状态防卫(State Guarding) (例如:“如果正在加载中则不触发”或者“如果没数据了则不触发”)。
3. “低侵入”方案:scroll_preload_detector
不像上述两种方法都需要你手动维护状态逻辑。
我最近使用了一个库 scroll_preload_detector,之所以用它,是因为一个关键特性:低侵入 。
什么是“低侵入”?
这意味着你现有的代码可以保持原样:
- 不需要把
StatelessWidget改为StatefulWidget。 - 不需要注入
ScrollController。 - 不需要修改你的
ListView或itemBuilder。
3.1 清爽的实现
这个库将计算、防抖处理和状态防卫封装成了一个声明式的 API。
import 'package:scroll_preload_detector/scroll_preload_detector.dart';
ScrollPreloadDetector(
// 当剩余内容不足 600px 时开始加载
preloadDistance: 600.0,
// 如果数据已耗尽,不要触发
// 这自动防止了不必要的 API 调用
hasMore: () => _viewModel.hasMoreData,
// 自动处理异步逻辑
preload: () async {
await _viewModel.fetchMoreData();
},
child: ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => MyCard(items[index]),
),
)
为什么这是“更合理”的方案:
- 安全性: 它强制你定义
hasMore,解决了 App 在列表底部不断尝试请求无效数据的常见 Bug。 - 整洁性: 它分离了布局的职责(你的 List)和分页的职责(Detector)。
- 通用性: 因为它是包裹在 Widget 外层的,所以它适用于
GridView、SliverList、CustomScrollView或任何可滚动的组件。
🤝 让我们一起共建
Flutter 生态系统的美妙之处在于,我们不必独自解决这些问题。scroll_preload_detector 是一个小而美的工具,它简化了我们的日常工作。
如果你厌倦了在每个项目里写重复的 addListener 样板代码:
- 在你的下一个 App 中试用它。
- 在 Pub.dev 上给它点个 Star。
- 如果你发现了边缘情况,提交一个 PR!
让我们彻底告别 Loading 转圈。
感谢阅读!
如果你觉得这篇技术指南对你有帮助,请点赞鼓励。