flutter

flutter

网络框架dio v5.3.3

dio 是一个强大的 HTTP 网络请求库,支持全局配置、Restful API、FormData、拦截器、 请求取消、Cookie 管理、文件上传/下载、超时、自定义适配器、转换器等。

入门使用

添加依赖

flutter pub add dio

使用

import 'package:dio/dio.dart';

final dio = Dio();

void getHttp() async {
final response = await dio.get('https://dart.dev');
print(response);
}

GET请求

import 'package:dio/dio.dart';

final dio = Dio();

void request() async {
Response response;
response = await dio.get('/test?id=12&name=dio');
print(response.data.toString());
// The below request is the same as above.
response = await dio.get(
'/test',
queryParameters: {'id': 12, 'name': 'dio'},
);
print(response.data.toString());
}

POST请求

response = await dio.post('/test', data: {'id': 12, 'name': 'dio'});

发起多个并发的请求

response = await Future.wait([dio.post('/info'), dio.get('/token')]);

下载文件

response = await dio.download(
'https://www.google.com/',
'${(await getTemporaryDirectory()).path}google.html',
);

以流的方式接收响应数据:

final rs = await dio.get(
url,
options: Options(responseType: ResponseType.stream), // 设置接收类型为 `stream`
);
print(rs.data.stream); // 响应流

以二进制数组的方式接收响应数据:

final rs = await dio.get(
url,
options: Options(responseType: ResponseType.bytes), // 设置接收类型为 `bytes`
);
print(rs.data); // 类型: List<int>

发送 FormData:

final formData = FormData.fromMap({
'name': 'dio',
'date': DateTime.now().toIso8601String(),
});
final response = await dio.post('/info', data: formData);

通过 FormData 上传多个文件:

final formData = FormData.fromMap({
'name': 'dio',
'date': DateTime.now().toIso8601String(),
'file': await MultipartFile.fromFile('./text.txt', filename: 'upload.txt'),
'files': [
await MultipartFile.fromFile('./text1.txt', filename: 'text1.txt'),
await MultipartFile.fromFile('./text2.txt', filename: 'text2.txt'),
]
});
final response = await dio.post('/info', data: formData);

监听发送(上传)数据进度:

final response = await dio.post(
'https://www.dtworkroom.com/doris/1/2.0.0/test',
data: {'aa': 'bb' * 22},
onSendProgress: (int sent, int total) {
print('$sent $total');
},
);

以流的形式提交二进制数据:

// Binary data
final postData = <int>[0, 1, 2];
await dio.post(
url,
data: Stream.fromIterable(postData.map((e) => [e])), // 构建 Stream<List<int>>
options: Options(
headers: {
Headers.contentLengthHeader: postData.length, // 设置 content-length.
},
),
);

Dio APIs

创建一个Dio实例并配置它

建议在项目中使用Dio单例,这样便可对同一个dio实例发起的所有请求进行一些统一的配置, 比如设置公共header、请求基地址、超时时间等。 这里有一个在Flutter工程中使用Dio单例 (定义为top level变量)的示例供开发者参考。

你可以使用默认配置或传递一个可选 BaseOptions参数来创建一个Dio实例 :

final dio = Dio(); // With default `Options`.

void configureDio() {
// Update default configs.
dio.options.baseUrl = 'https://api.pub.dev';
dio.options.connectTimeout = Duration(seconds: 5);
dio.options.receiveTimeout = Duration(seconds: 3);

// Or create `Dio` with a `BaseOptions` instance.
final options = BaseOptions(
baseUrl: 'https://api.pub.dev',
connectTimeout: Duration(seconds: 5),
receiveTimeout: Duration(seconds: 3),
);
final anotherDio = Dio(options);
}

Dio 的核心 API 是:

Future<Response<T>> request<T>(
String path, {
Object? data,
Map<String, dynamic>? queryParameters,
CancelToken? cancelToken,
Options? options,
ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress,
});
final response = await dio.request(
'/test',
data: {'id': 12, 'name': 'dio'},
options: Options(method: 'GET'),
);

请求配置

BaseOptions 描述的是 Dio 实例发起网络请求的的公共配置, 而 Options 描述了每一个Http请求的配置信息,每一次请求都可以单独配置, 单次请求的 Options 中的配置信息可以覆盖 BaseOptions 中的配置。 下面是 Options 的配置项:

/// 请求方式。
String method;

/// 请求基本地址,可以包含路径例如 https://dart.dev/api/。
String? baseUrl;

