前言
在移动应用开发中,分页加载和缓存策略是两个绕不开的核心问题。用户期望 App 启动时能立刻看到内容,同时又希望数据是最新的。本文将带你实现一套完整的「SWR(Stale-While-Revalidate)」分页缓存方案。
最终效果:
· 首次打开 → 显示 Loading
· 第二次打开 → 立即显示缓存,后台静默更新
· 上拉加载更多 → 同样支持缓存优先 + 后台更新
整体架构
┌─────────────────────────────────────────────────────┐
│ UI Layer │
│ DemoPage (纯展示组件,不依赖任何状态管理) │
└─────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ Adapter Layer │
│ DemoScreen (Riverpod Consumer,桥接数据与 UI) │
└─────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ Business Layer │
│ NatureNotifier/CityNotifier/AnimalNotifier │
│ (继承 PaginatedNotifier,定义各自的数据源) │
└─────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ Infrastructure Layer │
│ PaginatedNotifier<T> (通用分页缓存基类) │
│ - SharedPreferences 按页存储 │
│ - SWR 缓存策略 │
│ - 并发安全的数据替换 │
└─────────────────────────────────────────────────────┘
第一步:通用分页基类
1.1 状态定义
dart
// paginated_notifier.dart
class PaginatedState<T> {
final List<T> items; // 数据列表
final int currentPage; // 当前页码
final bool hasMore; // 是否还有更多
final bool isLoading; // 首次加载中
final bool isRefreshing; // 后台刷新中
final bool isLoadingMore; // 加载更多中
final String? error; // 错误信息
final bool fromCache; // 数据是否来自缓存
const PaginatedState({
this.items = const [],
this.currentPage = 0,
this.hasMore = true,
this.isLoading = false,
this.isRefreshing = false,
this.isLoadingMore = false,
this.error,
this.fromCache = false,
});
// copyWith 方法...
}
1.2 核心基类实现
dart
abstract class PaginatedNotifier<T> extends Notifier<PaginatedState<T>> {
// 必须实现的 getter
PageFetcher<T> get fetcher; // 网络请求方法
String? get cacheKey => null; // 缓存 Key 前缀
ToJson<T>? get toJson => null; // 序列化
FromJson<T>? get fromJson => null; // 反序列化
// 可配置项
int get pageSize => 20;
Duration get cacheTtl => const Duration(hours: 1);
// 按页缓存读写
Future<List<T>?> _readPageCache(int page) async { /* ... */ }
Future<void> _writePageCache(int page, List<T> items) async { /* ... */ }
/// 刷新(下拉刷新)
Future<void> refresh({bool forceNetwork = false}) async {
const page = 1;
// 尝试读缓存
if (!forceNetwork) {
final cached = await _readPageCache(page);
if (cached != null && cached.isNotEmpty) {
// 有缓存:立即展示,后台静默刷新
state = PaginatedState(
items: cached,
currentPage: page,
hasMore: true,
isRefreshing: true,
fromCache: true,
);
_silentRefreshPage(page, replace: true);
return;
}
}
// 无缓存:全屏 Loading
state = state.copyWith(isLoading: true, clearError: true);
try {
final (items, hasMore) = await fetcher(page, pageSize);
await _writePageCache(page, items);
state = PaginatedState(
items: items,
currentPage: page,
hasMore: hasMore,
fromCache: false,
);
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
}
}
/// 加载更多
Future<void> loadMore() async {
if (!state.hasMore || state.isLoadingMore || state.isLoading) return;
final nextPage = state.currentPage + 1;
final cached = await _readPageCache(nextPage);
if (cached != null && cached.isNotEmpty) {
// 有缓存:先追加,后静默更新
state = state.copyWith(
items: [...state.items, ...cached],
currentPage: nextPage,
hasMore: true,
isRefreshing: true,
fromCache: true,
);
_silentRefreshPage(nextPage, replace: false);
} else {
// 无缓存:正常请求
state = state.copyWith(isLoadingMore: true);
try {
final (newItems, hasMore) = await fetcher(nextPage, pageSize);
await _writePageCache(nextPage, newItems);
state = state.copyWith(
items: [...state.items, ...newItems],
currentPage: nextPage,
hasMore: hasMore,
isLoadingMore: false,
fromCache: false,
);
} catch (e) {
state = state.copyWith(isLoadingMore: false, error: e.toString());
}
}
}
/// 后台静默刷新(关键:精确替换对应页数据)
Future<void> _silentRefreshPage(int page, {required bool replace}) async {
try {
final (newItems, hasMore) = await fetcher(page, pageSize);
await _writePageCache(page, newItems);
final start = (page - 1) * pageSize;
final end = page * pageSize;
if (replace) {
// 第一页:替换前 pageSize 条
final rest = state.items.length > pageSize
? state.items.sublist(pageSize)
: <T>[];
state = state.copyWith(
items: [...newItems, ...rest],
hasMore: hasMore,
isRefreshing: false,
fromCache: false,
);
} else {
// 第 N 页:精确替换中间段
final before = state.items.length > start
? state.items.sublist(0, start)
: state.items;
final after = state.items.length > end
? state.items.sublist(end)
: <T>[];
state = state.copyWith(
items: [...before, ...newItems, ...after],
hasMore: hasMore,
isRefreshing: false,
fromCache: false,
);
}
} catch (e) {
state = state.copyWith(isRefreshing: false);
}
}
}
第二步:业务层实现
2.1 数据模型
dart
// demo_controller.dart
class ImageItem {
final int id;
final String url;
final String title;
const ImageItem({required this.id, required this.url, required this.title});
Map<String, dynamic> toJson() => {'id': id, 'url': url, 'title': title};
factory ImageItem.fromJson(Map<String, dynamic> json) => ImageItem(
id: json['id'] as int,
url: json['url'] as String,
title: json['title'] as String,
);
}
2.2 模拟 API
dart
Future<(List<ImageItem>, bool)> _fetchImages(
String category,
int page,
int pageSize,
) async {
await Future.delayed(const Duration(milliseconds: 1500));
const total = 50;
final start = (page - 1) * pageSize;
if (start >= total) return (<ImageItem>[], false);
final end = (start + pageSize).clamp(0, total);
// title 带时间戳,方便观察缓存 vs 网络数据
final fetchTime = '${DateTime.now().hour}:${DateTime.now().minute}:${DateTime.now().second}';
final items = List.generate(end - start, (i) {
final id = start + i + 1;
return ImageItem(
id: id,
url: 'https://picsum.photos/seed/${category}_$id/400/300',
title: '$category #$id [$fetchTime]',
);
});
return (items, end < total);
}
2.3 各 Tab 的 Notifier
dart
// 自然
class NatureNotifier extends PaginatedNotifier<ImageItem> {
@override PageFetcher<ImageItem> get fetcher => (p, s) => _fetchImages('nature', p, s);
@override String get cacheKey => 'paginated_nature';
@override ToJson<ImageItem> get toJson => (item) => item.toJson();
@override FromJson<ImageItem> get fromJson => ImageItem.fromJson;
@override Duration get cacheTtl => const Duration(hours: 1);
}
final natureProvider = NotifierProvider<NatureNotifier, PaginatedState<ImageItem>>(NatureNotifier.new);
// 城市(无 TTL 配置,使用默认 1 小时)
class CityNotifier extends PaginatedNotifier<ImageItem> {
@override PageFetcher<ImageItem> get fetcher => (p, s) => _fetchImages('city', p, s);
@override String get cacheKey => 'paginated_city';
@override ToJson<ImageItem> get toJson => (item) => item.toJson();
@override FromJson<ImageItem> get fromJson => ImageItem.fromJson;
}
final cityProvider = NotifierProvider<CityNotifier, PaginatedState<ImageItem>>(CityNotifier.new);
// 动物
class AnimalNotifier extends PaginatedNotifier<ImageItem> {
@override PageFetcher<ImageItem> get fetcher => (p, s) => _fetchImages('animal', p, s);
@override String get cacheKey => 'paginated_animal';
@override ToJson<ImageItem> get toJson => (item) => item.toJson();
@override FromJson<ImageItem> get fromJson => ImageItem.fromJson;
}
final animalProvider = NotifierProvider<AnimalNotifier, PaginatedState<ImageItem>>(AnimalNotifier.new);
第三步:UI 层实现
3.1 数据适配层(Screen)
dart
// demo_screen.dart
class DemoScreen extends ConsumerStatefulWidget {
const DemoScreen({super.key});
@override
ConsumerState<DemoScreen> createState() => _DemoScreenState();
}
class _DemoScreenState extends ConsumerState<DemoScreen> {
@override
void initState() {
super.initState();
Future.microtask(() {
ref.read(natureProvider.notifier).refresh();
ref.read(cityProvider.notifier).refresh();
ref.read(animalProvider.notifier).refresh();
});
}
@override
Widget build(BuildContext context) {
final nature = ref.watch(natureProvider);
final city = ref.watch(cityProvider);
final animal = ref.watch(animalProvider);
return DemoPage(
tabs: [
TabConfig(
label: '自然',
items: nature.items.map((e) => ImageItemData(id: e.id, url: e.url, title: e.title)).toList(),
isLoading: nature.isLoading,
isLoadingMore: nature.isLoadingMore,
isRefreshing: nature.isRefreshing,
fromCache: nature.fromCache,
hasMore: nature.hasMore,
error: nature.error,
onRefresh: () => ref.read(natureProvider.notifier).refresh(),
onLoadMore: () => ref.read(natureProvider.notifier).loadMore(),
),
// 城市、动物同理...
],
);
}
}
3.2 纯 UI 组件(Page)
dart
// demo_page.dart
class TabConfig {
final String label;
final List<ImageItemData> items;
final bool isLoading;
final bool isLoadingMore;
final bool isRefreshing;
final bool hasMore;
final bool fromCache;
final String? error;
final VoidCallback onRefresh;
final VoidCallback onLoadMore;
const TabConfig({...});
}
class DemoPage extends StatefulWidget {
final List<TabConfig> tabs;
const DemoPage({super.key, required this.tabs});
// ...
}
// 状态展示组件
class _StatusChip extends StatelessWidget {
final String label;
final Color color;
final bool showSpinner;
const _StatusChip({...});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: color.withOpacity(0.85),
borderRadius: BorderRadius.circular(14),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (showSpinner) ...[
const SizedBox(width: 10, height: 10, child: CircularProgressIndicator(strokeWidth: 1.5, color: Colors.white)),
const SizedBox(width: 6),
],
Text(label, style: const TextStyle(color: Colors.white, fontSize: 11)),
],
),
);
}
}
集成模版(开箱即用)
如果你想把这套方案集成到自己的项目中,只需以下几步:
Step 1: 复制核心文件
将 paginated_notifier.dart 复制到你的 lib/core/ 目录。
Step 2: 定义你的数据模型
dart
class YourModel {
final int id;
final String name;
YourModel({required this.id, required this.name});
Map<String, dynamic> toJson() => {'id': id, 'name': name};
factory YourModel.fromJson(Map<String, dynamic> json) => YourModel(
id: json['id'] as int,
name: json['name'] as String,
);
}
Step 3: 实现你的 Notifier
dart
final yourProvider = NotifierProvider<YourNotifier, PaginatedState<YourModel>>(YourNotifier.new);
class YourNotifier extends PaginatedNotifier<YourModel> {
@override
PageFetcher<YourModel> get fetcher => (page, size) => yourApi.fetch(page, size);
@override
String get cacheKey => 'your_cache_key';
@override
ToJson<YourModel> get toJson => (item) => item.toJson();
@override
FromJson<YourModel> get fromJson => YourModel.fromJson;
@override
int get pageSize => 15; // 可选,默认 20
@override
Duration get cacheTtl => const Duration(minutes: 30); // 可选,默认 1 小时
}
Step 4: 在页面中使用
dart
class YourPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(yourProvider);
useEffect(() {
ref.read(yourProvider.notifier).refresh();
return null;
}, []);
if (state.isLoading && state.items.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return RefreshIndicator(
onRefresh: () => ref.read(yourProvider.notifier).refresh(),
child: ListView.builder(
itemCount: state.items.length + (state.hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == state.items.length) {
return const Center(child: CircularProgressIndicator());
}
return YourListItem(item: state.items[index]);
},
),
);
}
}
关键技术点总结
特性 实现方式
按页缓存 SharedPreferences,Key 格式 {cacheKey}page {n}
过期策略 时间戳校验,可配置 TTL
SWR 策略 有缓存先展示,后台静默更新
并发安全 静默刷新时基于最新 state 精确替换对应页数据
UI 状态 fromCache 标识 + isRefreshing 后台刷新指示器
注意事项
- 大数据量:如果每页数据较大,建议换用 Hive/Isar 替代 SharedPreferences
- 缓存清理:提供 invalidate() 方法可强制清除缓存
- 离线支持:当前策略在有缓存时完全离线可用
- 内存占用:缓存仅存储当前展示的页,不会无限增长
希望这套方案能帮助你快速构建优雅的分页缓存体验!如果有问题,欢迎在评论区交流。