cubegao

Flutter 网络层架构设计与封装实践

2025-02-25

一、网络层混乱的代价

2024 年的一次线上事故,让我意识到网络层必须有统一架构。

事故的过程很简单:用户反馈部分聊天消息发送失败后没有重试,消息状态始终停留在「发送中」。排查后发现原因更简单——网络层没有统一的异常处理和重试策略。

具体来说,当时的项目里有 4 种不同的网络请求方式:

  • 部分模块用 dio 直接发请求
  • 部分模块封装了自己的 HttpClient
  • 部分模块通过 Platform Channel 走原生侧的 AFNetworking
  • WebView 轻应用里的 H5 用 fetch 走自己的逻辑

四种方式各有各的错误处理和重试策略,有的做了 Token 自动刷新,有的没有。消息发送使用的那个模块刚好没有兜底的重试逻辑,网络抖动一次就永久失败。

这个事故促使我们启动了网络层的统一封装。本文将复盘整个过程,包括整体架构设计、拦截器链、缓存策略、安全机制和跨平台适配。

二、网络层的三层架构

1
2
3
4
5
6
7
8
9
10
┌─────────────────────────────────────────┐
│ 业务层 (Business Layer) │
│ Repository / Service / Dao │
├─────────────────────────────────────────┤
│ 网络抽象层 (Network API) │
│ ApiClient 接口 / 请求模型 / 响应模型 │
├─────────────────────────────────────────┤
│ 网络实现层 (Network Impl) │
│ dio 封装 / 拦截器链 / 证书管理 / 加解密 │
└─────────────────────────────────────────┘

2.1 网络抽象层:业务不关心底层实现

最上层定义统一的 ApiClient 接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
abstract class ApiClient {
Future<ApiResponse<T>> get<T>(
String path, {
Map<String, dynamic>? queryParams,
T Function(Map<String, dynamic>)? fromJson,
});

Future<ApiResponse<T>> post<T>(
String path, {
Map<String, dynamic>? body,
T Function(Map<String, dynamic>)? fromJson,
});

Future<ApiResponse<Uint8List>> download(String url, {String? savePath, ProgressCallback? onProgress});

Future<ApiResponse<T>> upload<T>(
String path,
MultipartFile file, {
Map<String, dynamic>? fields,
ProgressCallback? onProgress,
});
}

业务层只依赖 ApiClient 接口,不 import dio 的任何类。这保证了网络库的可替换性——如果将来需要从 dio 切换到 http 包或其他方案,只需修改实现层。

2.2 网络实现层:基于 dio 的完整封装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class DioApiClient implements ApiClient {
late final Dio _dio;

DioApiClient({required String baseUrl, required List<Interceptor> interceptors}) {
_dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
sendTimeout: const Duration(seconds: 15),
headers: {'Content-Type': 'application/json'},
));
_dio.interceptors.addAll(interceptors);
}

@override
Future<ApiResponse<T>> get<T>(String path, {Map<String, dynamic>? queryParams, T Function(Map<String, dynamic>)? fromJson}) async {
final response = await _dio.get(path, queryParameters: queryParams);
return _parseResponse(response, fromJson);
}

ApiResponse<T> _parseResponse<T>(Response response, T Function(Map<String, dynamic>)? fromJson) {
if (response.statusCode == 200) {
final data = response.data;
if (data == null) return ApiResponse.success(null);
if (fromJson != null && data is Map<String, dynamic>) {
return ApiResponse.success(fromJson(data));
}
return ApiResponse.success(data as T);
}
return ApiResponse.error(ApiException(response.statusCode ?? -1, response.statusMessage ?? 'Unknown error'));
}
}

三、拦截器链:网络层的心脏

拦截器链是网络层最核心的设计。我们设计了五个拦截器,按顺序执行:

3.1 Token 拦截器(第一优先级)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class TokenInterceptor extends Interceptor {
final TokenStorage _tokenStorage;
bool _isRefreshing = false;
final _pendingRequests = <RequestOptions>[];

@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
final token = await _tokenStorage.getAccessToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}

