网络防抖之Dio请求去重
前言
我注意到很多开源的 Flutter 项目并没有处理网络请求的去重,或者一股脑的不管三七二十一先取消请求再重新发起请求。但是我感觉更多的开发者根本就不关心网络的重复请求,管他的反正不是我的流量,干嘛要去费力不讨好的做去重。
真的是这样吗?其实除了浪费用户流量这一微不足道的确定,还有很多其他去重的理由:
-
减少服务器压力: 服务器需要处理大量的请求,如果重复请求频繁发生,可能会增加服务器的负担。通过避免重复请求,可以减轻服务器压力,提高服务器性能。
-
节省用户流量: 重复的网络请求会占用用户的网络流量,特别是对于移动数据用户。通过避免重复请求,可以降低应用对用户流量的消耗,提升用户体验。
-
提高应用性能: 重复的网络请求可能导致应用性能下降,特别是在网络条件较差的情况下。避免重复请求可以提高应用的响应速度和整体性能。
-
节省电池寿命: 网络请求是消耗电池的重要因素之一。减少不必要的网络请求可以延长移动设备的电池寿命,提升用户体验。
-
避免数据不一致性: 重复的网络请求可能导致数据不一致性,例如在短时间内多次请求同一资源,但得到的结果可能不同。通过避免重复请求,可以保持数据的一致性。
如果我们想要去重也并不是只有通过Dio的配置与封装来实现,其实也有很多其他的方式实现,例如:
-
请求去重: 在发起网络请求前,检查当前是否已经有相同的请求在处理,如果是,则取消或等待当前请求,以避免发送重复的请求。
-
缓存机制: 使用合适的缓存策略,对于相同的请求可以先从缓存中获取数据,避免实际的网络请求。这对于一些静态数据或者数据不经常更新的情况非常有效。
-
用户交互触发: 通过用户的实际操作来触发网络请求,而不是在短时间内频繁发起相同的请求。例如,在用户点击按钮的时候弹窗Loading避免重复点击,或者在按钮上实现节流与防抖实现不让事件生效。
-
使用状态管理: 在Flutter中,使用状态管理工具(如Provider、Bloc)可以更好地控制网络请求的触发时机,避免在不同页面或组件中重复发起相同的请求,甚至可以管理按钮状态,直接不让按钮Enable。
方法有很多但是也有各自的局限性。
使用状态管理和Loading弹框都有局限性,一些按钮和操作不适合弹窗,并且如果用户想要修改网络请求则需要等上一个网络请求成功才能生效,如果此时网络不稳定很慢的话,简直无法想象。
使用事件防抖的效果会相对更好,但是也会真实发出多次请求,如果网络有波动,可能新发出的请求比上次的请求要更慢,也会出现网络结果的返回值不符合预期。关于事件防抖的实现可以参考我之前的文章。
使用缓存机制也存在弊端,第一个是一般我们只缓存Get请求不变化的数据,而一般业务逻辑参数变化较大的都是Post请求,并且对于一些常变的数据例如朋友圈列表的Get请求不适用缓存机制,所以也不是很推荐。
而请求去重的效果也会相对更好一点,在网络层就可以根据策略丢弃当前请求和取消之前的请求等不同的策略,封装之后内部自动实现,至于缺点第一个是微量的额外的内存开销,需要保存一些Map资源,第二个是Loading不好控制,说来话长后面我们边讲边说。
本文就是基于请求去重的方案展开,如何基于常用的 Dio 网络请求框架实现可配置的自动的请求去重。
一、方案与思路
之前我们讲到实现网络请求去重的方法有很多,各有利弊,我们今天只探讨其中Dio请求去重的方案。
一般来说一个简单的网络请求去重方案最终实现的效果是,如果是同一个完全一致的请求,我们可以丢弃当前的请求,如果是一个请求但是参数不同,我们认为它不是一个请求,需要取消前者请求,重新发起请求。
-
可配置:最重要的就是可配置,不是每一个 Dio 网络请求都需要去重,可以简单的想象一个场景,如果每一个网络请求都默认去重了,那么当我们分页加载列表数据的时候就会导致第一页的数据被取消掉,简直是灾难。所以我们最重要的就是可配置当前请求是否开启去重配置。
-
是否是同一个请求:我们需要把请求的地址和参数等序列化数据保存起来,当一个新请求过来的时候,我们需要在内存Map中查询是否已经有同样的请求了,如果有则校验参数是否都相同。
-
去重的策略:如果是同一个请求,我们要直接返回不处理,如果不是同一个请求,我们要找到之前请求的 CancelToken 取消之前的请求,并且发起新的请求。
-
取消的策略:由于我们的 UI Page 对应的 Controller 内部维护有 CancelToken,方便 Dio 的请求在页面关闭的时候取消当前页面的请求避免异常,所以我们需要可选的 CancelToken 是自己管理还是由 Controller 传递过来。
理清楚这些思路之后,我们就能实现一个简单的网络防抖之请求去重方案了。
二、方法地址与参数的校验
由于我之前的文章中已经简单封装过 Dio 的网络引擎【传送门】,内部已经实现了基本的 Post Get 请求,并且实现了基本的缓存策略,我们接下来就基于此基础上实现网络请求去重。
为了避免大家找不到代码,我先把整块的代码贴出:
/// 封装网络请求入口
Future<HttpResult> requestNetResult(
String url, {
HttpMethod method = HttpMethod.GET, //指明Get还是Post请求
Map<String, String>? headers, //请求头
Map<String, dynamic>? params, //请求参数,Get的Params,Post的Form
Map<String, String>? paths, //文件Flie
Map<String, Uint8List>? pathStreams, //文件流
CacheControl? cacheControl, // Get请求是否需要缓存
Duration? cacheExpiration, //缓存是否需要过期时间,过期时间为多长时间
ProgressCallback? send, // 上传进度监听
ProgressCallback? receive, // 下载监听
CancelToken? cancelToken, // 用于取消的 token,可以多个请求绑定一个 token
bool networkDebounce = false, // 当前网络请求是否需要网络防抖去重
}) async {
//尝试网络请求去重,内部逻辑判断发起真正的网络请求
return _tryNetworkdebounce(
url,
method,
headers,
params,
paths,
pathStreams,
cacheControl,
cacheExpiration,
send,
receive,
cancelToken,
networkDebounce,
);
}
首先我们在网络请求的唯一入口处加入了参数 networkDebounce 来控制是否需要加入请求去重的逻辑,如果不需要请求去重,则不会走现有逻辑不会有额外的内存开销。
而主要的逻辑在 tryNetworkdebounce 中:
static final Map<String, CancelToken> _cancelTokenMap = {}; // 保存每个请求的 CancelToken
static final Map<String, String> _urlParamsMap = {}; // 保存每个请求的url与params的序列化对应关系
/// 根据变量控制,当前网络请求是否需要去重防抖,内部逻辑调用真正的网络请求
Future<HttpResult> _tryNetworkdebounce(
String url,
HttpMethod method,
Map<String, String>? headers,
Map<String, dynamic>? params,
Map<String, String>? paths, //文件
Map<String, Uint8List>? pathStreams, //文件流
CacheControl? cacheControl,
Duration? cacheExpiration,
ProgressCallback? send, // 上传进度监听
ProgressCallback? receive, // 下载监听
CancelToken? cancelToken, // 用于取消的 token,可以多个请求绑定一个 token
bool networkDebounce, // 当前网络请求是否需要网络防抖去重
) async {
if (networkDebounce) {
//拿到当前的url对应的Map中的数据
final urlkey = _generateKeyByMethodUrl(method, url);
Log.d("_urlParamsMap:${_urlParamsMap.toString()}");
final urlserializedParams = _urlParamsMap[urlkey];
final serializedParams = _serializeAllParams(headers, params, paths, pathStreams);
Log.d("urlserializedParams:${urlserializedParams.toString()} serializedParams:${serializedParams.toString()}");
if (urlserializedParams == null) {
//说明没有缓存,添加缓存
_urlParamsMap[urlkey] = serializedParams;
//正常请求
return _executeRequests(
url, method, headers, params, paths, pathStreams, cacheControl, cacheExpiration, send, receive, cancelToken, networkDebounce);
} else {
//有缓存对比
if (serializedParams == urlserializedParams) {
//如果两者相同,说明是重复的请求,舍弃当前的请求
Log.d("是重复的请求,舍弃当前的请求");
final completer = Completer<HttpResult>();
completer.complete(HttpResult(
isSuccess: false,
code: 202,
msg: "Request canceled",
errorMsg: "Request canceled",
));
return completer.future;
} else {
//如果两者不相,说明不是重复的请求,需要取消之前的网络请求,发起新的请求
Log.d("不是重复的请求,需要取消之前的网络请求,发起新的请求");
//拿到当前请求的cancelToken
final previousCancekKey = "$urlkey - $urlserializedParams";
final previousCancelToken = _cancelTokenMap[previousCancekKey];
Log.d("previousCancekKey:${previousCancekKey.toString()} previousCancelToken:${previousCancelToken.toString()}");
if (previousCancelToken != null) {
cancelDio(previousCancelToken);
_urlParamsMap.remove(urlkey);
_cancelTokenMap.remove(previousCancekKey);
}
//添加缓存
_urlParamsMap[urlkey] = serializedParams;
//再次正常请求
return _executeRequests(
url, method, headers, params, paths, pathStreams, cacheControl, cacheExpiration, send, receive, cancelToken, networkDebounce);
}
}
} else {
//正常请求
return _executeRequests(
url, method, headers, params, paths, pathStreams, cacheControl, cacheExpiration, send, receive, cancelToken, networkDebounce);
}
}
按照之前的步骤,我进行了关键节点详细的注释,用 _urlParamsMap 来保存网络请求的地址与参数,用于判断是否是同一个网络请求,用 _cancelTokenMap 来保存每一个需要去重的请求它对应的 CancelToken 。
而这些Map的生成Key和序列化参数等方法如下:
//根据请求方式和Url生成Key
String _generateKeyByMethodUrl(HttpMethod method, String url) {
return "${method.name.toString()}-$url";
}
String _generateCacelKey(HttpMethod method, String url, Map<String, String>? headers, Map<String, dynamic>? params, Map<String, String>? paths,
Map<String, Uint8List>? pathStreams) {
return "${_generateKeyByMethodUrl(method, url)} - ${_serializeAllParams(headers, params, paths, pathStreams)}";
}
String _serializeAllParams(
Map<String, String>? headers, Map<String, dynamic>? params, Map<String, String>? paths, Map<String, Uint8List>? pathStreams) {
final serializedParams = _serializeMap(params);
final serializedHeaders = _serializeMap(headers);
final serializedpaths = _serializeMap(paths);
final serializedStreams = _serializeMap(pathStreams);
return "$serializedParams - $serializedHeaders - $serializedpaths - $serializedStreams";
}
//参数序列化为唯一字符串
String _serializeMap<T>(Map<String, T>? map) {
if (map == null || map.isEmpty) {
return '';
}
final sortedKeys = map.keys.toList()..sort();
final serializedList = sortedKeys.map((key) => '$key=${map[key]}').toList();
return serializedList.join('&');
}
至于 CancelToken 的加入和取消我们则需要额外的处理。
三、CancelToken兼容Controller的页面网络请求自动取消
在之前的文章中,我们实现了 UI Page 对应的网络请求自动取消,页面对应的 Controller 维护了一套 CancelToken, 我改怎么让网络请求不一致的时候需要取消上一个请求的时候让这个 CancelToken 生效呢?
其实前面的代码已经给出了答案:
Future<HttpResult> _tryNetworkdebounce(
...
CancelToken? cancelToken, // 用于取消的 token,可以多个请求绑定一个 token
bool networkDebounce, // 当前网络请求是否需要网络防抖去重
)
这里的 cancelToken 是一个可选的参数,如果对方 Controller 并没有设置 CancelToken,我们只需要自己 new 一个即可。
具体的代码如下:
Future<HttpResult> _executeRequests(
String url,
HttpMethod method,
Map<String, String>? headers,
Map<String, dynamic>? params,
Map<String, String>? paths, //文件
Map<String, Uint8List>? pathStreams, //文件流
CacheControl? cacheControl,
Duration? cacheExpiration,
ProgressCallback? send, // 上传进度监听
ProgressCallback? receive, // 下载监听
CancelToken? cancelToken, // 用于取消的 token,可以多个请求绑定一个 token
bool networkDebounce, // 当前网络请求是否需要网络防抖去重
) async {
//自动添加CancelToken的逻辑
String? cancelKey;
if (networkDebounce) {
cancelKey = _generateCacelKey(method, url, headers, params, paths, pathStreams);
cancelToken ??= CancelToken();
_cancelTokenMap[cancelKey] = cancelToken;
}
... //原有的网络请求,缓存处理,数据格式处理等逻辑
//自动移除CancelToken的逻辑
if (networkDebounce && cancelKey != null) {
final urlkey = _generateKeyByMethodUrl(method, url);
_urlParamsMap.remove(urlkey);
_cancelTokenMap.remove(cancelKey);
}
}
_executeRequests 是抽取出来的方法,就是之前的代码一样的逻辑抽取出来的,我们只需要在请求前处理添加 CancelToken,以及在请求后移除 CancelToken 即可。
到处完整的代码和流程就完成了,下面一起看看效果。
四、测试Log
以我们常用的需要防抖的场景为例,输入框输入之后监听到输入文本进行网络查询,CheckBox,RaidoButton Switch 等控件的选中未选中之后进行网络请求设置状态。
这些场景都是最普通最常见的网络防抖要求场景,我这里就以我们项目中的一个的 Swtich 控件为例,开启或关闭用户的推送功能为例。
拿出我们单身XX年的手速,疯狂点击按钮:
我们的逻辑如下:
void changed(bool checked) {
_resetRegistrationIdId(checked ? UserService.to.getRegistrationId : "null");
}
/// 调用接口重新设置 registrationId
void _resetRegistrationIdId(String registrationId) async {
if (UserService.to.isLogin) {
//获取到数据
HttpResult result = await mainRepository.resetRegistrationId(registrationId);
//处理数据
if (result.isSuccess) {
Log.d("MainController 重置 registrationId 成功");
} else {
Log.d("MainController 重置 registrationId 失败:${result.errorMsg}");
}
}
}
Switch 控件的监听注册的推送Id是不同的,所以当我们开启网络防抖之后,此时应该是先取消之前的请求,再重新发起请求。不管你点多少下只要之前的请求没有响应,那么只有发出最后一次请求:
结果是符合我们的预期的,那么如果我想要重复网络请求的去重呢?
我们修改一下代码,把发出请求的 resetRegistrationId 固定写死一个数值,那么它就是每次请求都是重复的网络请求:
//...
HttpResult result = await mainRepository.resetRegistrationId(UserService.to.getRegistrationId);
//...
此时我们再次疯狂的点击,看看和之前的效果有什么不同?
看 Log 就很清晰,由于是同一个网络请求,参数都是完全一致,所以就舍弃了当前的请求,等待第一次发出的请求响应。
总结
本文先介绍了为什么需要网络防抖以及网络防抖的几种方案,本文的重点主体是关于 Dio 请求去重这种方案。
我总是声明我的观点,具体问题具体分析,看场景实现需求,没有银弹,没有万全方案,例如我们之前讲到的的几种方案每一种方案都有各自的使用场景,万不可生搬硬套去把全部的网络防抖去重都走 Dio 请求去重这一种方案。
例如分页列表的请求,用得着你去重吗?根本不需要呀,你要是硬加上了请求去重的方案,根本没效果甚至还有反效果。一些按钮点击请求网络的场景其实用上事件节流加上Loading弹窗即可,也可以不用网络请求去重。
当然各位高工都是有经验的开发者,不需要我过多提醒,这里就不展开多说了。
关于 Dio 请求去重这一种方案,除了在网络封装中可以写,我们也可以利用 Dio 的拦截器来实现,其实包括缓存的逻辑,去重的逻辑,都能通过拦截器来解耦逻辑,更加的结构清晰,后期我会出一篇拦截器解耦与封装。
那么本期内容就到这里,如讲的不到位或错漏的地方,希望同学们可以评论区指出,如果有更多更好更方便的方式也欢迎大家评论区交流。
本文的代码已经全部贴出,部分没贴出的代码可以在前文中找到,也可以到我的 Flutter Demo 查看源码【传送门】 。
如果感觉本文对你有一点点的启发,还望你能点赞
支持一下,你的支持是我最大的动力啦!
Ok,这一期就此完结。
原文链接:https://juejin.cn/post/7341288089494437915 作者:Newki