0%

Flutter应用框架搭建(四) 网络请求封装

应用开发中,网络请求几乎是必不可少的功能,本文将介绍如何通过对 dio 进行二次封装一步一步实现网络请求封装,以便于在项目中方便快捷的使用网络请求。

封装后的网络请求将具备如下功能:

  • 简单易用
  • 数据解析
  • 异常处理
  • 请求拦截
  • 日志打印
  • loading 显示

下面将一步一步带你实现网络请求的封装。

添加依赖

首先在项目里添加 dio 的依赖:

1
2
dependencies:
dio: ^4.0.4

请求封装

首先创建一个 RequestConfig 类,用于放置 dio 的配置参数,如下:

1
2
3
4
5
class RequestConfig{
static const baseUrl = "https://www.fastmock.site/mock/6d5084df89b4c7a49b28052a0f51c29a/test";
static const connectTimeout = 15000;
static const successCode = 200;
}

配置了请求的 baseUrl 、连接超时时间、请求成功的业务编码。如果还有需其他配置也可以统一配置到该类下。

创建 RequestClient 用于封装 dio 的请求,在类的构造方法中初始化 dio 配置:

1
2
3
4
5
6
7
8
9
10
11
RequestClient requestClient = RequestClient();

class RequestClient {
late Dio _dio;

RequestClient() {
_dio = Dio(
BaseOptions(baseUrl: RequestConfig.baseUrl, connectTimeout: RequestConfig.connectTimeout)
);
}
}

在类的上方,创建了一个全局的变量 requestClient 方便外部调用。

dio 本身提供了getpostputdelete 等一系列 http 请求方法,但是通过源码发现最终这些方法都是调用的 request 的方法实现的。所以这里直接对 dio 的 request 方法进行封装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Future<dynamic> request(
String url, {
String method = "GET",
Map<String, dynamic>? queryParameters,
data,
Map<String, dynamic>? headers
}) async {
Options options = Options()
..method = method
..headers = headers;

Response response = await _dio.request(url,
queryParameters: queryParameters, data: data, options: options);

return response.data;
}

将常用参数进行统一封装为 request 方法然后调用 dio 的 request 方法,然后再在 request 方法里进行统一的数据处理,如数据解析等。

数据解析

返回数据解析

在移动开发中,开发者习惯将返回数据解析成实体类使用,在 Flutter应用框架搭建(三)Json数据解析 一文中讲解了在 Flutter 中如何将 json 数据解析为实体类,接下来将介绍如何结合 dio 完成数据解析的封装。

项目开发中接口返回的数据结构一般是这样的:

1
2
3
4
5
6
7
8
9
{
"code": 200,
"message": "success",
"data":{
"id": "12312312",
"name": "loongwind",
"age": 18
}
}

结合 Flutter应用框架搭建(三)Json数据解析 一文,创建 ApiResponse 类用于解析接口返回数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class ApiResponse<T> {

int? code;
String? message;
T? data;

ApiResponse();

factory ApiResponse.fromJson(Map<String, dynamic> json) => $ApiResponseFromJson<T>(json);

Map<String, dynamic> toJson() => $ApiResponseToJson(this);

@override
String toString() {
return jsonEncode(this);
}
}

关于 json 解析请详阅 Flutter应用框架搭建(三)Json数据解析

因为返回的数据中 data 的数据类型是不定的,所以改造 request 支持泛型,然后在 request 方法中统一进行数据解析,然后返回 data 数据,代码如下:

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
Future<T?> request<T>(
String url, {
String method = "GET",
Map<String, dynamic>? queryParameters,
data,
Map<String, dynamic>? headers
}) async {
Options options = Options()
..method = method
..headers = headers;

Response response = await _dio.request(url,
queryParameters: queryParameters, data: data, options: options);

return _handleRequestResponse<T>(response);
}

///请求响应内容处理
T? _handleResponse<T>(Response response) {
if (response.statusCode == 200) {
ApiResponse<T> apiResponse = ApiResponse<T>.fromJson(response.data);
return _handleBusinessResponse<T>(apiResponse);
} else {
return null;
}
}

///业务内容处理
T? _handleBusinessResponse<T>(ApiResponse<T> response) {
if (response.code == RequestConfig.successCode) {
return response.data;
} else {
return null;
}
}

通过 ApiResponse 解析返回数据,然后判断 ApiResponse 的业务 code 是否为成功,成功则返回 data 数据。