@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode == 401) {
if (!_isRefreshing) {
_isRefreshing = true;
try {
final newToken = await _refreshToken();
_tokenStorage.saveAccessToken(newToken);
// 重试所有等待中的请求
for (final request in _pendingRequests) {
request.headers['Authorization'] = 'Bearer $newToken';
_dio.fetch(request).then((r) => handler.resolve(r));
}
_pendingRequests.clear();
// 重试当前失败的请求
err.requestOptions.headers['Authorization'] = 'Bearer $newToken';
final response = await _dio.fetch(err.requestOptions);
handler.resolve(response);
} catch (e) {
_pendingRequests.clear();
handler.reject(err);
} finally {
_isRefreshing = false;
}
} else {
// 已有刷新进行中,加入等待队列
_pendingRequests.add(err.requestOptions);
}
} else {
handler.next(err);
}
}
}

这个拦截器解决了 Token 管理中最棘手的两个问题:

并发刷新控制:多个请求同时收到 401 时,只有第一个触发刷新,其余加入等待队列。刷新成功后统一重试,避免多次重复刷新。

请求排队:等待中的请求不会丢失,刷新完成后按顺序重试。

在企业 IM 中,聊天页面的消息同步、会话列表的增量更新、通讯录的增量同步可能同时发起。_isRefreshing_pendingRequests 的组合保证了这些并发请求在 Token 过期时不会全部失败。

3.2 日志拦截器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class LogInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
log('[HTTP] ${options.method} ${options.uri}');
log('[HTTP] headers: ${options.headers}');
if (options.data != null) {
// 生产环境脱敏处理
final safeData = _redactSensitiveData(options.data);
log('[HTTP] body: $safeData');
}
handler.next(options);
}

@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
log('[HTTP] ${response.statusCode} ${response.requestOptions.uri}');
log('[HTTP] duration: ${response.requestOptions.extra['startTime'] != null ? DateTime.now().difference(response.requestOptions.extra['startTime']).inMilliseconds : '?'}ms');
handler.next(response);
}

@override
void onError(DioException err, ErrorInterceptorHandler handler) {
log('[HTTP] ERROR ${err.type} ${err.message}');
handler.next(err);
}

Map<String, dynamic> _redactSensitiveData(dynamic data) {
// 对 token、密码等字段做脱敏
if (data is Map<String, dynamic>) {
final safe = Map<String, dynamic>.from(data);
['token', 'password', 'accessToken'].forEach((key) {
if (safe.containsKey(key)) safe[key] = '***';
});
return safe;
}
return {};
}
}

两个关键细节:生产环境下对敏感字段脱敏,记录每个请求的耗时用于性能监控。

3.3 重试拦截器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class RetryInterceptor extends Interceptor {
final int maxRetries;
final List<DioExceptionType> retryableErrors = [
DioExceptionType.connectionTimeout,
DioExceptionType.receiveTimeout,
DioExceptionType.connectionError,
];

RetryInterceptor({this.maxRetries = 3});

@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
final retryCount = err.requestOptions.extra['retryCount'] as int? ?? 0;

if (retryCount < maxRetries && retryableErrors.contains(err.type)) {
err.requestOptions.extra['retryCount'] = retryCount + 1;
// 指数退避:第1次等1秒,第2次等2秒,第3次等4秒
await Future.delayed(Duration(seconds: 1 << retryCount));
try {
final response = await _dio.fetch(err.requestOptions);
handler.resolve(response);
} catch (e) {
handler.next(err);
}
} else {
handler.next(err);
}
}
}

指数退避(Exponential Backoff) 策略:重试间隔依次为 1s、2s、4s,避免在服务端瞬时压力大时发起重试风暴。

重要决策:只重试连接层错误(超时、连接失败),不重试业务层错误(400/500 等 HTTP 状态码)。业务层错误的重试策略应该由业务层自己决定——比如消息发送失败是否重试、扣款请求是否重试,这些决策不能由网络层替业务层做。

3.4 加解密拦截器

