Flutter 网络层架构设计 · 缓存分层落地方案 · 高频面试题与参考答案
Flutter / Dart Dio Interceptor 内存缓存 + 磁盘缓存 LRU / TTL / SWR Token 无感刷新 面试题精讲
一、Dio 高阶封装方案
1.1 分层架构设计
核心思路是把「网络层」拆分成职责单一的几层,业务代码只依赖最上层的 ApiService,不直接接触 Dio:
scss
业务层 (UserService / OrderService,调用具体接口,返回 Model)
→ ApiClient (统一封装 get/post/upload/download,处理泛型解析)
→ Dio 实例 (拦截器链:日志→签名→Token→缓存→重试→统一异常)
→ Transport (HttpClientAdapter,真正发起 Socket 请求)
这样做的好处:业务层完全不感知 Token 刷新、缓存命中、重试等细节;网络层可以整体替换(比如换成 Chopper/Retrofit 风格)而不影响业务代码;每一层都可以单独做单元测试。
1.2 Dio 实例与基础配置
network/dio_client.dart --- 单例 + 基础配置
dart
class DioClient {
DioClient._internal();
static final DioClient instance = DioClient._internal();
late final Dio dio = _createDio();
Dio _createDio() {
final options = BaseOptions(
baseUrl: AppConfig.apiHost,
connectTimeout: const Duration(seconds: 10),
sendTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
responseType: ResponseType.json,
// 让 Dio 不要因为非 2xx 直接抛异常,交给统一拦截器处理业务码
validateStatus: (status) => status != null && status < 500,
);
final dio = Dio(options);
dio.interceptors.addAll([
SignInterceptor(), // 签名 / 公共参数
AuthInterceptor(dio), // Token 注入 + 无感刷新
CacheInterceptor(), // 多级缓存读写
RetryInterceptor(dio), // 失败重试
ResultInterceptor(), // 统一业务码 -> 异常转换
LoggerInterceptor(), // 日志(生产环境可关闭)
]);
return dio;
}
}
1.3 拦截器链设计(洋葱模型)
Dio 的拦截器执行顺序是「请求阶段正序、响应/错误阶段倒序」的洋葱模型:
ini
请求: Sign → Auth → Cache → Retry → Result → Logger → [发起网络请求]
响应: [网络返回] → Logger → Result → Retry → Cache → Auth → Sign → 业务层
因此顺序至关重要:Auth 必须在 Cache 之前 (保证缓存命中时也不用等 Token 刷新,命中缓存可以直接短路返回);Retry 必须包裹在最外层附近 ,因为重试需要拿到原始 RequestOptions 重新发起整条链路。
自定义拦截器基础模板
dart
class SignInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
options.headers['X-Timestamp'] = DateTime.now().millisecondsSinceEpoch.toString();
options.headers['X-Sign'] = SignUtil.sign(options);
handler.next(options); // 必须调用 next,否则请求会被挂起
}
}
1.4 统一响应结构与泛型解析
后端通常返回 { code, message, data } 结构,封装统一的泛型解析,业务层直接拿到强类型 Model:
dart
class ApiResult<T> {
final int code;
final String message;
final T? data;
ApiResult(this.code, this.message, this.data);
bool get isSuccess => code == 0;
}
class ApiClient {
final Dio _dio = DioClient.instance.dio;
Future<ApiResult<T>> get<T>(
String path, {
Map<String, dynamic>? params,
T Function(dynamic json)? fromJson,
CachePolicy cachePolicy = CachePolicy.networkFirst,
}) async {
final resp = await _dio.get(
path,
queryParameters: params,
options: Options(extra: {'cachePolicy': cachePolicy}),
);
final raw = resp.data as Map<String, dynamic>;
final parsed = fromJson != null && raw['data'] != null
? fromJson(raw['data'])
: raw['data'] as T?;
return ApiResult<T>(raw['code'], raw['message'] ?? '', parsed);
}
}
耗时的 JSON 解析建议放到 compute() / Isolate 中执行,避免大列表反序列化卡主线程掉帧。
1.5 Token 无感刷新与并发锁
最容易出错的点:多个接口同时 401 时,如果不加锁会触发多次刷新 Token 的请求。标准做法是用一个「刷新中」标记 + 请求队列:
dart
class AuthInterceptor extends Interceptor {
AuthInterceptor(this._dio);
final Dio _dio;
bool _isRefreshing = false;
final List<Completer<String>> _waitQueue = [];
@override
void onRequest(RequestOptions options, handler) async {
final token = await TokenStore.getAccessToken();
if (token != null) options.headers['Authorization'] = 'Bearer $token';
handler.next(options);
}
@override
void onError(DioException err, handler) async {
if (err.response?.statusCode != 401) return handler.next(err);
final newToken = await _refreshTokenSafely();
if (newToken == null) return handler.next(err); // 刷新失败,跳登录页
// 用新 token 重放原始请求
final req = err.requestOptions;
req.headers['Authorization'] = 'Bearer $newToken';
try {
final retryResp = await _dio.fetch(req);
handler.resolve(retryResp);
} catch (e) {
handler.next(err);
}
}
Future<String?> _refreshTokenSafely() async {
if (_isRefreshing) {
// 已有请求在刷新,当前请求排队等待结果,不重复发起刷新
final completer = Completer<String>();
_waitQueue.add(completer);
return completer.future;
}
_isRefreshing = true;
try {
final newToken = await AuthApi.refreshToken();
await TokenStore.saveAccessToken(newToken);
for (final c in _waitQueue) c.complete(newToken);
return newToken;
} catch (_) {
for (final c in _waitQueue) c.completeError('refresh failed');
return null;
} finally {
_isRefreshing = false;
_waitQueue.clear();
}
}
}
1.6 请求取消 / 请求去重 / 失败重试
CancelToken 统一管理(按页面 / 生命周期取消)
dart
class CancelTokenManager {
static final Map<String, CancelToken> _tokens = {};
static CancelToken of(String tag) {
return _tokens.putIfAbsent(tag, () => CancelToken());
}
static void cancel(String tag) {
_tokens.remove(tag)?.cancel('page disposed');
}
}
// Widget dispose 时:CancelTokenManager.cancel(widgetTag);
请求去重(防止用户快速重复点击触发相同请求)
dart
class DedupeInterceptor extends Interceptor {
final Set<String> _pending = {};
String _key(RequestOptions o) => '${o.method}_${o.uri}';
@override
void onRequest(RequestOptions options, handler) {
final key = _key(options);
if (_pending.contains(key)) {
return handler.reject(
DioException(requestOptions: options, error: 'duplicate request'),
);
}
_pending.add(key);
handler.next(options);
}
@override
void onResponse(response, handler) {
_pending.remove(_key(response.requestOptions));
handler.next(response);
}
@override
void onError(err, handler) {
_pending.remove(_key(err.requestOptions));
handler.next(err);
}
}
失败重试(指数退避)
dart
class RetryInterceptor extends Interceptor {
RetryInterceptor(this._dio, {this.maxRetries = 2});
final Dio _dio;
final int maxRetries;
@override
void onError(DioException err, handler) async {
final retriable = err.type == DioExceptionType.connectionTimeout
|| err.type == DioExceptionType.receiveTimeout
|| (err.response?.statusCode ?? 0) >= 500;
final int attempted = (err.requestOptions.extra['__retry'] ?? 0) as int;
if (retriable && attempted < maxRetries) {
await Future.delayed(Duration(milliseconds: 300 * (1 << attempted)));
err.requestOptions.extra['__retry'] = attempted + 1;
try {
handler.resolve(await _dio.fetch(err.requestOptions));
return;
} catch (_) {}
}
handler.next(err);
}
}
1.7 统一异常处理
dart
enum ApiErrorType { network, timeout, auth, business, unknown }
class ApiException implements Exception {
ApiException(this.type, this.message, {this.code});
final ApiErrorType type;
final String message;
final int? code;
}
ApiException mapDioError(DioException e) {
switch (e.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.receiveTimeout:
return ApiException(ApiErrorType.timeout, '网络超时,请重试');
case DioExceptionType.badResponse:
if (e.response?.statusCode == 401) {
return ApiException(ApiErrorType.auth, '登录已过期');
}
return ApiException(ApiErrorType.business, e.response?.data['message'] ?? '请求失败');
default:
return ApiException(ApiErrorType.network, '网络异常,请检查网络连接');
}
}
二、多级缓存策略具体逻辑
2.1 三级缓存架构
典型的客户端多级缓存分三层,命中优先级从上到下依次降低,写入时反向同步:
scss
L1 内存缓存 (LRU Map,读写 O(1),App 存活期间有效,容量小(如 50~200 条))
→ L2 磁盘缓存 (Hive / sqflite / 文件系统,跨进程重启依然存在,容量大,读写有 IO 开销)
→ L3 网络请求 (真实接口,走 CacheInterceptor 决策后按需发起)
读取顺序:L1 → L2 → L3,任何一层命中且未过期就短路返回,同时把结果回填到更上层(L2 命中会回填 L1,网络命中会同时写 L1 + L2)。
2.2 缓存 Key 设计
Key 必须能唯一标识一个「请求语义」,通常由 method + 完整 url + 排序后的 query/body 参数 做哈希:
dart
String buildCacheKey(RequestOptions options) {
final sortedParams = Map.fromEntries(
options.queryParameters.entries.toList()
..sort((a, b) => a.key.compareTo(b.key)),
);
final bodyStr = options.data is Map ? jsonEncode(options.data) : '';
final raw = '${options.method}_${options.uri.path}_${jsonEncode(sortedParams)}_$bodyStr';
return md5.convert(utf8.encode(raw)).toString(); // 定长、无特殊字符,适合做文件名/DB主键
}
query 参数排序是关键点:?a=1&b=2 和 ?b=2&a=1 语义相同,不排序会导致同一个请求生成两个不同的 Key,缓存命中率下降。
2.3 缓存策略枚举与决策流程
dart
enum CachePolicy {
onlyNetwork, // 只走网络,不读不写缓存(如提交类接口)
onlyCache, // 只读缓存,无缓存则报错(如离线模式)
cacheFirst, // 有缓存且未过期直接用,否则走网络(如配置类、字典类接口)
networkFirst, // 优先网络,失败或超时降级用缓存(如列表页,兜底弱网)
cacheThenNetwork, // 先返回缓存做首屏秒开,同时发起网络请求刷新(Stale-While-Revalidate)
}
| 策略 | 典型场景 | 一致性 | 体验 |
|---|---|---|---|
| onlyNetwork | 支付、下单、提交表单 | 强一致 | 无缓存加速 |
| cacheFirst | APP 配置、城市列表、字典表 | 弱一致(TTL 内) | 秒开 |
| networkFirst | 订单详情、个人信息 | 较强一致 | 弱网时兜底 |
| cacheThenNetwork | 首页信息流、商品列表 | 最终一致 | 秒开 + 自动刷新 |
| onlyCache | 离线模式、无网络兜底页 | 依赖缓存新鲜度 | 无网可用 |
2.4 CacheInterceptor 具体实现
dart
class CacheInterceptor extends Interceptor {
final LruMemoryCache _memory = LruMemoryCache(capacity: 200);
final DiskCache _disk = DiskCache();
@override
void onRequest(RequestOptions options, handler) async {
final policy = options.extra['cachePolicy'] as CachePolicy? ?? CachePolicy.networkFirst;
if (policy == CachePolicy.onlyNetwork) return handler.next(options);
final key = buildCacheKey(options);
final cached = _memory.get(key) ?? await _disk.get(key);
if (cached != null && !cached.isExpired) {
if (policy == CachePolicy.cacheFirst || policy == CachePolicy.onlyCache) {
return handler.resolve(cached.toResponse(options), true); // 短路,不再发起网络请求
}
if (policy == CachePolicy.cacheThenNetwork) {
// 先把缓存快照挂到 extra 里,业务层可以先渲染,同时继续走网络刷新
options.extra['staleResponse'] = cached.toResponse(options);
}
} else if (policy == CachePolicy.onlyCache) {
return handler.reject(DioException(requestOptions: options, error: 'no cache available'));
}
handler.next(options); // networkFirst / cacheThenNetwork 走到这里会真正发起网络请求
}
@override
void onResponse(Response response, handler) async {
final policy = response.requestOptions.extra['cachePolicy'] as CachePolicy?;
if (policy != null && policy != CachePolicy.onlyNetwork && response.statusCode == 200) {
final key = buildCacheKey(response.requestOptions);
final entry = CacheEntry.fromResponse(response, ttl: _ttlFor(response.requestOptions));
_memory.put(key, entry);
await _disk.put(key, entry); // 磁盘写入放到微任务/异步队列,避免阻塞响应返回
}
handler.next(response);
}
@override
void onError(DioException err, handler) async {
final policy = err.requestOptions.extra['cachePolicy'] as CachePolicy?;
if (policy == CachePolicy.networkFirst) {
// networkFirst 网络失败时降级读缓存(即使已过期也兜底展示,好过白屏)
final key = buildCacheKey(err.requestOptions);
final cached = _memory.get(key) ?? await _disk.get(key);
if (cached != null) {
return handler.resolve(cached.toResponse(err.requestOptions));
}
}
handler.next(err);
}
Duration _ttlFor(RequestOptions options) {
return options.extra['ttl'] as Duration? ?? const Duration(minutes: 5);
}
}
2.5 LRU 内存缓存实现
用 LinkedHashMap 天然维护访问顺序,超出容量时淘汰最久未访问的条目:
dart
class LruMemoryCache {
LruMemoryCache({this.capacity = 100});
final int capacity;
final LinkedHashMap<String, CacheEntry> _map = LinkedHashMap();
CacheEntry? get(String key) {
final entry = _map.remove(key);
if (entry == null) return null;
_map[key] = entry; // 重新插入 = 移到链表尾部,代表"最近使用"
return entry;
}
void put(String key, CacheEntry entry) {
if (_map.containsKey(key)) _map.remove(key);
else if (_map.length >= capacity) {
_map.remove(_map.keys.first); // 淘汰最久未使用(链表头部)
}
_map[key] = entry;
}
}
2.6 磁盘缓存与过期清理
dart
class CacheEntry {
CacheEntry({required this.data, required this.savedAt, required this.ttl, this.etag});
final dynamic data;
final DateTime savedAt;
final Duration ttl;
final String? etag;
bool get isExpired => DateTime.now().difference(savedAt) > ttl;
}
class DiskCache {
Box? _box; // Hive Box,也可换成 sqflite 表
Future<void> _ensureOpen() async {
_box ??= await Hive.openBox('http_cache');
}
Future<CacheEntry?> get(String key) async {
await _ensureOpen();
final raw = _box!.get(key);
if (raw == null) return null;
final entry = CacheEntry.fromMap(raw);
if (entry.isExpired) {
await _box!.delete(key); // 惰性删除:读取时发现过期顺手清理
return null;
}
return entry;
}
Future<void> put(String key, CacheEntry entry) async {
await _ensureOpen();
await _box!.put(key, entry.toMap());
if (_box!.length > 2000) _evictOldest(); // 容量兜底,防止磁盘占用无限增长
}
void _evictOldest() {
final entries = _box!.toMap().entries.toList()
..sort((a, b) =>
CacheEntry.fromMap(a.value).savedAt.compareTo(CacheEntry.fromMap(b.value).savedAt));
final toRemove = entries.take(200).map((e) => e.key);
_box!.deleteAll(toRemove);
}
}
注意: 磁盘缓存的写入建议放入队列异步执行(或用 Future 不 await),避免每次响应都同步等待磁盘 IO 拖慢接口返回速度;同时要给缓存加上总容量上限,防止长期运行后缓存文件无限膨胀占满存储。
2.7 协商缓存(结合 ETag / Last-Modified)
对于变化不频繁但又不能完全走强缓存的接口,可以结合 HTTP 协商缓存,命中 304 时直接复用本地数据,省掉响应体传输:
dart
// 请求时带上本地缓存的 etag
if (cached?.etag != null) {
options.headers['If-None-Match'] = cached!.etag;
}
// 响应处理:304 表示服务端数据未变化,直接复用旧缓存内容 + 刷新过期时间
if (response.statusCode == 304 && cached != null) {
final refreshed = cached.copyWith(savedAt: DateTime.now());
_memory.put(key, refreshed);
await _disk.put(key, refreshed);
return handler.resolve(refreshed.toResponse(response.requestOptions));
}
三、高频面试题与参考答案
高频 建议结合项目经历回答
3.1. Dio 拦截器的执行顺序是怎样的?为什么 Auth 拦截器要放在 Cache 拦截器前面?
Dio 拦截器请求阶段按注册顺序正序执行,响应/错误阶段按注册顺序倒序执行,形成"洋葱模型"。Auth 放在 Cache 前面的原因:请求阶段 Auth 先注入 Token,Cache 拦截器判断命中缓存后可以直接短路返回(handler.resolve),不需要等待、也不依赖 Token 是否有效,这样缓存命中的请求完全不受 Token 过期影响,响应更快;如果顺序反过来,每次命中缓存前都要先走一遍 Token 校验/刷新逻辑,白白浪费一次可能的异步等待。
3.2. Token 过期后如何保证多个并发请求只刷新一次 Token?
用一个全局的 _isRefreshing 标志位 + 等待队列(List<Completer>)。第一个遇到 401 的请求负责真正发起刷新接口,并将标志位设置为 true;期间其他请求遇到 401 时发现标志位已是 true,就创建一个 Completer 放入队列并 await 它的 future,自己不再发起刷新请求。等真正的刷新完成后,遍历队列用新 Token complete 所有等待者,大家再各自用新 Token 重放原始请求。刷新失败时要 completeError 通知所有等待者,并跳转登录页,同时要在 finally 中重置标志位和清空队列,防止死锁。
3.3. 如何设计一个多级缓存系统?各层的作用分别是什么?
通常分三层:L1 内存缓存(LinkedHashMap 实现的 LRU,读写 O(1),App 存活期间有效,容量小);L2 磁盘缓存(Hive/sqflite/文件,跨重启持久化,容量较大但有 IO 开销);L3 真实网络请求。读取顺序 L1 → L2 → L3,命中即短路返回,并把结果回填到更上层;写入时同时写 L1 和 L2(磁盘写入建议异步化,不阻塞响应返回)。核心设计点包括:统一的缓存 Key 生成规则(method + url + 排序后的参数做 hash)、TTL 过期策略、容量上限与淘汰算法、以及针对不同接口特性可配置的缓存策略(cacheFirst / networkFirst / cacheThenNetwork 等)。
3.4. LRU 缓存的核心原理是什么?Dart 里如何低成本实现?
LRU(Least Recently Used)核心思想是:当容量达到上限需要淘汰时,优先淘汰"最久没有被访问"的数据,因为局部性原理决定了最近被访问过的数据更可能在近期被再次访问。经典实现是哈希表 + 双向链表:哈希表保证 O(1) 查找,双向链表维护访问顺序,每次访问命中就把节点移动到链表尾部,淘汰时从链表头部删除。Dart 里可以直接用 LinkedHashMap,它本身就是按插入顺序(可理解为访问顺序)维护的哈希表:get 命中时先 remove 再重新 put 相当于"移到尾部",put 时如果超容量就删除 keys.first(最久未访问),不需要手写双向链表就能拿到 O(1) 的 LRU 语义。
3.5. cacheFirst、networkFirst、cacheThenNetwork 应该分别用在什么场景?
-
cacheFirst:数据变化极少、对实时性要求低的接口,比如城市列表、字典表、App 配置项,缓存未过期就完全不发网络请求,减少无意义的请求量。
-
networkFirst:需要保证数据新鲜度但要兼顾弱网体验的接口,比如订单详情、个人信息,优先请求网络,失败或超时才降级读缓存兜底,避免弱网下白屏。
-
cacheThenNetwork (Stale-While-Revalidate):首页信息流、商品列表等需要
秒开体验的场景,先用本地缓存快照立即渲染,同时后台发起网络请求刷新最新数据,请求成功后再局部更新 UI,兼顾速度与新鲜度。
3.6. 如何设计缓存的 Key,避免出现"同一个请求生成不同缓存"的问题?
Key 需要覆盖能影响响应内容的所有维度:请求方法、完整路径、query 参数、(POST/PUT 的)body 参数。最容易踩的坑是 query 参数的 Map 遍历顺序不固定,?a=1&b=2 和 ?b=2&a=1 语义完全相同,但如果直接拼接字符串会生成两个不同的 Key。解决办法是先对参数按 key 排序,再序列化成固定格式的字符串,最后做 MD5/SHA 哈希得到定长、无特殊字符的 Key,方便作为文件名或数据库主键使用。
3.7. 客户端缓存要不要考虑"缓存穿透/雪崩"问题?怎么解决?
这两个概念本是服务端缓存的经典问题,但在客户端场景可以类比:穿透 对应短时间内大量不同参数的请求都无缓存命中、全部打到网络层,比如列表分页疯狂切页导致每页都是新 Key;雪崩 对应大量缓存在同一时刻集中过期,导致某一瞬间请求量骤增。客户端的应对策略:给 TTL 增加随机抖动(比如基础 5 分钟 + 0~30 秒随机值),避免同批写入的缓存在同一时刻集体失效;对相同 Key 的并发请求做去重合并(正在请求中的相同 Key 直接复用同一个 Future,而不是各自发起网络请求);对关键接口设置合理的请求节流/防抖,避免用户高频操作导致请求风暴。
3.8. Dio 如何实现文件上传下载进度监听?
Dio 的 post/download 方法都提供了 onSendProgress 和 onReceiveProgress 回调,参数为 (int count, int total)。上传文件时用 FormData.fromMap 包装 MultipartFile,通过 onSendProgress 计算 count / total 得到上传百分比;下载大文件用 dio.download(url, savePath, onReceiveProgress: ...),Dio 内部会以流的方式写入文件,避免一次性把整个文件加载进内存。需要注意 total 在服务端没有返回 Content-Length 时可能是 -1,要做好兜底判断。
3.9. 如何防止用户快速重复点击导致同一个请求被发送多次?
两种常见方案可以结合使用:一是在拦截器层做请求去重,维护一个进行中请求的 Key 集合,onRequest 时如果发现相同 Key 的请求正在进行则直接 reject,onResponse/onError 时移除该 Key;二是在 UI 层做按钮防抖/loading 态锁定,请求发出后禁用按钮直到响应返回。生产环境建议两者都做:拦截器层做兜底保证网络语义正确,UI 层做体验优化防止用户误以为没有反应而重复点击。
3.10. 失败重试机制设计时要注意哪些点?哪些请求不应该重试?
设计要点:只对幂等或网络层瞬时错误重试,比如连接超时、接收超时、5xx 服务端错误;重试次数要有上限(一般 1~3 次),并配合指数退避(如 300ms、600ms、1200ms)避免瞬间重试加重服务端压力;重试状态要记录在 requestOptions.extra 里跟随请求本身传递,不能用全局变量(否则并发请求会互相污染重试计数)。不应该重试 的场景:POST 下单、支付、扣款等非幂等的写操作(重试可能导致重复下单/重复扣款),以及 4xx 客户端参数错误(重试也不会成功,属于无意义重试)。
3.11. 多级缓存中,内存缓存和磁盘缓存的数据一致性如何保证?
一般不追求强一致,而是遵循内存缓存是磁盘缓存的子集/镜像,且以内存缓存为准的原则:写入时同时更新 L1 和 L2;L1 命中直接返回不查 L2;L1 未命中才查 L2,命中后回填 L1;网络请求返回最新数据后,同时覆盖写入 L1 和 L2,保证两层最终一致。对于多 Isolate 场景(比如后台 Isolate 也需要读写缓存),内存缓存无法跨 Isolate 共享,应以磁盘缓存(如 sqflite,本身支持多连接读写)作为跨 Isolate 的唯一数据源,各 Isolate 各自维护独立的 L1 内存缓存即可。
3.12. 为什么要把耗时的 JSON 解析放到 Isolate 中?Dio 里怎么结合使用?
Dart 是单线程事件循环模型,UI 渲染和 JSON 反序列化都跑在同一个 Isolate(主线程)上;如果响应体很大(比如几千条数据的列表),jsonDecode + Model 转换是同步 CPU 密集操作,会阻塞事件循环导致掉帧卡顿。解决办法是在拿到 Dio 的原始 Response(建议设置 responseType: ResponseType.plain 拿到原始字符串,或直接处理 response.data)后,用 compute()(内部会创建/复用一个新 Isolate)把字符串解析和 Model 转换丢到子 Isolate 执行,完成后通过消息传递把结果传回主 Isolate,从而避免阻塞 UI 线程。