/// HTTP 请求头。
Map<String, dynamic>? headers;

/// 连接服务器超时时间.
Duration? connectTimeout;

/// 两次数据流数据接收的最长间隔时间,注意不是请求的最长接收时间。
Duration? receiveTimeout;

/// 请求内容体,可以是任意类型。
dynamic data;

/// 请求路径,如果以 http(s)开始, 则 [baseURL] 会被忽略,
/// 否则将会和 [baseUrl] 拼接出完整的地址。
String path = '';

/// 请求的 Content-Type。
///
/// 默认值会由 [ImplyContentTypeInterceptor] 根据请求载荷类型进行推导。
/// 可以调用 [Interceptors.removeImplyContentTypeInterceptor] 进行移除。
///
/// 如果你想以 `application/x-www-form-urlencoded` 格式编码请求数据,
/// 可以设置此选项为 `Headers.formUrlEncodedContentType`,
/// [Dio] 会自动编码请求体。
String? contentType;

/// 期望以哪种格式(方式)接受响应数据,包括 `json`、`stream` 和 `plain`。
///
/// 默认值是 `json`, 当响应头中 content-type 为 `application/json` 时,
/// dio 会自动将响应内容转化为 json 对象。
/// 如果想以二进制方式接受响应数据,如下载一个二进制文件,那么可以使用 `stream`。
///
/// 如果想以文本(字符串)格式接收响应数据,请使用 `plain`。
ResponseType? responseType;

/// `validateStatus` 决定 HTTP 响应状态码是否被视为请求成功,
/// 返回 `true` 请求结果就会按成功处理,否则会按失败处理.
ValidateStatus? validateStatus;

/// 用户自定义字段,可以在 [Interceptor]、[Transformer] 和 [Response] 中依次传递。
Map<String, dynamic>? extra;

/// 请求地址的参数。
Map<String, dynamic /*String|Iterable<String>*/ >? queryParameters;

/// 请求数据中数组的编码的方式,默认值为 `multiCompatible`。
ListFormat? listFormat;

响应数据

当请求成功时会返回一个Response对象,它包含如下字段:

/// 响应数据。可能已经被转换了类型, 详情请参考 [ResponseType]。
T? data;

/// 响应对应的请求配置。
RequestOptions requestOptions;

/// 响应的 HTTP 状态码。
int? statusCode;

/// 响应对应状态码的详情信息。
String? statusMessage;

/// 响应是否被重定向
bool isRedirect;

/// 请求连接经过的重定向列表。如果请求未经过重定向,则列表为空。
List<RedirectRecord> redirects;

/// 在 [RequestOptions] 中构造的自定义字段。
Map<String, dynamic> extra;

/// 响应对应的头数据。
Headers headers;

请求成功后,你可以访问到下列字段:

final response = await dio.get('https://pub.dev');
print(response.data);
print(response.headers);
print(response.requestOptions);
print(response.statusCode);

注意,Response.extra 与 RequestOptions.extra 是不同的实例,互相之间无关。

拦截器

每个 Dio 实例都可以添加任意多个拦截器,他们会组成一个队列,拦截器队列的执行顺序是先进先出。 通过使用拦截器,你可以在请求之前、响应之后和发生异常时(未被 then 或 catchError 处理) 做一些统一的预处理操作。

dio.interceptors.add(
InterceptorsWrapper(
onRequest: (RequestOptions options, RequestInterceptorHandler handler) {
// 如果你想完成请求并返回一些自定义数据,你可以使用 `handler.resolve(response)`。
// 如果你想终止请求并触发一个错误,你可以使用 `handler.reject(error)`。
return handler.next(options);
},
onResponse: (Response response, ResponseInterceptorHandler handler) {
// 如果你想终止请求并触发一个错误,你可以使用 `handler.reject(error)`。
return handler.next(response);
},
onError: (DioException e, ErrorInterceptorHandler handler) {
// 如果你想完成请求并返回一些自定义数据,你可以使用 `handler.resolve(response)`。
return handler.next(e);
},
),
);

一个简单的自定义拦截器示例:

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);
}

@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
print('RESPONSE[${response.statusCode}] => PATH: ${response.requestOptions.path}');
super.onResponse(response, handler);
}

@override
Future onError(DioException err, ErrorInterceptorHandler handler) async {
print('ERROR[${err.response?.statusCode}] => PATH: ${err.requestOptions.path}');
super.onError(err, handler);
}
}

完成和终止请求/响应

