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

12,291 阅读9分钟

「这是我参与2022首次更文挑战的第5天,活动详情查看:2022首次更文挑战

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

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

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

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

添加依赖

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

dependencies:
  dio: ^4.0.4

请求封装

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

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

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

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

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 方法进行封装。

  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 完成数据解析的封装。

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

{
  "code": 200,
  "message": "success",
  "data":{
    "id": "12312312",
    "name": "loongwind",
    "age": 18
  }
}

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

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 数据,代码如下:

  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 类,用于解析原始数据:

class RawData{
  dynamic value;
}

然后修改 RequestClient 中的 _handleResponse

  ///请求响应内容处理
  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 数据再进行数据请求。

  _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 用于统一封装请求的异常信息:

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 的代码,如下:

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 方法改造如下:

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 不为空进行后续处理,如果一个业务存在多个请求依赖调用,则此处则会嵌套多次,代码阅读性不好。如下:

var data1 = requestClient.request(url1);
if( data1 != null ){
  var data2 = requestClient.request(url2);
  if(data2 != null){
    var data3 = requestClient.request(url3);
    ///...
  }
}

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

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 则跳转登录页,其他错误统一弹出错误提示。

此时使用如下:

  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 信息时就可以通过拦截器实现。

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 时添加拦截器即可:

_dio.interceptors.add(TokenInterceptor());

日志打印

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

添加依赖:

pretty_dio_logger: ^1.1.1

dio 添加日期拦截器:

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

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

打印效果:

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 方法:

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 :

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 方法,如下:

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 方法。

基本使用

  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

自定义异常处理

 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 时,下面的代码会正常执行:

  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 显示隐藏

  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 来实现。代码如下:

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

源码:flutter_app_core

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