12 · 分页与列表
目标:能用通用四层骨架写出一个标准分页列表页,并知道设备模块为什么自建了一套。
⚠️ 硬规范:新增分页列表必须用
PagedListBody+HeListScaffold, 禁止手写EasyRefresh+LoadingStateHandler装配。
开篇:前端概念对照
| 前端概念 | 本项目 |
|---|---|
| 下拉刷新 + 上拉加载(vant List / antd List) | PagedListBody(内部已装配 EasyRefresh) |
loading / finished / error 状态 | PagedState 的 isFetchingMore / hasMore / loadMoreError |
| 骨架屏 | HeListSkeleton |
| 空状态 | HeEmptyState |
| 「没有更多了」尾签 | pagedNoMoreTail(内置) |
| 加载失败重试 | pagedRetryTail(内置) |
| 虚拟滚动 | ListView.separated(PagedListBody 内部) |
| 页面脚手架(导航+搜索+Tab) | HeListScaffold |
一、通用四层骨架
这是新增分页列表页的标准做法,四个文件各司其职:
| 层 | 文件 | 职责 |
|---|---|---|
| 状态 | lib/ui/core/models/paged_state.dart | PagedState<T> 数据快照(freezed) |
| 行为 | lib/ui/core/notifiers/paged_notifier_mixin.dart | PagedNotifierMixin 分页逻辑 |
| 视图 | lib/ui/core/widgets/paged_list_body.dart | PagedListBody<T> 列表 body 基类 |
| 脚手架 | lib/he_components/src/widgets/he_list_scaffold.dart | HeListScaffold 页面外壳 |
注意目录:前三个在
lib/ui/core/下,只有HeListScaffold在lib/he_components/。
二、PagedState<T>:状态契约
lib/ui/core/models/paged_state.dart:17-42:
@freezed
class PagedState<T> with _$PagedState<T> {
const factory PagedState({
@Default([]) List<T> items, // 已加载的累计列表
@Default(1) int page, // 当前页码(从 1 开始)
@Default(true) bool hasMore, // 是否还有下一页
@Default(false) bool isFetchingMore, // 加载更多中(保留旧 items)
Object? loadMoreError, // 加载更多失败(非 null 显示重试尾签)
int? totalCount, // 后端总条数(可选)
}) = _PagedState<T>;
const PagedState._();
/// 首页加载完成后的初始状态
factory PagedState.initial({
required List<T> items,
required bool hasMore,
int? totalCount,
}) => PagedState(items: items, page: 1, hasMore: hasMore, totalCount: totalCount);
}两个设计要点
① loadMoreError 是 Object? 而不是 bool
保留错误对象本身,重试尾签可以展示信息;同时「有错误」和「没错误」用 null 区分。
② isFetchingMore 时不清空 items
加载更多失败或进行中,已加载的数据必须保留——这是良好体验的关键。
三、PagedNotifierMixin:分页行为
lib/ui/core/notifiers/paged_notifier_mixin.dart:28-51:
mixin PagedNotifierMixin<TOrder, TDto>
on AutoDisposeAsyncNotifier<PagedState<TOrder>> {
/// 子类提供:拉取一页 DTO
Future<Result<PageDTO<TDto>>> fetchPage(
int page, {String? keyword, String? status, CancelToken? cancelToken});
/// 子类提供:DTO → UI Model 映射
TOrder mapDto(TDto dto);
}提供的能力(子类免费获得)
| 方法 | 作用 |
|---|---|
initialLoad({statusFilter}) | 首屏加载(build() 里调用) |
setKeyword(kw) | 搜索(重置游标 + 重新拉首页,相同 kw 则 no-op) |
setStatusFilter(status) | 筛选(保留旧数据,不闪骨架屏) |
refresh() | 下拉刷新 |
loadMore() | 上拉加载(失败时回退页码 + 置错误标记) |
hasMore | 是否还有下一页 |
⭐ 两个值得学的细节
① 搜索时取消上一个请求
Future<void> setKeyword(String kw) async {
_keyword = kw;
_cancelToken?.cancel('keyword changed'); // 取消在途请求
_cancelToken = CancelToken();
_page = PageDefaults.defaultPage;
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => _fetchPage(_page, cancelToken: _cancelToken));
}② 加载更多失败时回滚页码并保留列表
} catch (e) {
_page--; // 回退页码
state = AsyncValue.data(current.copyWith(
isFetchingMore: false,
loadMoreError: e, // 置错误标记 → 显示重试尾签
));
}失败不清空列表是这套设计的核心价值。
使用约束
mixin 的 on 约束是 AutoDisposeAsyncNotifier<PagedState<TOrder>>,所以:
- ✅ 适用于
build()无参的 ViewModel - ⚠️ family ViewModel(带参数)无法使用——需手写分页,但逻辑保持一致
四、PagedListBody<T>:列表视图
lib/ui/core/widgets/paged_list_body.dart:30 是 abstract ConsumerWidget。
必须实现的 4 个方法
class _PlantListBody extends PagedListBody<PlantDto> {
const _PlantListBody();
@override
AsyncValue<PagedState<PlantDto>> watchState(WidgetRef ref) =>
ref.watch(plantListViewModelProvider);
@override
Future<void> refresh(WidgetRef ref) =>
ref.read(plantListViewModelProvider.notifier).refresh();
@override
Future<void> loadMore(WidgetRef ref) =>
ref.read(plantListViewModelProvider.notifier).loadMore();
@override
Widget itemBuilder(BuildContext context, PlantDto item, int index) =>
PlantCard(plant: item);
}可选覆盖
| 成员 | 默认值 | 用途 |
|---|---|---|
buildHeader | null | 列表顶部固定栏(筛选栏/表头),loading/error 时也常驻 |
buildListHeader | null | 随列表滚动的头(banner/统计区),仅在 success 态渲染 |
buildLoading | HeListSkeleton() | 首屏骨架 |
useCard | false | 是否白卡包裹(表格形态用 true) |
listPadding | spacingMd/Sm/Md/Xl | 列表内边距 |
itemSpacing | spacingSm | 项间距 |
emptyMessage | l10n.t('tip.noData') | 空态文案 |
setupErrorListener | 空实现 | 注册错误 toast |
⭐ buildHeader vs buildListHeader 的区别
这是容易混淆的一对:
buildHeader | buildListHeader | |
|---|---|---|
| 位置 | 列表外部上方 | ListView 的第一项 |
| 是否随滚动 | ❌ 固定 | ✅ 跟随滚动 |
| loading/error 时 | ✅ 仍显示 | ❌ 不显示(走骨架/缺省) |
| 典型用途 | 筛选栏、表格表头 | banner、统计卡片 |
源码注释(paged_list_body.dart:51-57)明确说明:
与
buildHeader(固定栏)不同:本头渲染在EasyRefresh内ListView第一项,随列表一起滚动 + 下拉刷新(对齐 uni-app scroll-list 内的 banner / 统计区)
自动处理的三态尾签
paged_list_body.dart:127-131 的协调逻辑:
final hasError = data.loadMoreError != null;
final noMore = !hasError && !data.hasMore;
final showFooter = !hasError && !noMore; // == data.hasMore
final tailCount = (hasError || noMore) ? 1 : 0;| 状态 | 表现 |
|---|---|
loadMoreError != null | 隐藏 footer,显示重试按钮尾签 |
!hasMore | 隐藏 footer,显示「没有更多」尾签 |
hasMore | 显示标准 footer,无尾签 |
这些都不用你写,继承即可获得。
五、HeListScaffold:页面外壳
lib/he_components/src/widgets/he_list_scaffold.dart:47,装配了:
HeNavBar(沉浸式渐变 + 标题 + 搜索框 + actions)
└─ [subheader](可选固定栏)
└─ HeTabBar(可选)+ HeKeepAliveTab 保活的 TabBarView
└─ 各 Tab 的 body(推荐继承 PagedListBody)关键参数(回顾 10 章)
HeListScaffold(
title: l10n.t('home.title'),
searchHint: l10n.t('home.searchHint'),
tabs: [...], // 可选
tabBodies: [...], // 与 tabs 等长
body: ..., // tabs == null 时必填
subheader: ...,
onSearch: (kw) => ..., // 共享单 keyword
onTabChange: (i) => ...,
actions: [...],
showBack: true,
)Tab 保活
HeKeepAliveTab 保活各 Tab 的滚动位置与内部 state——本质是阻止 Element 被销毁(呼应 03 章的 Key/Element 知识)。
搜索是共享单 keyword
⚠️ 切 Tab 不清空搜索词。各 Tab 自行决定是否消费该 keyword(通常通过 family key 注入对应 ViewModel)。
六、体系 B:设备模块的自建分页
lib/ui/device/ 没有用四层骨架,而是自建了一套。
自建状态
lib/ui/device/models/device_list_state.dart:
DeviceListState {
items, pageNum, pageSize, total,
loading, refreshing, loadingMore, hasMore, error
}外层还有聚合状态 DeviceListUiState(包含了筛选条件等)。
ViewModel
lib/ui/device/view_models/device_list_view_model.dart:23-36:
@riverpod
class DeviceListViewModel extends _$DeviceListViewModel with DeviceManageMixin {
late DeviceRepository _repo;
CancelToken? _cancelToken;
@override
DeviceListUiState build() {
_repo = ref.watch(deviceRepositoryProvider);
ref.read(sysDictProvider).ensureLoaded();
Future.microtask(refresh);
return const DeviceListUiState();
}
}注意 build() 返回的是同步状态(不是 Future),首次加载用 Future.microtask(refresh) 触发。
为什么设备模块自建?
从代码推断的合理原因(这是历史遗留,不是推荐做法):
- 设备模块有复杂的搜索/筛选草稿-确认语义
- 需要乐观删除等强交互
- 早于通用骨架出现,迁移成本未被消化
⚠️ 选型建议
| 场景 | 用哪套 |
|---|---|
| 新增分页列表页 | ✅ 通用四层骨架 |
| 需要改设备模块 | 沿用其自建状态(保持一致,不要混用) |
不要在新页面模仿体系 B。项目规范明文:新增分页列表必须用 PagedListBody + HeListScaffold。
七、完整示例(可直接照抄)
ViewModel
// lib/ui/plant_alarm/view_models/alarm_list_view_model.dart
part 'alarm_list_view_model.g.dart';
@riverpod
class AlarmListViewModel extends _$AlarmListViewModel
with PagedNotifierMixin<AlarmItem, AlarmDto> {
@override
Future<PagedState<AlarmItem>> build() => initialLoad();
@override
Future<Result<PageDTO<AlarmDto>>> fetchPage(
int page, {
String? keyword,
String? status,
CancelToken? cancelToken,
}) =>
ref.read(alarmRepositoryProvider).getAlarmPage(
AlarmPageRequest(
pageNum: page,
pageSize: PageDefaults.defaultSize,
keyword: keyword,
),
cancelToken: cancelToken,
);
@override
AlarmItem mapDto(AlarmDto dto) => AlarmItem.fromDto(dto);
}页面
// lib/ui/plant_alarm/views/alarm_list_page.dart
class AlarmListPage extends StatelessWidget {
const AlarmListPage({super.key});
@override
Widget build(BuildContext context) {
final l10n = RuntimeI18n.ofNonNull(context);
return HeListScaffold(
title: l10n.t('alarm.title'),
searchHint: l10n.t('alarm.searchHint'),
onSearch: (kw) => ..., // 或由 body 内部处理
body: const _AlarmListBody(),
);
}
}
class _AlarmListBody extends PagedListBody<AlarmItem> {
const _AlarmListBody();
@override
AsyncValue<PagedState<AlarmItem>> watchState(WidgetRef ref) =>
ref.watch(alarmListViewModelProvider);
@override
Future<void> refresh(WidgetRef ref) =>
ref.read(alarmListViewModelProvider.notifier).refresh();
@override
Future<void> loadMore(WidgetRef ref) =>
ref.read(alarmListViewModelProvider.notifier).loadMore();
@override
Widget itemBuilder(BuildContext context, AlarmItem item, int index) =>
AlarmCard(alarm: item);
@override
void setupErrorListener(WidgetRef ref, RuntimeI18n l10n) {
ref.listen(alarmListViewModelProvider, (prev, next) {
showAsyncErrorToast(prev, next, l10n);
});
}
}就这么多——骨架屏、空态、下拉刷新、上拉加载、「没有更多」、失败重试全部自动。
八、禁止事项
| 禁止 | 正确做法 |
|---|---|
手写 EasyRefresh + ListView.builder 装配分页 | 继承 PagedListBody |
自造 items/pageNum/total/hasMore/loading 状态 | 用 PagedState<T> |
| 自造空态 / 骨架屏 | HeEmptyState / HeListSkeleton |
| 加载更多失败时清空列表 | 保留列表 + 置 loadMoreError |
| 新页面模仿设备模块的自建状态 | 用通用四层骨架 |
项目内的反面案例:
add-device-list的device_list_page.dart手写了EasyRefresh+ListView.builder装配,被 AGENTS.md 明文列为禁止模式。
九、自检清单
- [ ] 知道四层骨架各自的文件路径(前三层在
lib/ui/core/,HeListScaffold在he_components/) - [ ] 能实现
PagedListBody的 4 个必需方法 - [ ] 理解
buildHeader(固定)与buildListHeader(随滚动)的区别 - [ ] 知道三态尾签(重试/没有更多/footer)是自动协调的
- [ ] 知道加载更多失败时要保留列表并回退页码
- [ ] 知道新增分页页必须用通用骨架,不模仿设备模块的自建状态
- [ ] 知道
PagedNotifierMixin不适用于 family ViewModel