有时候在应用里还需要调用第三方接口,但是第三方接口返回的数据结构可能会有差异,此时就需要返回原始数据单独做处理。创建一个 RawData 类,用于解析原始数据:

1
2
3
class RawData{
dynamic value;
}

然后修改 RequestClient 中的 _handleResponse

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
///请求响应内容处理
T? _handleResponse<T>(Response response) {
if (response.statusCode == 200) {
if(T.toString() == (RawData).toString()){
RawData raw = RawData();
raw.value = response.data;
return raw as T;
}else {
ApiResponse<T> apiResponse = ApiResponse<T>.fromJson(response.data);
return _handleBusinessResponse<T>(apiResponse);
}
} else {
var exception = ApiException(response.statusCode, ApiException.unknownException);
throw exception;
}
}

新增判断泛型是否为 RawData ,是则直接去除 response.data 放入 RawData 中返回,即 RawData 的 value 就是接口返回的原始数据。

请求数据转换

除了返回数据的解析,实际开发过程中还会遇到对请求参数的处理,比如请求参数为 json 数据,但是代码里为了方便处理使用的实体类,request 中 data 参数可能传入的是一个实体类实例,此时就需要将 data 转换为 json 数据再进行数据请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
  _convertRequestData(data) {
if (data != null) {
data = jsonDecode(jsonEncode(data));
}
return data;
}

Future<T?> request<T>(
String url, {
String method = "GET",
Map<String, dynamic>? queryParameters,
data,
Map<String, dynamic>? headers
}) async {
///...
data = _convertRequestData(data);

Response response = await _dio.request(url,
queryParameters: queryParameters, data: data, options: options);

return _handleResponse<T>(response);
}
}

此处使用 _convertRequestData 方法,将请求 data 数据先使用 jsonEncode 转换为字符串,再使用 jsonDecode 方法将字符串转换为 Map。

异常处理

接下来看看如何进行统一的异常处理,异常一般分为两部分:Http异常、业务异常。

  • Http 异常:Http 错误,如 404、503 等
  • 业务异常:请求成功,但是业务异常,如:登录时用户名密码错误等

首先创建一个 ApiException 用于统一封装请求的异常信息:

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
class ApiException implements Exception {
static const unknownException = "未知错误";
final String? message;
final int? code;
String? stackInfo;

ApiException([this.code, this.message]);

factory ApiException.fromDioError(DioError error) {
switch (error.type) {
case DioErrorType.cancel:
return BadRequestException(-1, "请求取消");
case DioErrorType.connectTimeout:
return BadRequestException(-1, "连接超时");
case DioErrorType.sendTimeout:
return BadRequestException(-1, "请求超时");
case DioErrorType.receiveTimeout:
return BadRequestException(-1, "响应超时");
case DioErrorType.response:
try {

/// http 错误码带业务错误信息
ApiResponse apiResponse = ApiResponse.fromJson(error.response?.data);
if(apiResponse.code != null){
return ApiException(apiResponse.code, apiResponse.message);
}

int? errCode = error.response?.statusCode;
switch (errCode) {
case 400:
return BadRequestException(errCode, "请求语法错误");
case 401:
return UnauthorisedException(errCode!, "没有权限");
case 403:
return UnauthorisedException(errCode!, "服务器拒绝执行");
case 404:
return UnauthorisedException(errCode!, "无法连接服务器");
case 405:
return UnauthorisedException(errCode!, "请求方法被禁止");
case 500:
return UnauthorisedException(errCode!, "服务器内部错误");
case 502:
return UnauthorisedException(errCode!, "无效的请求");
case 503:
return UnauthorisedException(errCode!, "服务器异常");
case 505:
return UnauthorisedException(errCode!, "不支持HTTP协议请求");
default:
return ApiException(
errCode, error.response?.statusMessage ?? '未知错误');
}
} on Exception catch (e) {
return ApiException(-1, unknownException);
}
default:
return ApiException(-1, error.message);
}
}

factory ApiException.from(dynamic exception){
if(exception is DioError){
return ApiException.fromDioError(exception);
} if(exception is ApiException){
return exception;
} else {
var apiException = ApiException(-1, unknownException);
apiException.stackInfo = exception?.toString();
return apiException;
}
}
}

/// 请求错误
class BadRequestException extends ApiException {
BadRequestException([int? code, String? message]) : super(code, message);
}

