Flutter 分页列表页面实现指南
在移动端开发中,分页列表是最常见的场景之一。本文将分享一套经过实践检验的通用分页列表实现模式
一、架构分层
清晰的架构分层是代码可维护性的基础。我们采用三层架构:
UI 层(ui 包)
XxxView ← 纯 UI,watch provider,触发 init/loadMore
状态层(ckzl_core 包)
XxxController ← Notifier,管理分页状态,调用 Service
XxxState ← 不可变状态对象
数据层(ckzl_core 包)
XxxService ← HTTP 封装,分页查询接口
职责划分:
· UI 层:只负责展示和用户交互,不包含业务逻辑
· 状态层:管理页面状态,处理分页逻辑,调用数据层接口
· 数据层:封装网络请求,与后端 API 交互
二、State 设计
状态对象应该是不可变的,这样可以保证状态变化的可预测性:
dart
class XxxState {
final bool isLoading; // 首次加载中
final bool isLoadingMore; // 加载更多中
final List<XxxData> items;
final int pageNum; // 当前页码
final int pageSize; // 每页数量
final int total; // 总记录数
final bool hasMore; // 是否还有更多
final String? error; // 错误信息
XxxState({
this.isLoading = false,
this.isLoadingMore = false,
this.items = const [],
this.pageNum = 1,
this.pageSize = 20,
this.total = 0,
this.hasMore = true,
this.error,
});
XxxState copyWith({...}) {...}
}
💡 提示:使用 copyWith 方法可以方便地创建新状态实例,这是不可变对象的常见模式。
三、Controller 核心实现
Controller 负责状态管理和业务逻辑,核心是 init() 和 loadMore() 两个方法:
方法 说明
init() 重置状态,从第1页重新加载(下拉刷新也调这个)
loadMore() 加载下一页,hasMore + isLoadingMore 双重防重
dart
class XxxController extends Notifier<XxxState> {
@override
XxxState build() => XxxState();
Future<void> init() async {
state = state.copyWith(
isLoading: true,
pageNum: 1,
items: [],
hasMore: true,
);
await _fetch(pageNum: 1, refresh: true);
}
Future<void> loadMore() async {
if (!state.hasMore || state.isLoadingMore) return;
state = state.copyWith(isLoadingMore: true);
await _fetch(pageNum: state.pageNum + 1, refresh: false);
}
Future<void> _fetch({required int pageNum, required bool refresh}) async {
final resp = await XxxService.instance.listXxx(
pageNum: pageNum,
pageSize: state.pageSize,
);
if (resp.isSuccess && resp.data != null) {
final newItems = resp.data!.items;
state = state.copyWith(
isLoading: false,
isLoadingMore: false,
items: refresh ? newItems : [...state.items, ...newItems],
pageNum: pageNum,
total: resp.data!.total,
hasMore: newItems.length >= state.pageSize,
);
} else {
state = state.copyWith(
isLoading: false,
isLoadingMore: false,
error: resp.msg,
);
}
}
}
关键点说明:
· 双重防重:hasMore 确保还有数据时才能继续加载,isLoadingMore 防止重复请求
· 分页判断:返回数据量 >= pageSize 则继续分页,否则认为已无更多数据
· 错误处理:将错误信息保存到 state,UI 层可以据此展示错误提示
四、Provider 注册
使用 Riverpod 的 NotifierProvider 注册 Controller:
dart
final xxxControllerProvider = NotifierProvider<XxxController, XxxState>(
XxxController.new,
);
五、UI 页面实现
5.1 基础结构
dart
class XxxView extends ConsumerStatefulWidget {
const XxxView({super.key});
@override
ConsumerState<XxxView> createState() => _XxxViewState();
}
class _XxxViewState extends ConsumerState<XxxView> {
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(xxxControllerProvider.notifier).init();
});
}
// 滚动监听:距底部 200px 时触发加载更多
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
ref.read(xxxControllerProvider.notifier).loadMore();
}
}
@override
Widget build(BuildContext context) {
final state = ref.watch(xxxControllerProvider);
return Scaffold(
body: RefreshIndicator(
onRefresh: () async => ref.read(xxxControllerProvider.notifier).init(),
child: _buildBody(state),
),
);
}
}
5.2 列表主体(CustomScrollView + Sliver)
推荐使用 CustomScrollView + Sliver 组合,这样可以灵活控制列表的各个部分:
dart
Widget _buildBody(XxxState state) {
if (state.isLoading && state.items.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (state.error != null && state.items.isEmpty) {
return Center(child: Text('加载失败: ${state.error}'));
}
return CustomScrollView(
controller: _scrollController,
slivers: [
// 主列表区域 - 根据布局选择 SliverGrid 或 SliverList
SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 4,
mainAxisSpacing: 4,
),
delegate: SliverChildBuilderDelegate(
(context, index) => _buildItem(state.items[index]),
childCount: state.items.length,
),
),
// 加载更多 Loading
if (state.isLoadingMore)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
),
),
// 没有更多提示
if (!state.hasMore && state.items.isNotEmpty)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: Center(child: Text('没有更多了')),
),
),
],
);
}
六、完整流程图
Service Controller UI User Service Controller UI User alt [有更多数据且未加载中] [无更多数据或加载中] 进入页面/下拉刷新 init() 重置状态 listXxx(pageNum=1) 返回数据 更新 state 渲染列表 滚动到底部 loadMore() listXxx(pageNum+1) 返回数据 追加数据 渲染新增项 忽略请求
七、复用新页面的步骤
当你需要实现一个新的分页列表页面时,只需按照以下步骤操作:
步骤 操作 说明
1 新建 XxxState 包含 isLoading / isLoadingMore / items / pageNum / hasMore / error 等字段
2 新建 XxxController 实现 init() / loadMore() / _fetch() 方法
3 新建 Service 方法 返回 ApiResponse,响应体包含 items + total
4 新建 UI 页面 添加 ScrollController 监听滚动,RefreshIndicator 下拉刷新,CustomScrollView 渲染列表
5 注册 Provider 使用 NotifierProvider 注册 Controller
八、常见问题与优化建议
8.1 空状态处理
当列表为空时,建议显示空状态占位图:
dart
if (state.items.isEmpty && !state.isLoading) {
return const Center(child: EmptyStateWidget(message: '暂无数据'));
}
8.2 错误重试
在错误状态中提供重试按钮:
dart
if (state.error != null && state.items.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('加载失败: ${state.error}'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.read(xxxControllerProvider.notifier).init(),
child: const Text('重试'),
),
],
),
);
}
8.3 滚动优化
· 设置 cacheExtent 可以提前缓存列表项,提升滚动流畅度
· 对于图片列表,建议配合缓存框架(如 cached_network_image)使用
结语
这套分页列表模式经过多个项目的验证,具备良好的复用性和可维护性。核心思想是状态与 UI 分离、分页逻辑统一封装,新页面只需替换数据模型和 Service 即可快速实现。