Repository 层如何无缝接入本地缓存 / 数据库

------一套"先快后准"的数据策略:Memory → DB → Network → 回写

目标:页面打开秒出数据(缓存/数据库),后台再拉取网络数据更新;弱网/离线也能用;Repository 对上层只暴露干净的领域模型,不让 UI 知道缓存细节。

1)为什么缓存/数据库要放在 Repository?

Repository 的职责是:对业务提供"数据真相"

它应该屏蔽数据来自哪里:

  • 网络:Dio / REST

  • 本地:SQLite/Isar/Hive/SharedPreferences

  • 内存:Map/LRU

  • 组合策略:先缓存后网络、过期刷新、离线兜底

UI/State 层只关心:

"我需要 profile 数据","我刷新一下","我加载更多"。

2)推荐目录结构(可复用)

复制代码
data/
  remote/
    api_client.dart
    profile_api.dart
  local/
    db.dart
    profile_dao.dart
    entities/
      profile_entity.dart
  repository/
    profile_repository.dart
domain/
  models/
    profile.dart
  mappers/
    profile_mapper.dart
  • Entity:数据库结构(字段可能更偏存储)
  • Model:业务模型(给 UI/UseCase 用)
  • Mapper:Entity ↔ Model 的转换
  • Repository:组合 Network + DB + Cache 策略

3)策略选型:Cache-Aside + Stale-While-Revalidate(最常用)

你想要的"无缝"体验,最常用就是这套:

  1. 先读缓存/DB(快)→ 立即返回给 UI
  2. 同时/随后拉 Network(准)
  3. 成功后 回写 DB/Cache 并通知 UI 更新

这就是经典的:

  • Cache-aside(缓存旁路)
  • SWR(过期可用 + 后台刷新)

4)核心接口:Repository 对外暴露什么?

强烈建议 Repository 对外提供两种能力:

A. 一次性读取(适合简单页面)

Future<Profile> getProfile({bool forceRefresh = false});

B. 流式订阅(推荐:DB 作为单一事实来源)

Stream<Profile> watchProfile();

Future<void> refreshProfile();

如果你想"秒出 + 自动刷新后 UI 自动更新",选 B 会更爽。

5)实现方案 1:DB 为单一事实源(推荐中大型项目)

思路:UI 只订阅 DB,Repository 负责刷新并回写 DB。

5.1 Domain Model(业务模型)

Dart 复制代码
class Profile {
  final String id;
  final String name;
  final String avatar;
  Profile({required this.id, required this.name, required this.avatar});
}

5.2 DB Entity(存储结构)

Dart 复制代码
class ProfileEntity {
  final String id;
  final String name;
  final String avatar;
  final int updatedAtMs;

  ProfileEntity({
    required this.id,
    required this.name,
    required this.avatar,
    required this.updatedAtMs,
  });
}

5.3 Mapper(Entity ↔ Model)

Dart 复制代码
class ProfileMapper {
  static Profile toModel(ProfileEntity e) =>
      Profile(id: e.id, name: e.name, avatar: e.avatar);

  static ProfileEntity toEntity(Profile m) => ProfileEntity(
        id: m.id,
        name: m.name,
        avatar: m.avatar,
        updatedAtMs: DateTime.now().millisecondsSinceEpoch,
      );
}

5.4 DAO(你用 Drift/Isar/Hive 都行,这里只给接口)

Dart 复制代码
abstract class ProfileDao {
  Stream<ProfileEntity?> watch();
  Future<ProfileEntity?> get();
  Future<void> upsert(ProfileEntity entity);
  Future<void> clear();
}

5.5 Remote API

Dart 复制代码
abstract class ProfileApi {
  Future<Profile> fetchProfile();
}

5.6 Repository(重点:策略实现)

Dart 复制代码
class ProfileRepository {
  final ProfileApi api;
  final ProfileDao dao;

  ProfileRepository({required this.api, required this.dao});

  Stream<Profile?> watchProfile() {
    return dao.watch().map((e) => e == null ? null : ProfileMapper.toModel(e));
  }

  Future<void> refreshProfile() async {
    final profile = await api.fetchProfile();
    await dao.upsert(ProfileMapper.toEntity(profile));
  }