/// 未认证异常
class UnauthorisedException extends ApiException {
UnauthorisedException([int code = -1, String message = ''])
: super(code, message);
}

ApiException 主要根据 DioError 信息创建 ApiException,但是仔细发现其中有一段解析返回数据让创建 ApiException 的代码,如下:

1
2
3
4
ApiResponse apiResponse = ApiResponse.fromJson(error.response?.data);
if(apiResponse.code != null){
return ApiException(apiResponse.code, apiResponse.message);
}

是因为有些时候后端业务异常时修改了返回的 http 状态码,当 http 状态码非 200 开头时 dio 会抛出 DioError 错误,但此时需要的错误信息为 response 中的错误信息,所以这里需要先解析 response 数据获取错误信息。

ApiException 类创建好后,需要在 request 方法中捕获异常,对 request 方法改造如下:

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
48
49
Future<T?> request<T>(
String url, {
String method = "Get",
Map<String, dynamic>? queryParameters,
data,
Map<String, dynamic>? headers,
bool Function(ApiException)? onError,
}) async {
try {
Options options = Options()
..method = method
..headers = headers;

data = _convertRequestData(data);

Response response = await _dio.request(url,
queryParameters: queryParameters, data: data, options: options);

return _handleResponse<T>(response);
} catch (e) {
var exception = ApiException.from(e);
if(onError?.call(exception) != true){
throw exception;
}
}

return null;
}

///请求响应内容处理
T? _handleResponse<T>(Response response) {
if (response.statusCode == 200) {
ApiResponse<T> apiResponse = ApiResponse<T>.fromJson(response.data);
return _handleBusinessResponse<T>(apiResponse);
} else {
var exception = ApiException(response.statusCode, ApiException.unknownException);
throw exception;
}
}

///业务内容处理
T? _handleBusinessResponse<T>(ApiResponse<T> response) {
if (response.code == RequestConfig.successCode) {
return response.data;
} else {
var exception = ApiException(response.code, response.message);
throw exception;
}
}

在 request 方法上添加了 bool Function(ApiException)? onError 参数,用于错误信息处理的回调,且返回值为 bool

request 方法中添加 try-catch 包裹,并在 catch 中创建 ApiException ,调用 onError,当 onError 返回为 true 时即错误信息已被调用方处理,则不抛出异常,否则抛出异常。

同时为 response 数据解析的方法也加上了抛出异常的处理。当业务异常时抛出对应的业务异常信息。

经过上述封装后,确实能对异常信息进行处理,但在实际开发中有个问题,开发中经常会在接口请求成功后做其他处理,比如数据处理或者界面刷新等,请求失败后弹出提示或者错误处理等等,如果按照上述的封装则需要判断返回数据是否为 null 不为空进行后续处理,如果一个业务存在多个请求依赖调用,则此处则会嵌套多次,代码阅读性不好。如下:

1
2
3
4
5
6
7
8
var data1 = requestClient.request(url1);
if( data1 != null ){
var data2 = requestClient.request(url2);
if(data2 != null){
var data3 = requestClient.request(url3);
///...
}
}

为了解决上述问题,并且实现统一异常处理,创建一个顶级的 request 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Future request(Function() block,  {bool Function(ApiException)? onError}) async{
try {
await block();
} catch (e) {
handleException(ApiException.from(e), onError: onError);
}
return;
}


bool handleException(ApiException exception, {bool Function(ApiException)? onError}){

if(onError?.call(exception) == true){
return true;
}

if(exception.code == 401 ){
///todo to login
return true;
}
showError(exception.message ?? ApiException.unknownException);

return false;
}

request 方法有个 block 函数参数,在 request 中进行调用,并对其包裹 try-catch ,在 catch 中进行统一异常处理,当外部未处理异常时则在 handleException 中进行统一处理,如 401 则跳转登录页,其他错误统一弹出错误提示。

此时使用如下:

1
2
3
4
5
6
7
void testRequest() => request(() async {
UserEntity? user = await apiService.test();
print(user?.name);

user = await apiService.test();
print(user?.name);
});

当 request 包裹的代码中其中一个请求错误则不会继续向下执行。

请求拦截

dio 支持添加拦截器自定义处理请求和返回数据,只需实现自定义拦截类继承 Interceptor 实现 onRequestonResponse 即可。

比如当登录后需要给所有请求添加统一的 Header 携带 token 信息时就可以通过拦截器实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class TokenInterceptor extends Interceptor{

@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
///token from cache
var token = Cache.getToken();
options.headers["Authorization"] = "Basic $token";
super.onRequest(options, handler);
}