在所有拦截器中,你都可以改变请求执行流, 如果你想完成请求/响应并返回自定义数据,你可以 resolve 一个 Response 对象 或返回 handler.resolve(data) 的结果。 如果你想终止(触发一个错误,上层 catchError 会被调用)一个请求/响应, 那么可以 reject 一个DioException 对象或返回 handler.reject(errMsg) 的结果。

dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
return handler.resolve(
Response(requestOptions: options, data: 'fake data'),
);
},
),
);
final response = await dio.get('/test');
print(response.data); // 'fake data'

如果同时发起多个网络请求,则它们是可以同时进入Interceptor 的(并行的), 而 QueuedInterceptor 提供了一种串行机制: 它可以保证请求进入拦截器时是串行的(前面的执行完后后面的才会进入拦截器)。

假设这么一个场景:出于安全原因,我们需要给所有的请求头中添加一个 csrfToken, 如果 csrfToken 不存在,我们先去请求 csrfToken,获取到 csrfToken 后再重试。 假设刚开始的时候 csrfToken 为 null,如果允许请求并发,则这些并发请求并行进入拦截器时 csrfToken 都为 null,所以它们都需要去请求 csrfToken,这会导致 csrfToken 被请求多次。 为了避免不必要的重复请求,可以使用 QueuedInterceptor, 这样只需要第一个请求处理一次即可。

完整的示例代码请点击 这里.

日志拦截器

我们可以添加 LogInterceptor 拦截器来自动打印请求和响应等日志:

注意: LogInterceptor 应该保持最后一个被添加到拦截器中, 否则在它之后进行处理的拦截器修改的内容将无法体现。

Dart

dio.interceptors.add(LogInterceptor(responseBody: false)); // 不输出响应内容体

注意: 默认的 logPrint 只会在 DEBUG 模式(启用了断言) 的情况下输出日志。

你也可以使用 dart:developer 中的 log 来输出日志(在 Flutter 中也可以使用)。

Flutter

在 Flutter 中你应该使用 debugPrint 来打印日志。

这样也会让调试日志能够通过 flutter logs 获取到。

注意: debugPrint 的意义 不是只在 DEBUG 模式下打印, 而是对输出内容进行节流,从而保证输出完整。 请不要在生产模式使用,除非你有意输出相关日志。

dio.interceptors.add(
LogInterceptor(
logPrint: (o) => debugPrint(o.toString()),
),
);

自定义拦截器

开发者可以通过继承 Interceptor/QueuedInterceptor 类来实现自定义拦截器。 这是一个简单的 缓存拦截器。

错误处理

当请求过程中发生错误时, Dio 会将 Error/Exception 包装成一个 DioException:

try {
// 404
await dio.get('https://api.pub.dev/not-exist');
} on DioException catch (e) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx and is also not 304.
if (e.response != null) {
print(e.response.data)
print(e.response.headers)
print(e.response.requestOptions)
} else {
// Something happened in setting up or sending the request that triggered an Error
print(e.requestOptions)
print(e.message)
}
}

DioException

/// 错误的请求对应的配置。
RequestOptions requestOptions;

/// 错误的请求对应的响应内容。如果请求未完成,响应内容可能为空。
Response? response;

/// 错误的类型。
DioExceptionType type;

/// 实际错误的内容。
Object? error;

/// 实际错误的堆栈。
StackTrace? stackTrace;

/// 错误信息。
String? message;

使用 application/x-www-form-urlencoded 编码

默认情况下, Dio 会将请求数据(除了 String 类型)序列化为 JSON。 如果想要以 application/x-www-form-urlencoded 格式编码, 你可以设置 contentType :

// Instance level
dio.options.contentType = Headers.formUrlEncodedContentType;
// or only works once
dio.post(
'/info',
data: {'id': 5},
options: Options(contentType: Headers.formUrlEncodedContentType),
);

发送 FormData

Dio 支持发送 FormData, 请求数据将会以 multipart/form-data 方式编码, FormData 中可以包含一个或多个文件。

final formData = FormData.fromMap({
'name': 'dio',
'date': DateTime.now().toIso8601String(),
'file': await MultipartFile.fromFile('./text.txt',filename: 'upload.txt')
});
final response = await dio.post('/info', data: formData);

通常情况下只有 POST 方法支持发送 FormData。

这里有一个完整的 示例。

多文件上传

