------一套"先快后准"的数据策略: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(最常用)
你想要的"无缝"体验,最常用就是这套:
- 先读缓存/DB(快)→ 立即返回给 UI
- 同时/随后拉 Network(准)
- 成功后 回写 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 个坑
-
UI 直接读 DB + 直接调 API:会绕过 Repository,逻辑散落
-
没有 Mapper:Entity/Model 混用,后期字段调整会痛苦
-
缓存失效策略缺失:要么永远旧,要么永远打网
-
写入时机不统一:建议所有网络成功的数据都回写 DB,DB 成事实源
9)你该怎么选(给你一句话)
-
想要"秒开 + 自动更新 + 离线可用" ✅ 方案 1:DB 单一事实源 + watch
-
项目小、只想快速落地 ✅ 方案 2:TTL + 失败回退