@override
void onResponse(dio.Response response, ResponseInterceptorHandler handler) {
super.onResponse(response, handler);
}
}

然后在初始化 dio 时添加拦截器即可:

1
_dio.interceptors.add(TokenInterceptor());

日志打印

开发过程中为了方便调试经常需要打印请求返回日志,可以使用自定义拦截器实现,也可以使用第三方实现的日志打印的拦截器 pretty_dio_logger 库。

添加依赖:

1
pretty_dio_logger: ^1.1.1

dio 添加日期拦截器:

1
_dio.interceptors.add(PrettyDioLogger(requestHeader: true, requestBody: true, responseHeader: true));

PrettyDioLogger 拦截器可以设置打印哪些信息,可根据需求进行设置。

打印效果:

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
flutter: ╔╣ Request ║ POST
flutter: ║ https://www.fastmock.site/mock/6d5084df89b4c7a49b28052a0f51c29a/test/test
flutter: ╚══════════════════════════════════════════════════════════════════════════════════════════╝
flutter: ╔ Headers
flutter: ╟ content-type: application/json; charset=utf-8
flutter: ╟ Authorization: Basic ZHhtaF93ZWI6ZHhtaF93ZWJfc2VjcmV0
flutter: ╟ token: Bearer
flutter: ╟ contentType: application/json; charset=utf-8
flutter: ╟ responseType: ResponseType.json
flutter: ╟ followRedirects: true
flutter: ╟ connectTimeout: 15000
flutter: ╟ receiveTimeout: 0
flutter: ╚══════════════════════════════════════════════════════════════════════════════════════════╝
flutter:
flutter: ╔╣ Response ║ POST ║ Status: 200 OK
flutter: ║ https://www.fastmock.site/mock/6d5084df89b4c7a49b28052a0f51c29a/test/test
flutter: ╚══════════════════════════════════════════════════════════════════════════════════════════╝
flutter: ╔ Headers
flutter: ╟ access-control-allow-credentials: [true]
flutter: ╟ connection: [keep-alive]
flutter: ╟ x-powered-by: [Express]
flutter: ╟ set-cookie:
flutter: ║ [connect.sid=s%3AkDiyUQw5crHmB0UuY03dYX3Z2HPVO8Sf.bOVO2aDh%2FSviB70e9Xt5sMQjkiDtorwn%2B%2F
flutter: ║ bKN7y8UtY; Path=/; Expires=Sun, 06 Feb 2022 21:37:08 GMT; HttpOnly]
flutter: ╟ date: [Sun, 06 Feb 2022 09:37:08 GMT]
flutter: ╟ vary: [Accept, Origin, Accept-Encoding]
flutter: ╟ content-length: [82]
flutter: ╟ etag: [W/"52-2tuUsqqRy8jX+vcUJL+3D5AmQss"]
flutter: ╟ content-type: [application/json; charset=utf-8]
flutter: ╟ server: [nginx/1.17.8]
flutter: ╚══════════════════════════════════════════════════════════════════════════════════════════╝
flutter: ╔ Body
flutter: ║
flutter: ║ {
flutter: ║ code: 200,
flutter: ║ message: "success",
flutter: ║ data: {id: 111111, name: zhangsan, age: 18}
flutter: ║ }
flutter: ║
flutter: ╚══════════════════════════════════════════════════════════════════════════════════════════╝

loading 显示

网络请求是一个耗时操作,为了提高用户体验,一般会在请求的过程中显示 loading 提示用户正在加载数据。

前面解决异常处理使用了一个全局的 request 方法,loading 可以使用同样的思路实现,创建 loading 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Future loading( Function block, {bool isShowLoading = true}) async{
if (isShowLoading) {
showLoading();
}
try {
await block();
} catch (e) {
rethrow;
} finally {
dismissLoading();
}
return;
}

void showLoading(){
EasyLoading.show(status: "加载中...");
}

void dismissLoading(){
EasyLoading.dismiss();
}

实现很简单,在 block 调用前后调用 loading 的 show 和 dismiss。同时对 block 包裹 try-catch 保证在异常时取消 loading,并且在 catch 中不做任何处理直接抛出异常。

这里 loading 使用了 flutter_easyloading 插件

对 request 方法进行改造支持 loading :