多文件上传时,通过给 key 加中括号 [] 方式作为文件数组的标记,大多数后台也会通过 key[] 来读取多个文件。 然而 RFC 标准中并没有规定多文件上传必须要使用 [],关键在于后台与客户端之间保持一致。

final formData = FormData.fromMap({
'files': [
MultipartFile.fromFileSync('path/to/upload1.txt', filename: 'upload1.txt'),
MultipartFile.fromFileSync('path/to/upload2.txt', filename: 'upload2.txt'),
],
});

最终编码时会 key 会为 files[], **如果不想添加 []**,可以通过 Formdata 的 files 来构建:

final formData = FormData();
formData.files.addAll([
MapEntry(
'files',
MultipartFile.fromFileSync('./example/upload.txt',filename: 'upload.txt'),
),
MapEntry(
'files',
MultipartFile.fromFileSync('./example/upload.txt',filename: 'upload.txt'),
),
]);

复用 FormData 和 MultipartFile

如果你在重复调用的请求中使用 FormData 或者 MultipartFile,确保你每次使用的都是新实例。 常见的错误做法是将 FormData 赋值给一个共享变量,在每次请求中都使用这个变量。 这样的操作会加大 无法序列化 的错误出现的可能性。 你可以像以下的代码一样编写你的请求以避免出现这样的错误:

Future<void> _repeatedlyRequest() async {
Future<FormData> createFormData() async {
return FormData.fromMap({
'name': 'dio',
'date': DateTime.now().toIso8601String(),
'file': await MultipartFile.fromFile('./text.txt',filename: 'upload.txt'),
});
}

await dio.post('some-url', data: await createFormData());
}

转换器

转换器 Transformer 用于对请求数据和响应数据进行编解码处理。 Dio 实现了一个默认转换器 DefaultTransformer。 如果你想对请求和响应数据进行自定义编解码处理,可以提供自定义转换器并通过 dio.transformer 设置。

Transformer.transformRequest 只在 PUT/POST/PATCH 方法中生效, 只有这些方法可以使用请求内容体 (request body)。 但是 Transformer.transformResponse 可以用于所有请求方法的返回数据。

在 Flutter 中进行设置

如果你在开发 Flutter 应用,强烈建议通过 compute 在单独的 isolate 中进行 JSON 解码, 从而避免在解析复杂 JSON 时导致的 UI 卡顿。

/// 
Map<String, dynamic> _parseAndDecode(String response) {
return jsonDecode(response) as Map<String, dynamic>;
}

Future<Map<String, dynamic>> parseJson(String text) {
return compute(_parseAndDecode, text);
}

void main() {
// 自定义 `jsonDecodeCallback`
dio.transformer = DefaultTransformer()..jsonDecodeCallback = parseJson;
runApp(MyApp());
}

其它示例

这里有一个 自定义 Transformer 的示例。

HttpClientAdapter

HttpClientAdapter 是 Dio 和 HttpClient 之间的桥梁。

Dio 实现了一套标准且强大的 API,而 HttpClient 则是真正发起 HTTP 请求的对象。

我们通过 HttpClientAdapter 将 Dio 和 HttpClient 解耦, 这样一来便可以自由定制 HTTP 请求的底层实现。 Dio 使用 IOHttpClientAdapter 作为原生平台默认的桥梁, BrowserClientAdapter 作为 Web 平台的桥梁。 你可以通过 HttpClientAdapter() 来根据平台创建它们。

dio.httpClientAdapter = HttpClientAdapter();

如果你需要单独使用对应平台的适配器:

  • 对于 Web 平台

    import 'package:dio/browser.dart';
    // ...
    dio.httpClientAdapter = BrowserClientAdapter();
  • 对于原生平台:

    import 'package:dio/io.dart';
    // ...
    dio.httpClientAdapter = IOClientAdapter();

示例 中包含了一个简单的自定义桥接。

设置代理

IOHttpClientAdapter 提供了一个 createHttpClient 回调来设置底层 HttpClient 的代理:

import 'package:dio/io.dart';

void initAdapter() {
dio.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () {
final client = HttpClient();
client.findProxy = (uri) {
// 将请求代理至 localhost:8888。
// 请注意,代理会在你正在运行应用的设备上生效,而不是在宿主平台生效。
return 'PROXY localhost:8888';
};
return client;
},
);
}

完整的示例请查看 这里。

Web 平台不支持设置代理。

HTTPS 证书校验

HTTPS 证书验证(或公钥固定)是指确保端侧与服务器的 TLS 连接的证书是期望的证书,从而减少中间人攻击的机会。 OWASP 中解释了该理论。

