学线培训:winter-flutter:网络、同步与异步
Dio:
前言
热知识:Dio是国人开发的网络请求库,所以中文文档很完善。
又:目前最新版本为dio: ^4.0.5-beta1
,在其github的develop分支,不过并不建议使用。以下基于master的dio: ^4.0.3 / dio: ^4.0.4
大致看了一下改变并不大。基本直接搬运github文档,免得有同学上不去github。
总而言之跟okhttp挺像的。
添加依赖
1 | dependencies: |
如果你是dio 3.x 用户,想了解4.0的变更,请参考 4.x更新列表!
一个极简的示例
1 | import 'package:dio/dio.dart'; |
*示例
发起一个 GET
请求 :
1 | Response response; |
发起一个 POST
请求:
1 | response = await dio.post('/test', data: {'id': 12, 'name': 'wendu'}); |
发起多个并发请求:
1 | response = await Future.wait([dio.post('/info'), dio.get('/token')]); |
下载文件:
1 | response = await dio.download('https://www.google.com/', './xx.html'); |
以流的方式接收响应数据:
1 | Response<ResponseBody> rs; |
以二进制数组的方式接收响应数据:
1 | Response<List<int>> rs |
发送 FormData:
1 | var formData = FormData.fromMap({ |
通过FormData上传多个文件:
1 | var formData = FormData.fromMap({ |
监听发送(上传)数据进度:
1 | response = await dio.post( |
以流的形式提交二进制数据:
1 | List<int> postData = <int>[...]; |
注意:如果要监听提交进度,则必须设置content-length,否则是可选的。
示例目录
你可以在这里查看dio的全部示例.
Dio APIs
创建一个Dio实例,并配置它
建议在项目中使用Dio单例,这样便可对同一个dio实例发起的所有请求进行一些统一的配置,比如设置公共header、请求基地址、超时时间等;这里有一个在Flutter工程中使用Dio单例(定义为top level变量)的示例供开发者参考。
你可以使用默认配置或传递一个可选 BaseOptions
参数来创建一个Dio实例 :
1 | var dio = Dio(); // with default Options |
Dio实例的核心API是 :
Future request(String path, {data,Map queryParameters, Options options,CancelToken cancelToken, ProgressCallback onSendProgress, ProgressCallback onReceiveProgress)
1 | response = await dio.request( |
请求方法别名
为了方便使用,Dio提供了一些其它的Restful API, 这些API都是request
的别名。
Future get(…)
Future post(…)
Future put(…)
Future delete(…)
Future head(…)
Future put(…)
Future path(…)
Future download(…)
Future fetch(RequestOptions)
*请求配置
BaseOptions
描述的是Dio实例发起网络请求的的公共配置,而Options
类描述了每一个Http请求的配置信息,每一次请求都可以单独配置,单次请求的Options
中的配置信息可以覆盖BaseOptions
中的配置,下面是BaseOptions
的配置项:
1 | { |
这里有一个完成的示例.
*响应数据
当请求成功时会返回一个Response对象,它包含如下字段:
1 | { |
示例如下:
1 | Response response = await dio.get('https://www.google.com'); |
拦截器
每个 Dio 实例都可以添加任意多个拦截器,他们组成一个队列,拦截器队列的执行顺序是FIFO。通过拦截器你可以在请求之前、响应之后和发生异常时(但还没有被 then
或 catchError
处理)做一些统一的预处理操作。
1 | dio.interceptors.add(InterceptorsWrapper( |
一个简单的自定义拦截器示例:
1 | import 'package:dio/dio.dart'; |
完成和终止请求/响应
在所有拦截器中,你都可以改变请求执行流, 如果你想完成请求/响应并返回自定义数据,你可以resolve一个 Response
对象或返回 handler.resolve(data)
的结果。 如果你想终止(触发一个错误,上层catchError
会被调用)一个请求/响应,那么可以reject一个DioError
对象或返回 handler.reject(errMsg)
的结果.
1 | dio.interceptors.add(InterceptorsWrapper( |
QueuedInterceptor
如果同时发起多个网络请求,则它们是可以同时进入Interceptor
的(并行的),而 QueuedInterceptor
提供了一种串行机制:它可以保证请求进入拦截器时是串行的(前面的执行完后后面的才会进入拦截器)。
例子
假设这么一个场景:出于安全原因,我们需要给所有的请求头中添加一个csrfToken,如果csrfToken不存在,我们先去请求csrfToken,获取到csrfToken后再重试。假设刚开始的时候 csrfToken 为 null ,如果允许请求并发,则这些并发请求并行进入拦截器时 csrfToken 都为null,所以它们都需要去请求 csrfToken,这会导致 csrfToken 被请求多次,为了避免不必要的重复请求,可以使用 QueuedInterceptor,这样只需要第一个请求请求一次即可,示例代码如下:
1 | dio.interceptors.add(QueuedInterceptorsWrapper( |
完整的示例代码请点击 这里.
日志
我们可以添加 LogInterceptor
拦截器来自动打印请求、响应日志, 如:
1 | dio.interceptors.add(LogInterceptor(responseBody: false)); //开启请求日志 |
由于拦截器队列的执行顺序是FIFO,如果把log拦截器添加到了最前面,则后面拦截器对
options
的更改就不会被打印(但依然会生效), 所以建议把log拦截添加到队尾。
Cookie管理
dio_cookie_manager 包是Dio的一个插件,它提供了一个Cookie管理器。详细示例可以移步dio_cookie_manager 。
自定义拦截器
开发者可以通过继承Interceptor/QueuedInterceptor
类来实现自定义拦截器,这是一个简单的缓存示例拦截器。
*错误处理
当请求过程中发生错误时, Dio 会包装 Error/Exception
为一个 DioError
:
1 | try { |
DioError 字段
1 | { |
DioErrorType
1 | enum DioErrorType { |
使用application/x-www-form-urlencoded编码
默认情况下, Dio 会将请求数据(除过String类型)序列化为 JSON
. 如果想要以 application/x-www-form-urlencoded
格式编码, 你可以显式设置contentType
:
1 | //Instance level |
这里有一个示例.
FormData
Dio支持发送 FormData, 请求数据将会以 multipart/form-data
方式编码, FormData中可以一个或多个包含文件 .
1 | var formData = FormData.fromMap({ |
注意: 只有 post 方法支持发送 FormData.
这里有一个完整的示例.
多文件上传
多文件上传时,通过给key加中括号“[]”方式作为文件数组的标记,大多数后台也会通过key[]这种方式来读取。不过RFC中并没有规定多文件上传就必须得加“[]”,所以有时不带“[]”也是可以的,关键在于后台和客户端得一致。v3.0.0 以后通过Formdata.fromMap()
创建的Formdata
,如果有文件数组,是默认会给key加上“[]”的,比如:
1 | FormData.fromMap({ |
最终编码时会key会为 “files[]”,如果不想添加“[]”,可以通过Formdata
的API来构建:
1 | var formData = FormData(); |
这样构建的FormData
的key是不会有“[]”。
转换器
转换器Transformer
用于对请求数据和响应数据进行编解码处理。Dio实现了一个默认转换器DefaultTransformer
作为默认的 Transformer
. 如果你想对请求/响应数据进行自定义编解码处理,可以提供自定义转换器,通过 dio.transformer
设置。
请求转换器
Transformer.transformRequest(...)
只会被用于 ‘PUT’、 ‘POST’、 'PATCH’方法,因为只有这些方法才可以携带请求体(request body)。但是响应转换器Transformer.transformResponse()
会被用于所有请求方法的返回数据。
Flutter中设置
如果你在开发Flutter应用,强烈建议json的解码通过compute方法在后台进行,这样可以避免在解析复杂json时导致的UI卡顿。
注意,根据笔者实际测试,发现通过
compute
在后台解码json耗时比直接解码慢很多,建议开发者仔细评估。
1 | // 必须是顶层函数 |
其它示例
这里有一个 自定义Transformer的示例.
执行流
虽然在拦截器中也可以对数据进行预处理,但是转换器主要职责是对请求/响应数据进行编解码,之所以将转化器单独分离,一是为了和拦截器解耦,二是为了不修改原始请求数据(如果你在拦截器中修改请求数据(options.data),会覆盖原始请求数据,而在某些时候您可能需要原始请求数据). Dio的请求流是:
请求拦截器 >> 请求转换器 >> 发起请求 >> 响应转换器 >> 响应拦截器 >> 最终结果。
这是一个自定义转换器的示例.
HttpClientAdapter
HttpClientAdapter是 Dio 和 HttpClient之间的桥梁。2.0抽象出adapter主要是方便切换、定制底层网络库。Dio实现了一套标准的、强大API,而HttpClient则是真正发起Http请求的对象。我们通过HttpClientAdapter将Dio和HttpClient解耦,这样一来便可以自由定制Http请求的底层实现,比如,在Flutter中我们可以通过自定义HttpClientAdapter将Http请求转发到Native中,然后再由Native统一发起请求。再比如,假如有一天OKHttp提供了dart版,你想使用OKHttp发起http请求,那么你便可以通过适配器来无缝切换到OKHttp,而不用改之前的代码。
Dio 使用DefaultHttpClientAdapter
作为其默认HttpClientAdapter,DefaultHttpClientAdapter
使用dart:io:HttpClient
来发起网络请求。
设置Http代理
DefaultHttpClientAdapter
提供了一个onHttpClientCreate
回调来设置底层 HttpClient
的代理,我们想使用代理,可以参考下面代码:
1 | import 'package:dio/dio.dart'; |
完整的示例请查看这里.
Https证书校验
有两种方法可以校验https证书,假设我们的后台服务使用的是自签名证书,证书格式是PEM格式,我们将证书的内容保存在本地字符串中,那么我们的校验逻辑如下:
1 | String PEM='XXXXX'; // certificate content |
X509Certificate
是证书的标准格式,包含了证书除私钥外所有信息,读者可以自行查阅文档。另外,上面的示例没有校验host,是因为只要服务器返回的证书内容和本地的保存一致就已经能证明是我们的服务器了(而不是中间人),host验证通常是为了防止证书和域名不匹配。
对于自签名的证书,我们也可以将其添加到本地证书信任链中,这样证书验证时就会自动通过,而不会再走到badCertificateCallback
回调中:
1 | (dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate = (client) { |
注意,通过setTrustedCertificates()
设置的证书格式必须为PEM或PKCS12,如果证书格式为PKCS12,则需将证书密码传入,这样则会在代码中暴露证书密码,所以客户端证书校验不建议使用PKCS12格式的证书。
Http2支持
dio_http2_adapter 包提供了一个支持Http/2.0的Adapter,详情可以移步 dio_http2_adapter 。
请求取消
你可以通过 cancel token 来取消发起的请求:
1 | CancelToken token = CancelToken(); |
注意: 同一个cancel token 可以用于多个请求,当一个cancel token取消时,所有使用该cancel token的请求都会被取消。
完整的示例请参考取消示例.
继承 Dio class
Dio
是一个拥有factory 构造函数的接口类,因此不能直接继承 Dio
,但是可以通过 DioForNative
或DioForBrowser
来间接实现:
1 | import 'package:dio/dio.dart'; |
我们也可以直接实现 Dio
接口类 :
1 | class MyDio with DioMixin implements Dio{ |
一些其他库
awesome-dio
🎉 A curated list of awesome things related to dio.
Plugins | Status | Description |
---|---|---|
dio_cookie_manager | A cookie manager for Dio | |
dio_http2_adapter | A Dio HttpClientAdapter which support Http/2.0 | |
dio_smart_retry | Flexible retry library for Dio | |
http_certificate_pinning | Https Certificate pinning for Flutter | |
curl_logger_dio_interceptor | A Flutter curl-command generator for Dio. | |
dio_http_cache | A simple cache library for Dio like Rxcache in Android | |
pretty_dio_logger | Pretty Dio logger is a Dio interceptor that logs network calls in a pretty, easy to read format. |
异步与同步
原理
异步
也就是说,代码执行中,某段代码的执行并不会影响后面代码的执行。
说得好像有点抽象,让我们看看异步的实现方式:
多线程
- 开启另一条线程执行一段耗时代码,这样两条线程可以并列执行,自然不会阻塞线程而影响后面代码的执行了。
- 多线程在适量并合理地使用下,可以说真香。但是其缺点也是显而易见的:
- 开启线程会带来额外的资源和性能消耗,在遇到大量并发时,会给服务器带来极大的压力。
- 多个线程操作共享内存时需要加锁控制,锁竞争会降低性能和效率,复杂情况下还容易造成死锁。
单线程
- 一条执行线上,同时且只能执行一个任务(事件),其他任务都必须在后面排队等待被执行。也就是说,在一条执行线上,为了不阻碍代码的执行,每遇到的耗时任务都会被挂起放入任务队列,待执行结束后再按放入顺序依次执行队列上的任务,从而达到异步效果。
上述执行线为什么不直接说是线程?因为Dart没有线程概念,只有Isolate
。所以,Dart线程这个说法就是错的。当然,在此方面理解为线程,甚至理解为一个函数体也没毛病…
单线程模型的优势就是避免了上述多线程的缺点,然而这种方式比较适合于往往把时间浪费在等待对方传送数据或者返回结果的耗时操作,如网络请求,IO流操作等。对于尽可能利用处理器的多核实现并行计算的计算密集型操作相对来说多线程更为合适。
Dart事件循环机制
上文说了Dart是基于事件循环机制
的单线程模型
,那么问题来了…
为什么要采用单线程模型?
App使用过程中,多数时间处于空闲状态,并不需要进行密集或高并发的处理,计算以及UI渲染,多线程方式显然有些多余。
为什么要基于事件循环机制?
对于用户点击,滑动,硬盘IO访问等等事件,你不知道何时发生或以什么顺序发生,所以得有一个永不停歇且不能阻塞的循环来等待并处理这些“突发”事件。
Dart事件循环机制是怎样的?
Dart事件循环机制是由一个 消息循环(Event looper) 和两个消息队列:事件队列(Event queue) 和 微任务队列(MicroTask queue) 构成。
Event Looper
Dart代码的运行是从main函数开始的,main函数执行完后,Event looper
开始工作,Event looper
优先全部执行完Microtask queue
中的event
直到Microtask queue
为空时,才会执行Event queue
中的event,后者为空时才可以退出循环,这里强调“可以”而不是“一定”要退出,视场景而定。
更官方的:
执行完
main()
函数后将会创建一个Main Isolate
。
- Event Loop会处理两个队列
MicroTask queue
和Event queue
中的任务; Event queue
主要处理外部的事件任务:I/O
,手势事件
,定时器
,isolate间的通信
等;MicroTask queue
主要处理内部的任务:譬如处理I/O
事件的中间过程中可能涉及的一些特殊处理等;- 两个队列都是先进先出的处理逻辑,优先处理
MicroTask queue
的任务,当MicroTask queue
队列为空后再执行Event queue
中的任务; - 当两个队列都为空的时候就进行GC操作,或者仅仅是在等待下个任务的到来。
为了比较好的理解 Event Loop 的异步逻辑,我们来打个比喻:就像我去某网红奶茶品牌店买杯“幽兰拿铁”(由于是现做的茶,比较耗时)的过程。
- 我来到前台给服务员说我要买一杯你们店的“幽兰拿铁”,然后服务员递给了我一个有编号的飞盘(获取凭证);
- 奶茶店的备餐员工就将我的订单放在订单列表的最后面,他们按照顺序准备订单上的商品,准备好一个就让顾客去领取(Event queue 先进先出进行处理),而我就走开了,该干啥干啥去了(异步过程,不等待处理结果);
- 突然他们来了个超级VIP会员的订单,备餐员工就把这个超级VIP订单放在了其他订单的最前面,优先安排了这个订单的商品(MicroTask优先处理)—此场景为虚构;
- 当我的订单完成后,飞盘开始震动(进行结果回调),我又再次回到了前台,如果前台妹子递给我一杯奶茶(获得结果),如果前台妹子说对不起先生,到您的订单的时候没水了,订单没法完成了给我退钱(获得异常错误错误)。
Event Queue
该队列事件来源于外部事件
和Future
- 外部事件
例如:输入/输出,手势,绘制,计时器,Stream等等;
对于外部事件
,一旦没有任何microtask要执行,Event looper就会考虑将列为队列中的第一项并执行它。
- Future
用于自定义Event queue事件。
通过创建Future
类实例来向Event queue添加事件:
1 | new Future(() { |
延时5秒后添加一个事件:
1 | new Future.delayed(const Duration(seconds:5), () { |
如果该任务前面有其它任务需要先执行,该任务被执行的时间会大于5秒(单线程模型的缺陷之一,不能基于时钟调度)。
这里拓展下Future
的一些用法(了解下就可以了,不是本文重点):
1 | new Future(() => doTask) // 执行异步任务 |
事件任务执行完后会立即依次执行then
子任务,最后执行whenComplete
函数。
Microtask Queue
- 上文已述,Microtask queue的优先级要高于Event queue
- 使用场景:想要在稍后完成一些任务(microtask)但又希望在执行下一个事件(event)之前执行。
Microtask一般用于非常短的内部异步动作,并且任务量非常少,如果微任务非常多,就会造成Event queue排不上队,会阻塞Event queue的执行(如,用户点击没有反应)。所以,大多数情况下优先考虑使用Event queue,整个Flutter源代码仅引用scheduleMicroTask()方法7次。
通过创建scheduleMicrotask
函数来向Microtask queue添加任务:
1 | scheduleMicrotask(() { |
Isolate
所有的Dart代码都是在isolate
中运行,它就像是机器上的一个小空间,具有自己的私有内存块和一个运行着Event looper
的单个线程。正如上文强调的:Dart中没有线程的概念只有isolate
。
每个isolate
都是相互隔离(独立)的,并不像线程那样可以共享内存,isolate本身就是隔离的意思…
许多Dart应用都在单个isolate
中运行所有代码,但是如果特殊需要,您可以拥有多个。
Isolate
间可以一起工作的唯一方法是通过来回传递消息。一个isolate
将消息发送到另一个isolate
,接收者使用其Event looper
处理该消息。
如果你想了解更多,参考Flutter之isolate的使用及通信原理
关键字
异步操作常用关键字:Future
,async
,await
,都是基于Event Loop。
future 是
Future
类的对象,其表示一个T
类型的异步操作结果。如果异步操作不需要结果,则 future 的类型可为Future<void>
。当一个返回 future 对象的函数被调用时,会发生两件事:- 将函数操作列入队列等待执行并返回一个未完成的
Future
对象。 - 不久后当函数操作执行完成,
Future
对象变为完成并携带一个值或一个错误。
- 将函数操作列入队列等待执行并返回一个未完成的
异步函数即在函数头中包含关键字
async
的函数。关键字await
只能用在异步函数中
请注意异步函数是立即开始执行的(同步地),其将会在下述情况之一首次出现时暂停执行并返回一个未完成的 future 对象:
- 函数中第一个
await
表达式出现时(在该函数从await
表达式获取到未完成的 future 之后)。 - 函数中任何
return
语句的出现时。 - 函数体的结束。
使用
你可以想上面dio
那样直接使用await,这是完全可以的。
1 | Response response; |
.then()
与.whenComplete ()
.then()
上面的请求也可以这么写:
1 | Response response; |
then 方法的第一个参数 FutureOr<R> onValue(T value)
就是 Future 的 onValue 代表的值 , 类型是 Future 泛型类型 R ;
then 方法的第二个参数 {Function? onError}
是可选的 , 用于捕获异常的方法 ;
当然你可以在dio.get()
方法前面加await
,但加与不加是有区别的,对这个区别的理解程度其实就是对await
关键字的理解程度。
.whenComplete()
在 Future 执行快要结束时 , 如果想要执行一些任务 , 可以在链式调用时 , 调用 Future 的 whenComplete 方法 ;
该方法类似于 try … catch … finally 中的 finally 代码块 , 是必定执行的代码 , 即使出险错误 , 也会执行该代码 ;
1 | dio.get('/test?id=12&name=wendu').then((value) { |
其他
Future也有其他方法,比如:
Future.delay
1 | final future4 = Future.delayed(Duration(seconds: 1), () { |
延迟一定时间再执行
用的不多,需要的话可以自学。
注意事项
Async 方法
当你使用 async 关键字作为方法声明的后缀时,Dart 会将其理解为:
- 该方法的返回值是一个 Future;
- 它同步执行该方法的代码直到第一个 await 关键字,然后它暂停该方法其他部分的执行;
- 一旦由 await 关键字引用的 Future 执行完成,下一行代码将立即执行。
为了更好地进行说明,让我们通过以下示例并尝试指出其运行的结果。
1 | void main() async { |
正确的顺序是:
- A
- B start
- C start from B
- C end from B
- B end
- C start from main
- C end from main
- D
- C running Future from B
- C end of Future from B
- C running Future from main
- C end of Future from main
现在,让我们认为上述代码中的 methodC() 为对服务端的调用,这可能需要不均匀的时间来进行响应。我相信可以很明确地说,预测确切的执行流程可能变得非常困难。
如果你最初希望示例代码中仅在所有代码末尾执行 methodD() ,那么你应该按照以下方式编写代码:
1 | void main() async { |
输出序列为:
- A
- B start
- C start from B
- C running Future from B
- C end of Future from B
- C end from B
- B end
- C start from main
- C running Future from main
- C end of Future from main
- C end from main
- D
事实是通过在 methodC() 中定义 Future 的地方简单地添加 await 会改变整个行为。
其他小技巧
快速json解析为对象
1 | class BookCollection { |
1 | List list = jsonMap['collectionBooks'] ?? []; |
参考:
https://github.com/flutterchina/dio/blob/master/README-ZH.md
https://github.com/flutterchina/dio/blob/develop/README-ZH.md