1
2
3
4
5
6
7
8
Future request(Function() block,  {bool showLoading = true, bool Function(ApiException)? onError, }) async{
try {
await loading(block, isShowLoading: showLoading);
} catch (e) {
handleException(ApiException.from(e), onError: onError);
}
return;
}

对 request 中的 block 又包装了一层 loading 从而实现自动 loading 的显示隐藏。

使用示例

经过上述步骤就完成了对网络请求的封装,接下来看看怎么使用。

开发过程中常用的网络请求为 get 和 post,为了方便调用,在 RequestClient 中添加 get 和 post 方法,如下:

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
Future<T?> get<T>(
String url, {
Map<String, dynamic>? queryParameters,
Map<String, dynamic>? headers,
bool showLoading = true,
bool Function(ApiException)? onError,
}) {
return request(url,
queryParameters: queryParameters,
headers: headers,
onError: onError);
}

Future<T?> post<T>(
String url, {
Map<String, dynamic>? queryParameters,
data,
Map<String, dynamic>? headers,
bool showLoading = true,
bool Function(ApiException)? onError,
}) {
return request(url,
method: "POST",
queryParameters: queryParameters,
data: data,
headers: headers,
onError: onError);
}

实际也是封装后调用 request 方法。

基本使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
  void login(String password) => request(() async {
LoginParams params = LoginParams();
params.username = "loongwind";
params.password = password;
UserEntity? user = await requestClient.post<UserEntity>(APIS.login, data: params);
state.user = user;
update();
});


/// View
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Text("${SR.hello.tr} : ${state.count}", style: TextStyle(fontSize: 50.sp),),
ElevatedButton(onPressed: () => controller.login("123456"), child: const Text("正常登录")),
ElevatedButton(onPressed: () => controller.login("654321"), child: const Text("错误登录")),
Text("登录用户:${state.user?.username ?? ""}", style: TextStyle(fontSize: 20.sp),),

],
)

login.gif

自定义异常处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void loginError(bool errorHandler) => request(() async {
LoginParams params = LoginParams();
params.username = "loongwind";
params.password = "654321";
UserEntity? user = await requestClient.post<UserEntity>(APIS.login, data: params);
state.user = user;
print("-------------${user?.username ?? "登录失败"}");
update();
}, onError: (e){
state.errorMessage = "request error : ${e.message}";
print(state.errorMessage);
update();
return errorHandler;
});

login_error.gif

onError 无论返回 false 或者 true 都会调用 onError 方法,且 print("-------------${user?.username ?? "登录失败"}"); 这句输出并没有执行,当 onError 返回 false 时依然会弹出错误的提示,是因为返回 false 时调用了默认的异常处理弹出提示,返回 true 时则不会调用默认的异常处理方法。

在 requestClient 的请求方法上添加 onError 处理是一样的效果,不同的是在 requestClient 上的 onError 为 true 时,下面的代码会正常执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void loginError(bool errorHandler) => request(() async {
LoginParams params = LoginParams();
params.username = "loongwind";
params.password = "654321";
UserEntity? user = await requestClient.post<UserEntity>(APIS.login, data: params, onError: (e){
state.errorMessage = "request error : ${e.message}";
print(state.errorMessage);
update();
return errorHandler;
});
state.user = user;
print("-------------${user?.username ?? "登录失败"}");
update();
});

界面效果跟上面的一样,当 onError 返回 true 时,requestClient 下面的代码会正常执行。即会打印出 -------------登录失败, 返回 false 时则不会执行下面的代码。

loading 显示隐藏

1
2
3
4
5
6
7
8
void loginLoading(bool showLoading) => request(() async {
LoginParams params = LoginParams();
params.username = "loongwind";
params.password = "123456";
UserEntity? user = await requestClient.post<UserEntity>(APIS.login, data: params, );
state.user = user;
update();
}, showLoading: showLoading);

login_loading.gif

切换接口地址

在开发过程中会出现多个环境地址,比如开发环境、测试环境、预发布环境、生产环境等,此时为了方便切换环境一般都会在开发时增加一个环境切换的功能,此时就可以修改 baseUrl 然后重新创建 RequestClient 来实现。代码如下:

1
2
RequestConfig.baseUrl = "https://xxxxxx";
requestClient = RequestClient();

源码:flutter_app_core

Flutter 应用框架搭建系列文章:

 wechat
欢迎您扫一扫上面的微信公众号,订阅我的博客!