Skip to content

12 · 分页与列表

目标:能用通用四层骨架写出一个标准分页列表页,并知道设备模块为什么自建了一套。

⚠️ 硬规范:新增分页列表必须PagedListBody + HeListScaffold禁止手写 EasyRefresh + LoadingStateHandler 装配。


开篇:前端概念对照

前端概念本项目
下拉刷新 + 上拉加载(vant List / antd List)PagedListBody(内部已装配 EasyRefresh
loading / finished / error 状态PagedStateisFetchingMore / hasMore / loadMoreError
骨架屏HeListSkeleton
空状态HeEmptyState
「没有更多了」尾签pagedNoMoreTail(内置)
加载失败重试pagedRetryTail(内置)
虚拟滚动ListView.separatedPagedListBody 内部)
页面脚手架(导航+搜索+Tab)HeListScaffold

一、通用四层骨架

这是新增分页列表页的标准做法,四个文件各司其职:

文件职责
状态lib/ui/core/models/paged_state.dartPagedState<T> 数据快照(freezed)
行为lib/ui/core/notifiers/paged_notifier_mixin.dartPagedNotifierMixin 分页逻辑
视图lib/ui/core/widgets/paged_list_body.dartPagedListBody<T> 列表 body 基类
脚手架lib/he_components/src/widgets/he_list_scaffold.dartHeListScaffold 页面外壳

注意目录:前三个在 lib/ui/core/ 下,只有 HeListScaffoldlib/he_components/


二、PagedState<T>:状态契约

lib/ui/core/models/paged_state.dart:17-42

dart
@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);
}

两个设计要点

loadMoreErrorObject? 而不是 bool

保留错误对象本身,重试尾签可以展示信息;同时「有错误」和「没错误」用 null 区分。

isFetchingMore 时不清空 items

加载更多失败或进行中,已加载的数据必须保留——这是良好体验的关键。


三、PagedNotifierMixin:分页行为

lib/ui/core/notifiers/paged_notifier_mixin.dart:28-51

dart
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是否还有下一页

⭐ 两个值得学的细节

① 搜索时取消上一个请求

dart
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));
}

② 加载更多失败时回滚页码并保留列表

dart
} 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 个方法

dart
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);
}

可选覆盖

成员默认值用途
buildHeadernull列表顶部固定栏(筛选栏/表头),loading/error 时也常驻
buildListHeadernull随列表滚动的头(banner/统计区),仅在 success 态渲染
buildLoadingHeListSkeleton()首屏骨架
useCardfalse是否白卡包裹(表格形态用 true
listPaddingspacingMd/Sm/Md/Xl列表内边距
itemSpacingspacingSm项间距
emptyMessagel10n.t('tip.noData')空态文案
setupErrorListener空实现注册错误 toast

buildHeader vs buildListHeader 的区别

这是容易混淆的一对:

buildHeaderbuildListHeader
位置列表外部上方ListView第一项
是否随滚动❌ 固定✅ 跟随滚动
loading/error 时✅ 仍显示❌ 不显示(走骨架/缺省)
典型用途筛选栏、表格表头banner、统计卡片

源码注释(paged_list_body.dart:51-57)明确说明:

buildHeader(固定栏)不同:本头渲染在 EasyRefreshListView 第一项,随列表一起滚动 + 下拉刷新(对齐 uni-app scroll-list 内的 banner / 统计区)

自动处理的三态尾签

paged_list_body.dart:127-131 的协调逻辑:

dart
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 章)

dart
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

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

dart
@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) 触发。

为什么设备模块自建?

从代码推断的合理原因(这是历史遗留,不是推荐做法):

  1. 设备模块有复杂的搜索/筛选草稿-确认语义
  2. 需要乐观删除等强交互
  3. 早于通用骨架出现,迁移成本未被消化

⚠️ 选型建议

场景用哪套
新增分页列表页✅ 通用四层骨架
需要改设备模块沿用其自建状态(保持一致,不要混用)

不要在新页面模仿体系 B。项目规范明文:新增分页列表必须用 PagedListBody + HeListScaffold


七、完整示例(可直接照抄)

ViewModel

dart
// 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);
}

页面

dart
// 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-listdevice_list_page.dart 手写了 EasyRefresh+ListView.builder 装配,被 AGENTS.md 明文列为禁止模式。


九、自检清单

  • [ ] 知道四层骨架各自的文件路径(前三层在 lib/ui/core/HeListScaffoldhe_components/
  • [ ] 能实现 PagedListBody 的 4 个必需方法
  • [ ] 理解 buildHeader(固定)与 buildListHeader(随滚动)的区别
  • [ ] 知道三态尾签(重试/没有更多/footer)是自动协调的
  • [ ] 知道加载更多失败时要保留列表并回退页码
  • [ ] 知道新增分页页必须用通用骨架,不模仿设备模块的自建状态
  • [ ] 知道 PagedNotifierMixin 不适用于 family ViewModel

下一步

13 · 路由与导航