企业 IM 中部分敏感数据需要端到端加密(如机密会话的消息体)。加解密拦截器在请求发出前加密 body,在收到响应后解密:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class EncryptionInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
if (options.extra['encrypt'] == true) {
options.data = CryptoUtil.encrypt(options.data);
}
handler.next(options);
}

@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
if (response.requestOptions.extra['encrypt'] == true) {
response.data = CryptoUtil.decrypt(response.data);
}
handler.next(response);
}
}

注意加解密拦截器放在拦截器链的最后,确保加解密发生在 Token 注入和日志记录之后。这样日志中可以看到加密前的请求参数(用于调试)和加密后的网络传输内容(确保安全)。

3.5 缓存拦截器

对于不频繁变化的数据(如企业组织架构、部门列表、表情包列表),我们使用缓存拦截器减少网络请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class CacheInterceptor extends Interceptor {
final CacheStorage _cache;

@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
final cacheDuration = options.extra['cacheDuration'] as Duration?;
if (cacheDuration != null) {
final cachedData = await _cache.get(options.uri.toString());
if (cachedData != null && !cachedData.isExpired) {
// 缓存命中,直接返回
handler.resolve(Response(
requestOptions: options,
data: cachedData.data,
statusCode: 200,
));
return;
}
}
handler.next(options);
}

@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
final cacheDuration = response.requestOptions.extra['cacheDuration'] as Duration?;
if (cacheDuration != null && response.statusCode == 200) {
_cache.set(response.requestOptions.uri.toString(), CacheEntry(
data: response.data,
expiresAt: DateTime.now().add(cacheDuration),
));
}
handler.next(response);
}
}

使用方式:业务层在请求时通过 extra['cacheDuration'] 声明缓存策略,不想缓存的请求不需要做任何改变。

四、跨平台适配

4.1 证书锁定(SSL Pinning)

企业 IM 使用自签名证书的内网环境,需要支持证书锁定:

1
2
3
4
5
6
7
8
9
10
11
12
13
class CertificateInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
if (options.extra['useCertificatePin'] == true) {
(_dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate = (client) {
client.badCertificateCallback = (cert, host, port) {
return _verifyCertificate(cert, host);
};
};
}
handler.next(options);
}
}

4.2 鸿蒙平台的网络适配

鸿蒙平台的网络栈与 Android/iOS 差异较大。在鸿蒙适配过程中,我们遇到了两个问题:

问题一:鸿蒙的 HTTP 客户端对 connectionTimeout 参数处理方式不同,部分请求在弱网环境下连接超时时间比预期短。解决方案是在鸿蒙平台单独调大超时参数。

问题二:鸿蒙的代理设置不会自动生效。在开发环境下需要通过 options.extra['proxy'] 指定 Charles 或 Fiddler 的代理地址,鸿蒙网络适配层自动检测并设置。

五、实战效果

统一网络层上线后的效果:

指标 统一前 统一后
消息发送成功率 97.2% 99.8%
Token 过期导致的重登录率 5%(每天) 0.1%(每天)
网络层相关线上事故 4 次/季度 0 次/季度
新增接口的开发时间 2 天(含异常处理) 0.5 天

最大的收益不是性能指标,而是网络层的问题有了唯一的排查入口。以前线上出现网络异常时,需要逐个模块排查各自的网络实现。现在所有请求都经过同一个拦截器链,日志和监控数据集中,排查效率提升了 10 倍以上。

六、总结

网络层封装的本质是建立统一的关注点分离机制

  1. 业务层不关心网络细节ApiClient 接口屏蔽了 dio、http 的实现差异。
  2. 横切关注点集中在拦截器链。Token 管理、日志、重试、加解密、缓存——这些横切关注点通过拦截器统一处理,而不是散落在各个业务模块中。
  3. 可配置、可插拔。每个请求通过 extra 参数声明自己的特殊需求(是否加密、缓存多久、是否重试),不声明的走默认策略。

一个精心设计的网络层,对业务开发者来说应该是「透明的」——发一个请求,拿回一个结果,中间发生了什么不需要关心。而对架构师来说,它必须是「可控的」——每一个请求的完整生命周期,都在拦截器链的监控和约束之下。

扫描二维码,分享此文章