Flutter之Dio拦截器 2024-06-19 周三
简介
简单使用,直接封装Dio就可以了,本人的文章Flutter之Dio封装一 2024-06-11 周二就是一次实际练习,确实可行。整个过程下来,感觉Dio相比于AFNetworking和Alamofire甚至是Moya都简单好用,确实不错。
一些听上去比较厉害的功能,都是通过拦截器来实现的,可以随时添加功能,确实是一个很好的设计思路。目前,还出现了配合Dio的一些插件,很多也是通过拦截器实现的,真的很不错。
Interceptor简介
- 简单讲就是定义了网络传输前,传输成功,传输失败三个方法;并且定义了这三个方法的默认实现方式都是handler.next(),也就是顺序执行下一个拦截器
class Interceptor {
/// The constructor only helps sub-classes to inherit from.
/// Do not use it directly.
const Interceptor();
/// Called when the request is about to be sent.
void onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) {
handler.next(options);
}
/// Called when the response is about to be resolved.
void onResponse(
Response response,
ResponseInterceptorHandler handler,
) {
handler.next(response);
}
/// Called when an exception was occurred during the request.
void onError(
DioException err,
ErrorInterceptorHandler handler,
) {
handler.next(err);
}
}
-
InterceptorsWrapper继承Interceptor,以一种声明的方式定义这三个方法,把这三个方法放在构造参数的位置,是一个帮助方法,适合在Dio管理类中直接加拦截器。感觉还是尽量少用,推出拦截器的目的是解耦,把一些功能分散到其他类中,这样写又回去了,代码又堆积在管理类中,感觉不怎么好。
-
自定义拦截器,直接继承Interceptor,然后自定义三个方法;不需要三个方法都重写,根据实际需求来就行。每个拦截器实现一个单一功能,符合单职原则,推荐用这种方式
import 'package:dio/dio.dart';
class CustomInterceptors extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
print('REQUEST[${options.method}] => PATH: ${options.path}');
///super.onRequest(options, handler);
handler.next(options);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
print('RESPONSE[${response.statusCode}] => PATH: ${response.requestOptions.path}');
///super.onResponse(response, handler);
handler.next(response);
}
@override
Future onError(DioException err, ErrorInterceptorHandler handler) async {
print('ERROR[${err.response?.statusCode}] => PATH: ${err.requestOptions.path}');
///super.onError(err, handler);
handler.next(err);
}
}
super.xxx()的写法似是而非,应该用handler.xxx()的形式更直观。除了next之外,还有resolve和reject等方式,类似前端的Promise概念,可以根据实际业务的需要进行选择。
- 感觉应该创建一个单例,统一管理这些拦截器,进一步简化Dio管理单例中的代码。
常见的拦截器
1. Token
-
为了鉴权,为了验证身份,当前Token是常见的一种方式。比如上篇文章提到的登录接口,其返回就包含Token信息,登录接口成功之后,客户短会缓存本地,然后在后续个人相关的接口,一般都要在头部加上这个Token,不然的话就会被认为是越权访问而被拒绝。
-
一般情况下,登录成功之后,就会把Token存本地,然后在请求的时候带上就可以了,一般会加在headers中。后端根据需要,去headers中取对应的值就可以了。
-
根据以上的需求,只要重写onRequest方法,往headers中添加几个字段就可以了。
class TokenInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
options.headers['Authorization'] = LocalStorageUtil.getToken();
handler.next(options);
}
}
这里Authorization字段就是Token;一般在登录成功之后会存本地,而这里只要从本地缓存读取就好了,有就设置,没有就给个空字符串。具体的处理,交给后端就可以了。
默认的super.onRequest(options, handler);这里改成了handler.next(options);意图更加明确;
2. Log
-
为了调试网络,log能带来很大的帮助。不过在release版本,最好能关闭log,流量只是小事,信息泄露风险才是大事。
-
在Flutter中debugPrint这个千万不要用,这个字面意思有严重的误导,在生产模式,这个也会输出log,是个严重脑残的设计。
选项A:Dio提供deLogInterceptor
- 虽然简陋了点,但是方便省事;log方法用一个Assert包了一下,算是比较取巧的方法。默认参数设置得不合理,需要改一下。
LogInterceptor({
this.request = true, /// 这个一般要用
this.requestHeader = true, /// 这个很鸡肋,很多信息在request中有;不过一般都要开,前后端有些约定是放在头部的,比如Token之类的
this.requestBody = false, /// 这个要改成true,post的参数能显示出来,但是FormData的就不行了
this.responseHeader = true, /// 这个要改成false,大多数情况这个不会管
this.responseBody = false, /// 这个要改成true,这个是最重要的。响应数据都不看的话,你log个啥,脑子抽风了才会设置为false
this.error = true, /// 这个保持true不动
this.logPrint = _debugPrint,
});
/// assert包一下,保证release不输出,比较取巧的做法
void _debugPrint(Object? object) {
assert(() {
print(object);
return true;
}());
}
- 用这个的话很简单,只要一句话,把这个拦截器加入就可以了,简单省事。并且Dio的说明文档中建议log的拦截器加在最后,这样有利于把其他拦截器所做的修改也能打印出来,说得还是挺有道理的。
/// Note: LogInterceptor should always be the last interceptor added, otherwise modifications by following interceptors will not be logged.
_dio.interceptors.add(LogInterceptor(responseBody: true, requestBody: true, responseHeader: false, requestHeader: true));
- 除了简单,其他的优点很少,缺点很多。图省事的情况下,勉强可用。效果如下,可以自己感受下:
flutter: *** Request ***
flutter: uri: http://47.92.232.69:8080/sign/signUp/password
flutter: method: POST
flutter: responseType: ResponseType.json
flutter: followRedirects: true
flutter: persistentConnection: true
flutter: connectTimeout: 0:00:05.000000
flutter: sendTimeout: null
flutter: receiveTimeout: 0:00:03.000000
flutter: receiveDataWhenStatusError: true
flutter: extra: {}
flutter: headers:
flutter: Accept: application/json,*/*
flutter: ContentType: application/json; charset=utf-8
flutter: platform: ios
flutter: content-type: application/json; charset=utf-8
flutter: data:
flutter: {phone: 138xxxxxxxx, password: 123456}
flutter:
flutter: *** Response ***
flutter: uri: http://47.92.232.69:8080/sign/signUp/password
flutter: Response Text:
flutter: {"code":-1,"data":null,"errMsg":"账号不存在"}
选项B:插件pretty_dio_logger
- 使用简单,和LogInterceptor差不多,默认参数稍微改一下就可以了
PrettyDioLogger(
{this.request = true, /// 保持true
this.requestHeader = false, /// 需要调Header参数的时候改为true,稳定后可以保持false
this.requestBody = false, /// 改为true,post参数,表单数据也能看
this.responseHeader = false, /// 保持false
this.responseBody = true, /// 保持true
this.error = true, /// 保持默认
this.maxWidth = 90, /// 保持默认
this.compact = true, /// 保持默认
this.logPrint = print}); /// 这个要改,系统的print不应该直接用,release模式不应该有log
- 打印函数直接用print不合适,用判断条件包一下就好,用Assert那种取巧的方法没有必要
/// Debug模式才输出log
void _debugLogPrint(Object object) {
if (kDebugMode) {
print(object);
}
}
- 使用和LogInterceptor一样简单,只需要一句话,添加拦截器就好了,最好遵循建议,加在最后。
_dio.interceptors.add(PrettyDioLogger(
request: true,
requestHeader: true,
requestBody: true,
responseHeader: false,
responseBody: true,
logPrint: _debugLogPrint,
));
- 输出的效果比LogInterceptor好很多,表单数据的Post请求参数也能打印出来。一个例子如下,可以对比一下:
flutter: ╔╣ Request ║ POST
flutter: ║ http://47.92.232.69:8080/sign/signUp/password
flutter: ╚══════════════════════════════════════════════════════════════════════════════════════════╝
flutter: ╔ Headers
flutter: ╟ Accept: application/json,*/*
flutter: ╟ ContentType: application/json; charset=utf-8
flutter: ╟ platform: ios
flutter: ╟ content-type: application/json; charset=utf-8
flutter: ╟ contentType: application/json; charset=utf-8
flutter: ╟ responseType: ResponseType.json
flutter: ╟ followRedirects: true
flutter: ╟ connectTimeout: 0:00:05.000000
flutter: ╟ receiveTimeout: 0:00:03.000000
flutter: ╚══════════════════════════════════════════════════════════════════════════════════════════╝
flutter: ╔ Form data | --dio-boundary-2480454291
flutter: ╟ phone: 15858109291
flutter: ╟ password: 123456
flutter: ╚══════════════════════════════════════════════════════════════════════════════════════════╝
flutter:
flutter: ╔╣ Response ║ POST ║ Status: 200
flutter: ║ http://47.92.232.69:8080/sign/signUp/password
flutter: ╚══════════════════════════════════════════════════════════════════════════════════════════╝
flutter: ╔ Body
flutter: ║
flutter: ║ {
flutter: ║ code: 0,
flutter: ║ "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJjdXJyZW50IjoxNzE4ODUzNzQzOTkyLCJ1c2VyTmFt
flutter: ║ ZSI6IjE1ODU4MTA5MjkxIiwiZXhwIjoxNzUwMzg5NzQzLCJpYXQiOjE3MTg4NTM3NDN9.M45Ie_Wyr_Hni
flutter: ║ mmtp8-W6NpwH9ueaR-qymKAGXjT9iQ"
flutter: ║ errMsg: "处理成功"
flutter: ║ }
flutter: ║
flutter: ╚══════════════════════════════════════════════════════════════════════════════════════════╝
- 可选:Header在一开始的时候会看看,有些字段前后端可能会约定放在Header部分。成熟之后,可以考虑把Header隐藏掉,会更简洁。url, 参数,method,响应内容,这几个是最常用,最关心的。比如上面的例子隐藏Header之后的效果如下:
flutter: ╔╣ Request ║ POST
flutter: ║ http://47.92.232.69:8080/sign/signUp/password
flutter: ╚══════════════════════════════════════════════════════════════════════════════════════════╝
flutter: ╔ Form data | --dio-boundary-0514650006
flutter: ╟ phone: 15858109291
flutter: ╟ password: 123456
flutter: ╚══════════════════════════════════════════════════════════════════════════════════════════╝
flutter:
flutter: ╔╣ Response ║ POST ║ Status: 200
flutter: ║ http://47.92.232.69:8080/sign/signUp/password
flutter: ╚══════════════════════════════════════════════════════════════════════════════════════════╝
flutter: ╔ Body
flutter: ║
flutter: ║ {
flutter: ║ code: 0,
flutter: ║ "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJjdXJyZW50IjoxNzE4ODU0NDUyMDgyLCJ1c2VyTmFt
flutter: ║ ZSI6IjE1ODU4MTA5MjkxIiwiZXhwIjoxNzUwMzkwNDUyLCJpYXQiOjE3MTg4NTQ0NTJ9.KkfTtiWFHwjrX
flutter: ║ wOuVcd7p3HQZo0me0eR_mijLbxUcok"
flutter: ║ errMsg: "处理成功"
flutter: ║ }
flutter: ║
flutter: ╚══════════════════════════════════════════════════════════════════════════════════════════╝
通常的做法,一开始会打开Request的Header,和后端对照Header参数;稳定后可以关闭Request的Header,基本上都一样,简洁一些更好。
选项C:自定义Interceptor,自己写一个
这个就不说了,有现成的代码可以抄,根据自己的意愿改。
3. 网络状态检查
-
在传输数据之前,进行网络状态检查是有必要的。如果断网了,那么就可以直接错误退出,没有必要傻傻等超时。
-
网络状态检查,可以写在通用的request方法中,不过有拦截器这么好用的手段,在一个独立的拦截其中做更合适,更有利于解耦。还有网络状态实时监控什么的,都可以做在这个拦截器中,更容易扩展。
-
按照通常的理解,网络状态检查应该作为第1个拦截器,一旦判断是断网情况,直接handler.reject()就可以了,后续的拦截器什么的都不需要执行。甚至这个时候都可以考虑加个toast提醒用户。本次就打算采用这种武断的方式。
-
另外一种思路是就算断网,后续的拦截器也应该执行,比如log拦截器,记录一下状态也好。这个根据具体情况,具体取舍就好,怎么做都可以。
-
网络状态检查,有个比较流行的插件connectivity_plus,点赞数量比较多,直接拿来用,没必要自己再整一套。
插件热度高
-
按照上面的理解,只要重写onRequest一个方法就可以了。
class NetworkStatusInterceptor extends Interceptor {
@override
void onRequest(
RequestOptions options, RequestInterceptorHandler handler) async {
List<ConnectivityResult> resultList =
await (Connectivity().checkConnectivity());
if (resultList.contains(ConnectivityResult.none)) {
LogUtil.log("检测到网络不通,请检查联网状态", level: LogLevel.fatal);
handler.reject(
DioException.connectionError(requestOptions: options, reason: "无网络"));
} else {
handler.next(options);
}
}
}
- 虽然插件connectivity_plus热度很高,但是有个很大的坑,就是用iOS模拟器通过关闭WiFi的形式来调试的时候,网络状态判断不准。据说真机是可以的。这篇文章Flutter 检查连接网络 connectivity_plus提到了这一点。
4. 错误处理
- 一种是网络错误,Http返回值不是2XX(一般是200)的那种;这个就是通常认为的网络异常。DioException就是用来描述这些错误的,具体的错误类型用了一个枚举类型DioExceptionType来列举。每种类型都有一个对应的DioException,其中的message字段就是错误原因。只是其中的描述是英文的,不是很友好,可以考虑自己提供一套。由于这个时候,后端没有返回数据,所以可以模仿网络正常的时候,自定义一套code和message,放入DioException的response字段。
/// The exception enumeration indicates what type of exception
/// has happened during requests.
enum DioExceptionType {
/// Caused by a connection timeout.
connectionTimeout,
/// It occurs when url is sent timeout.
sendTimeout,
///It occurs when receiving timeout.
receiveTimeout,
/// Caused by an incorrect certificate as configured by [ValidateCertificate].
badCertificate,
/// The [DioException] was caused by an incorrect status code as configured by
/// [ValidateStatus].
badResponse,
/// When the request is cancelled, dio will throw a error with this type.
cancel,
/// Caused for example by a `xhr.onError` or SocketExceptions.
connectionError,
/// Default error type, Some other [Error]. In this case, you can use the
/// [DioException.error] if it is not null.
unknown,
}
- 另外一种错误叫做逻辑错误。网络是没问题的,服务器有响应,但是不符合业务规则,所以定义为错误。比如,没有Token,访问个人用户数据,就会定义为业务错误。比如这样的:
{"code":-1,"data":null,"errMsg":"账号不存在"}
有些特殊的业务,需要特殊处理,可以根据和后端的约定,判断code的值,然后在这里统一处理。比如,没有Token的时候,约定code是405,就弹出登录对话框。
- 对于网络错误,可以根据DioException中的DioExceptionType对特定类型进行进行特殊处理。比如我们对证书问题,连接问题,取消问题三种情况比较关注,先记一下log,提醒开发者注意。
class ErrorInterceptor extends Interceptor {
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
final code = response.data["code"];
if (code == 405) {
ToastUtil.showText(text: "Token过期,请重新登录");
}
handler.next(response);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
switch (err.type) {
case DioExceptionType.badCertificate:
LogUtil.log("证书问题", level: LogLevel.warning);
case DioExceptionType.cancel:
LogUtil.log("请求被取消了", level: LogLevel.warning);
case DioExceptionType.connectionError:
LogUtil.log("连接问题,请检查网络状态", level: LogLevel.warning);
default:
// 什么也不做
}
handler.next(err);
}
}
拦截器统一管理
将添加拦截器的代码集中到一个文件,简化主流程的复杂度。
class InterceptorManager {
/// 单例
static final InterceptorManager _instance = InterceptorManager._internal();
factory InterceptorManager() => _instance;
InterceptorManager._internal() {
/// 网络状态拦截器开始侦听网络
networkStatusInterceptor = NetworkStatusInterceptor();
networkStatusInterceptor.startMonitorNetworkStatus();
}
/// 网络状态拦截器
late NetworkStatusInterceptor networkStatusInterceptor;
/// iOS模拟器,插件connectivity_plus有问题,所以给这个拦截器加个开关
bool isCheckNetworkStatus = false;
/// 添加拦截器
void addTo(Dio dio) {
/// 网络状态, 一般放第一个
if (isCheckNetworkStatus) {
dio.interceptors.add(networkStatusInterceptor);
}
/// token
dio.interceptors.add(TokenInterceptor());
/// 错误处理
dio.interceptors.add(ErrorInterceptor());
/// 日志; 一般放最后
dio.interceptors.add(PrettyDioLogger(
request: true,
requestHeader: false,
requestBody: true,
responseHeader: false,
responseBody: true,
logPrint: _debugLogPrint,
));
}
/// Debug模式才输出log
void _debugLogPrint(Object object) {
if (kDebugMode) {
print(object);
}
}
}
Loading和Toast
这两个没什么内容,并且需要变量控制,还是直接放在主流程比较好,不适合做成拦截器。考虑了Loading和Toast之后的request代码变成如下的样子:
Future<BaseResponse> doRequest(
String path,
HttpMethod method, {
Map<String, dynamic>? parameters,
bool isShowLoading = false,
bool isShowToast = true,
}) async {
Options options = Options(method: method.value);
/// 根据method类型处理参数
Object? data;
Map<String, dynamic>? queryParameters;
switch (method) {
case HttpMethod.get:
queryParameters = parameters;
case HttpMethod.post:
data = parameters;
case HttpMethod.postFormData:
if (parameters != null) {
data = FormData.fromMap(parameters);
}
}
/// 包装成自定义的响应;后端自定义的数据优先
BaseResponse baseResponse = BaseResponse();
try {
if (isShowLoading) {
LottieUtils.showToastLoading();
}
/// 统一调用request进行数据传输
final dioResponse = await _dio.request(
path,
data: data,
queryParameters: queryParameters,
options: options,
);
baseResponse.code = dioResponse.data?["code"];
baseResponse.data = dioResponse.data?["data"];
baseResponse.errMsg = dioResponse.data?["errMsg"];
} on DioException catch (e) {
/// 异常时,将DioException转化为自定义的baseResponse
baseResponse = BaseResponse.fromException(e);
} finally {
if (isShowLoading) {
LottieUtils.hideToastLoading();
}
if (isShowToast && !baseResponse.isSuccess) {
ToastUtil.showText(text: baseResponse.errMsg ?? "");
}
}
return baseResponse;
}
缓存和Cookie
这两个有现成的插件可用,暂时也不知道具体的价值,也没有想到必须导入的原因,网上的评价也一般,所以不考虑引入,等需要的时候再加就可以了。拦截器可以随时加,很方便的。另外,重试,代理等功能也是一样,不是强需求,可以必要的时候再考虑加。
dio_cache_interceptor
dio_cookie_manager