Flutter 分页缓存实战:基于 Riverpod 的 SWR 策略实现

前言

在移动应用开发中,分页加载和缓存策略是两个绕不开的核心问题。用户期望 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 后台刷新指示器

注意事项

  1. 大数据量:如果每页数据较大,建议换用 Hive/Isar 替代 SharedPreferences
  2. 缓存清理:提供 invalidate() 方法可强制清除缓存
  3. 离线支持:当前策略在有缓存时完全离线可用
  4. 内存占用:缓存仅存储当前展示的页,不会无限增长

希望这套方案能帮助你快速构建优雅的分页缓存体验!如果有问题,欢迎在评论区交流。

相关推荐
Ww.xh3 小时前
鸿蒙Flutter混合开发实战:跨平台UI无缝集成
flutter·华为·harmonyos
SoulRed3 小时前
Android Studio 调试flutter gradle的问题
android·flutter·android studio
blanks20203 小时前
为 Zed 编辑器 添加 flutter dart snippets
前端·flutter
blanks20203 小时前
使用 zed 和 使用 vscode 开发 flutter
flutter
2601_949593654 小时前
Flutter_OpenHarmony_三方库_file_selector文件选择适配详解
flutter
陆业聪4 小时前
跨端框架横评 2026:Flutter、React Native、KMP、Kuikly、小程序,谁是你下一个项目的正确答案?
flutter·大前端·跨端开发
2601_949593655 小时前
Flutter_OpenHarmony_三方库_url_launcher链接跳转适配详解
flutter
天渺工作室5 小时前
Flutter 版的 NVM——FVM 使用指南
flutter·dart
Lanren的编程日记16 小时前
Flutter鸿蒙应用开发:生物识别(指纹/面容)功能集成实战
flutter·华为·harmonyos