服务器响应证书

与其他方法不同,此方法使用服务器本身的证书。

void initAdapter() {
const String fingerprint = 'ee5ce1dfa7a53657c545c62b65802e4272878dabd65c0aadcf85783ebb0b4d5c';
dio.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () {
// Don't trust any certificate just because their root cert is trusted.
final HttpClient client = HttpClient(context: SecurityContext(withTrustedRoots: false));
// You can test the intermediate / root cert here. We just ignore it.
client.badCertificateCallback = (cert, host, port) => true;
return client;
},
validateCertificate: (cert, host, port) {
// Check that the cert fingerprint matches the one we expect.
// We definitely require _some_ certificate.
if (cert == null) {
return false;
}
// Validate it any way you want. Here we only check that
// the fingerprint matches the OpenSSL SHA256.
return fingerprint == sha256.convert(cert.der).toString();
},
);
}

你可以使用 OpenSSL 读取密钥的 SHA-256:

openssl s_client -servername pinning-test.badssl.com -connect pinning-test.badssl.com:443 < /dev/null 2>/dev/null \
| openssl x509 -noout -fingerprint -sha256

#### SHA256 Fingerprint=EE:5C:E1:DF:A7:A5:36:57:C5:45:C6:2B:65:80:2E:42:72:87:8D:AB:D6:5C:0A:AD:CF:85:78:3E:BB:0B:4D:5C
# (remove the formatting, keep only lower case hex characters to match the `sha256` above)

证书颁发机构验证

当您的服务器具有自签名证书时,可以用下面的方法,但它们不适用于 AWS 或 Let’s Encrypt 等第三方颁发的证书。

有两种方法可以校验证书,假设我们的后台服务使用的是自签名证书,证书格式是 PEM 格式,我们将证书的内容保存在本地字符串中, 那么我们的校验逻辑如下:

void initAdapter() {
String PEM = 'XXXXX'; // root certificate content
dio.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () {
final client = HttpClient();
client.badCertificateCallback = (X509Certificate cert, String host, int port) {
return cert.pem == PEM; // Verify the certificate.
};
return client;
},
);
}

对于自签名的证书,我们也可以将其添加到本地证书信任链中, 这样证书验证时就会自动通过,而不会再走到 badCertificateCallback 回调中:

void initAdapter() {
String PEM = 'XXXXX'; // root certificate content
dio.httpClientAdapter = IOHttpClientAdapter(
onHttpClientCreate: (_) {
final SecurityContext sc = SecurityContext();
sc.setTrustedCertificates(File(pathToTheCertificate));
final HttpClient client = HttpClient(context: sc);
return client;
},
);
}

注意,通过 setTrustedCertificates() 设置的证书格式必须为 PEM 或 PKCS12, 如果证书格式为 PKCS12,则需将证书密码传入, 这样则会在代码中暴露证书密码,所以客户端证书校验不建议使用 PKCS12 格式的证书。

HTTP/2 支持

dio_http2_adapter 提供了一个支持 HTTP/2 的桥接 。

请求取消

你可以通过 CancelToken 来取消发起的请求。 一个 CancelToken 可以给多个请求共用, 在共用时调用 cancel() 会取消对应的所有请求:

final cancelToken = CancelToken();
dio.get(url, cancelToken: cancelToken).catchError((DioException err) {
if (CancelToken.isCancel(err)) {
print('Request canceled: ${err.message}');
} else {
// handle error.
}
});
// Cancel the requests with "cancelled" message.
token.cancel('cancelled');

完整的示例请参考 取消示例.

继承 Dio class

Dio 是一个拥有工厂构造函数的接口类,因此不能直接继承 Dio, 但是可以继承 DioForNative 或 DioForBrowser:

import 'package:dio/dio.dart';
import 'package:dio/io.dart';
// 在浏览器中,导入 'package:dio/browser.dart'。

class Http extends DioForNative {
Http([BaseOptions options]) : super(options) {
// 构造函数执行
}
}

我们也可以直接实现 Dio 接口类 :

class MyDio with DioMixin implements Dio {
// ...
}

Web 平台跨域资源共享 (CORS)

在 Web 平台上发送网络请求时,如果请求不是一个 简单请求, 浏览器会自动向服务器发送 CORS 预检 (Pre-flight requests), 用于检查服务器是否支持跨域资源共享。

你可以参考简单请求的定义修改你的请求,或者为你的服务加上 CORS 中间件进行跨域处理。