  Future<Profile?> getCachedProfile() async {
    final e = await dao.get();
    return e == null ? null : ProfileMapper.toModel(e);
  }
}

UI 用法:

  • 页面订阅 watchProfile()
  • 页面下拉刷新调用 refreshProfile()
  • 弱网下仍有 DB 数据兜底

6)实现方案 2:一次性读取 + TTL(适合小中型项目)

如果你暂时不想用 Stream(或 DB 不支持 watch),用 TTL 也很常见:

6.1 定义缓存策略

  • 内存缓存:秒开
  • DB 缓存:离线兜底
  • TTL:比如 10 分钟过期
Dart 复制代码
class CachePolicy {
  final Duration ttl;
  CachePolicy(this.ttl);

  bool isExpired(int updatedAtMs) {
    final age = DateTime.now().millisecondsSinceEpoch - updatedAtMs;
    return age > ttl.inMilliseconds;
  }
}

6.2 Repository:先快后准

Dart 复制代码
class ProfileRepository2 {
  final ProfileApi api;
  final ProfileDao dao;
  final CachePolicy policy;

  ProfileRepository2({required this.api, required this.dao, required this.policy});

  Future<Profile> getProfile({bool forceRefresh = false}) async {
    final cached = await dao.get();

    if (!forceRefresh && cached != null && !policy.isExpired(cached.updatedAtMs)) {
      // 未过期:直接用本地
      return ProfileMapper.toModel(cached);
    }

    try {
      // 过期/强刷:走网络
      final remote = await api.fetchProfile();
      await dao.upsert(ProfileMapper.toEntity(remote));
      return remote;
    } catch (_) {
      // 网络失败:兜底用旧缓存(只要有)
      if (cached != null) return ProfileMapper.toModel(cached);
      rethrow;
    }
  }
}

这就是"过期刷新 + 失败回退"。

7)如何"无缝接入 401 自动刷新 Token"?

Repository 不需要知道 token 刷新逻辑。

你只要保证 Dio 层有:

  • AuthInterceptor 注入 token

  • RefreshInterceptor / QueueRefreshInterceptor 处理 401

Repository 仍旧只是:

final profile = await api.fetchProfile();

登录过期 (refresh 失败)由全局 onAuthExpired 统一处理即可。

8)工程建议:你最容易踩的 4 个坑

  1. UI 直接读 DB + 直接调 API:会绕过 Repository,逻辑散落

  2. 没有 Mapper:Entity/Model 混用,后期字段调整会痛苦

  3. 缓存失效策略缺失:要么永远旧,要么永远打网

  4. 写入时机不统一:建议所有网络成功的数据都回写 DB,DB 成事实源

9)你该怎么选(给你一句话)

  • 想要"秒开 + 自动更新 + 离线可用" ✅ 方案 1:DB 单一事实源 + watch

  • 项目小、只想快速落地 ✅ 方案 2:TTL + 失败回退

相关推荐
stand_forever2 小时前
redis秒杀实现
redis·缓存·php
尋有緣2 小时前
力扣1225-报告系统状态的连续日期
数据库·sql·算法·leetcode·oracle
消失的旧时光-19432 小时前
用 Drift 实现 Repository 无缝接入本地缓存/数据库(SWR:先快后准)
数据库·flutter·缓存
Tony Bai2 小时前
【API 设计之道】08 流量与配额:构建基于 Redis 的分布式限流器
数据库·redis·分布式·缓存
消失的旧时光-19432 小时前
Android(Kotlin) ↔ Flutter(Dart) 的“1:1 对应表”:架构分层来对照(MVVM/MVI 都适用)
android·flutter·kotlin
无名-CODING2 小时前
MyBatis 动态 SQL 全攻略
数据库·sql·mybatis
枫叶丹42 小时前
【Qt开发】Qt事件(二)-> QKeyEvent 按键事件
c语言·开发语言·数据库·c++·qt·microsoft
想学后端的前端工程师2 小时前
【Redis实战与高可用架构设计:从缓存到分布式锁的完整解决方案】
redis·分布式·缓存
llxxyy卢4 小时前
JWT安全&预编译CASE注入
数据库·sql·安全