Flutter Dio封裝實踐

隔壁的李叔叔發表於2021-03-14

接著上一篇文章:基於MVVM架構封裝Flutter基礎庫

這篇文章主要介紹dio封裝,實現http請求:Demo地址

一、http元件XApi包含的特性

 1、http請求時傳遞泛型直接解析為對應物件或List; 

 2、配合MVVM元件(BaseViewModel和BaseView)使用,實現了通用載入中、載入錯誤頁,空白頁以及正常顯示頁UI和邏輯; 

 3、http載入時dialog自動顯示隱藏邏輯,dialog UI可自定義; 

 4、http載入時手動關閉dialog會自動取消請求,也可以禁止在請求過程中關閉彈窗; 

 5、請求失敗時自動顯示錯誤Toast,可以自定義樣式或不顯示; 

 6、token失效時回撥指定方法; 

 7、可以自定義載入提示文字;

二、初始化http元件

在應用啟動時呼叫BaseLibPlugin.init()初始化httpConfig:

BaseLibPlugin.init(
        ...省略其它程式碼
        ///http配置
        httpConfig: HttpConfigImpl(),
        ...
);
複製程式碼

 參考下面程式碼,根據業務實現IHttpConfig介面:

///@date:  2021/2/26 14:01
///@author:  lixu
///@description: 全域性http相關配置
///當前配置在[XApi]類中被呼叫
class HttpConfigImpl implements IHttpConfig {
String _tag = 'HttpConfigImpl';

///配置預設值:http請求時是否顯示載入dialog
@override
bool isShowLoading() {
    return true;
}

///配置預設值:http載入提示文字
@override
String configLoadingText() {
    return 'loading...';
}

///配置預設值:載入中能否通過關閉載入彈窗取消請求
@override
bool isCancelableDialog() {
    return false;
}

///配置預設值:請求失敗時是否自動顯示toast提示錯誤
@override
bool isShowFailToast() {
    return true;
}

///配置預設值:請求前是否校驗網路連線
///true:如果無網路,直接返回錯誤
@override
bool isCheckNetwork() {
    return true;
}

///配置通用的http請求選項[BaseOptions]
///優先順序最低,優先取[XApi]#[request]方法中配置的method和option
@override
BaseOptions configBaseOptions() {
    BaseOptions options = BaseOptions(
            baseUrl: HttpUrls.httpHost,
            connectTimeout: HttpConst.httpTimeOut,
            receiveTimeout: HttpConst.httpTimeOut,
            sendTimeout: HttpConst.httpTimeOut,
            contentType: XApi.contentTypeJson,
            method: XApi.methodPost,
            responseType: ResponseType.json,
         );
    return options;
}

///返回http成功的響應碼
@override
String configHttpResultSuccessCode() {
    return HttpConst.httpResultSuccess.toString();
}

///配置https
@override
bool configHttps(X509Certificate cert, String host, int port) {
    ///TODO 根據業務做校驗
    
    ///true:忽略證書校驗
    return true;
}

///新增http攔截器
///攔截器佇列的執行順序是FIFO,先新增的攔截器先執行
@override
List<Interceptor> configInterceptors() {
    List<Interceptor> interceptors = [];
    interceptors.add(HeaderInterceptor());
    
    ///TODO 可以新增攔截器實現http快取邏輯,或其它功能
    return interceptors;
}

///是否自動新增[LogInterceptors]預設日誌攔截器,列印http請求響應相關的日誌
@override
bool configLogEnable() {
    return true;
}

///每個http請求前回撥該方法獲取baseUrl
///優先順序高於[IHttpConfig]#[configBaseOptions]方法配置的baseUrl
///[url] 當前正在請求的介面url
///return: 返回null使用[IHttpConfig]#[configBaseOptions]方法配置的baseUrl
@override
String getHttpHost(String url) {
    return HttpUrls.httpHost;
}

///http請求失敗時會回撥該方法,判斷是否是token失效導致的錯誤
///[errorBean] 請求失敗物件
@override
bool isHttpRespTokenError(HttpErrorBean errorBean) {
    ///通過code判斷是否是token失效了
    return HttpConst.sysTokenError.toString() == errorBean.code 
            || HttpConst.sysTokenExpired.toString() == errorBean.code;
}

///token失效回撥該方法
///[errorBean] 請求失敗物件
@override
void onTokenErrorCallback(HttpErrorBean errorBean) {
  ToastUtils.show("Token 失效:${errorBean.toString()}", isShowLong: true);
  ///TODO 實現token失效的業務邏輯
  
}

///將http響應的json解析成物件
///[url] 當前請求url
///[jsonData] http響應完整json
///[isRespListData] 響應資料是否是List格式
@override
HttpResultBean<T> parseJsonToObject<T>(String url, Map<String, dynamic> 
jsonData, bool isRespListData) {
  ///TODO 通過傳遞的泛型,解析成物件
  return HttpJsonUtils.parseJsonToObject<T>(url, jsonData, isRespListData);
}

///http請求顯示載入框
///[XApi]#[request]方法isShowLoading欄位為true時,會回撥該方法
///[url] 當前請求url
///[tag] 當前請求對應的tag,唯一
///[cancelToken] 用於載入框關閉時取消http請求
///[loadingText] 載入提示提示文字
///[isCancelableDialog] 請求過程中能否關閉載入框,預設false
@override
void showLoading(String url, int tag, CancelToken cancelToken, String loadingText, 
bool isCancelableDialog) {    LogUtils.i(_tag, 'showLoading tag:$tag  loadingText:$loadingText');

        ///顯示http載入dialog:isShowLoading為true時,會回撥該方法
        BaseLibPlugin.oneContext.showDialog(
                    barrierDismissible: false,
                    barrierColor: Colors.transparent,
                    isBackButtonDismissible: isCancelableDialog,
                    builder: (_) {
                        ///TODO 可以參考HttpLoadingDialog類自定義dialog樣式
                        
                        return HttpLoadingDialog(loadingText);
                    },
                    onClickBackButtonDismissCallback: () {
                            ///請求過程中關閉載入框時取消請求
                            if (isCancelableDialog) {
                                  XApi().cancel(cancelToken);
                            }
                    },
         );
}

///http請求完成,關閉載入框
///[XApi]#[request]方法isShowLoading欄位為true時,會回撥該方法
///[url] 當前請求url
///[tag]當前請求對應的tag,唯一
@override
void hideLoading(String url, int tag, bool isCancelled) {
    if (!isCancelled && BaseLibPlugin.oneContext.hasDialogVisible) {
          BaseLibPlugin.oneContext.popDialog();
    }
}
複製程式碼

三、XApi 主要方法說明

///@date:  2021/2/25 14:25
///@author:  lixu
///@description: 網路請求基類(單例)
class XApi {
    ...省略其它程式碼

///單例  
factory XApi() {
    if (_instance == null) {
        _instance = XApi._internal();
    }
    return _instance;
}

///私有構造方法  
XApi._internal() {
    ///基於通用BaseOptions建立單例Dio物件
    _dio = Dio(_httpConfig.configBaseOptions());
    
    ///攔截器佇列的執行順序是FIFO
    ///新增自定義攔截器
    if (_httpConfig.configInterceptors() != null 
            && _httpConfig.configInterceptors().isNotEmpty) {
        _dio.interceptors.addAll(_httpConfig.configInterceptors());
    }

    ///新增預設的日誌攔截器
    if (_httpConfig.configLogEnable()) {
        _dio.interceptors.add(LogsInterceptors());
    }

    (_dio.httpClientAdapter as DefaultHttpClientAdapter).
        onHttpClientCreate = (client) {
            client.badCertificateCallback = 
                (X509Certificate cert, String host, int port) {
                    ///true:忽略證書校驗
                    return _httpConfig.configHttps(
                                        cert,host,port) ?? true;
                };
    };
}   


///發起請求,響應data為單個物件
Future request<T>(String url,
{Map<String, dynamic> params,
    Map<String, dynamic> header,
    String method,
    RequestOptions option,
    bool isShowLoading,
    String loadingText,
    bool isCancelableDialog,
    bool isShowFailToast,
    bool isCheckNetwork,
    CancelToken cancelToken,
    Function(T) onSuccess,
    Function(HttpErrorBean) onError,
    Function() onComplete}
) {
    呼叫_commonRequest方法
}


///發起請求,響應data為List
Future requestList<T>(String url,
{Map<String, dynamic> params,
    Map<String, dynamic> header,
    String method,
    RequestOptions option,
    bool isShowLoading,
    String loadingText,
    bool isCancelableDialog,
    bool isShowFailToast,
    bool isCheckNetwork,
    CancelToken cancelToken,
    Function(List<T>) onSuccess,
    Function(HttpErrorBean) onError,
    Function() onComplete}
){
    呼叫_commonRequest方法
}

///通用http請求
///[url] 請求url
///[params] 請求引數,可為空
///[isResultList] 返回的data是否是List型別
///[header] 請求頭
///[method] 請求方法,優先順序最高
///[option] 針對當前請求的配置選項,優先順序次高
///[isShowLoading] 是否顯示載入彈窗
///[loadingText] 載入提示
///[isCancelableDialog] 載入中能否關閉載入彈窗
///[isShowFailToast] 請求失敗時是否自動顯示toast提示錯誤
///[isCheckNetwork] 請求前是否校驗網路連線
///[onSuccessListCallback] 請求List成功回撥
///[onSuccessObjCallback] 請求單個物件成功回撥
///[onErrorCallback] 請求失敗回撥
///[onComplete] 請求完成回撥,在onSuccess或onError方法後面呼叫
Future _commonRequest<T>(String url, bool isResultList,
        {Map<String, dynamic> params,
        Map<String, dynamic> header,
        String method,
        RequestOptions option,
        bool isShowLoading,
        String loadingText,
        bool isCancelableDialog,
        bool isShowFailToast,
        bool isCheckNetwork,
        CancelToken cancelToken,
        Function(List<T>) onSuccessListCallback,
        Function(T) onSuccessObjCallback,
        Function(HttpErrorBean) onErrorCallback,
        Function() onCompleteCallback}) async {
        
    ///設定預設值
    isShowLoading ??= _httpConfig.isShowLoading();
    loadingText ??= _httpConfig.configLoadingText();
    isCancelableDialog ??= _httpConfig.isCancelableDialog();
    isShowFailToast ??= _httpConfig.isShowFailToast();
    isCheckNetwork ??= _httpConfig.isCheckNetwork();

    if (isCheckNetwork) {
        ///判斷網路連線
        ConnectivityResult connResult = 
                              await Connectivity().checkConnectivity();
        if (connResult != null && connResult == ConnectivityResult.none) {
            return _onRespErrorCallback(
                    isShowFailToast,
                    onErrorCallback,
                    HttpErrorBean(code: HttpCode.networkError?.toString(), 
                                  message: '無網路連線,請檢查網路設定'),
                    );
         }
    }
    
    option ??= RequestOptions();
    
    ///新增baseUrl
    ///baseUrl優先順序:形參option.baseUrl>_httpConfig.getBaseUrl>_httpConfig.
                                                                configBaseOptions
    if (!url.startsWith(Constants.httpStartWith) 
                        && option.baseUrl == null) {
        String baseUrl = _httpConfig.getBaseUrl(url);
        if (baseUrl != null && baseUrl.isNotEmpty) {
          option.baseUrl = baseUrl;
        }
    }

    params ??= {};
    
    ///新增CancelToken,用於取消請求
    cancelToken ??= CancelToken();
    _cancelTokenList ??= [];
    _cancelTokenList.add(cancelToken);
    
    ///處理請求頭
    if (header != null) {
        option.headers ??= {};
        option.headers.addAll(header);
    }

    ///設定請求方法
    if (method != null) {
        option.method = method;
    } else {
        option.method ??= methodPost;
    }
    
    ///顯示載入框
    if (isShowLoading) {
      ///只是封裝了顯示dialog的邏輯,具體的dialog UI實現交給呼叫者處理
       _httpConfig.showLoading(url, 
                               cancelToken.hashCode, 
                               cancelToken, 
                               loadingText, 
                               isCancelableDialog
                               );
    }

    try {
        Response response;
        if (methodGet == option.method) {
            ///get請求
            response = await _dio.get(url, 
                                      queryParameters: params, 
                                      options: option, 
                                      cancelToken: cancelToken
                                      );
        } else if (methodPost == option.method) {
            ///預設post請求
            response = await _dio.post(url, 
                                       data: params, 
                                       options: option, 
                                       cancelToken: cancelToken
                                       );
        } else {
            ///其他請求方式
            response = await _dio.request(url, 
                                          data: params, 
                                          options: option, 
                                          cancelToken: cancelToken
                                          );
        }
        
        ///json解析
        HttpResultBean<T> resultBean = _parseJsonToObject<T>(url, 
                                                             response, 
                                                             isResultList
                                                             );
        if (resultBean.isSuccess()) {
            ///請求成功
              _onRespSuccessCallback(
                  resultBean, 
                  onSuccessObjCallback, 
                  onSuccessListCallback
              );
        } else {
             ///請求失敗
              _onRespErrorCallback(
                  isShowFailToast, 
                  onErrorCallback, 
                  resultBean.obtainErrorBean()
              );
        }
    } on DioError catch (e) {
        _onRespErrorCallback(
            isShowFailToast, 
            onErrorCallback, 
            _createErrorEntity(e)
        );
    } catch (exception) {
        LogUtils.e(_tag, ' 異常:${exception?.toString()}');
        _onRespErrorCallback(
            isShowFailToast,
            onErrorCallback,
            HttpErrorBean(
                code: HttpCode.fail, 
                message: exception?.toString() ?? '網路異常,請稍後再試'
            ),
        );
    } finally {
        ///請求完成,隱藏載入框
        if (isShowLoading) {
            _httpConfig.hideLoading(url, 
                    cancelToken.hashCode, 
                    cancelToken.isCancelled
            );
        }
    
        ///請求完成移除cancelToken
        if (cancelToken != null 
                && _cancelTokenList != null 
                && _cancelTokenList.contains(cancelToken)) {
            _cancelTokenList.remove(cancelToken);
        }
    
        ///請求完成回撥
        onCompleteCallback?.call();
    }

}

///http響應json解析為物件
///[response] http 響應的物件
///[isRespListData] http響應的資料是否是List資料結構
HttpResultBean<T> _parseJsonToObject<T>(String url, Response response, 
                                        bool isRespListData) {
    if (response == null || response.data == null) {
        HttpResultBean<T> resultBean = HttpResultBean();
        resultBean.isRespListData = isRespListData;
        resultBean.code = HttpCode.unKnowError;
        resultBean.message = 'response is null';
        return resultBean;
    }
    
    //TODO 將json解析邏輯交給呼叫者處理
    HttpResultBean<T> resultBean = _httpConfig.parseJsonToObject<T>(
                             url, response.data, isRespListData);
    resultBean.json = response.data;
    return resultBean;
}

 ...省略其它程式碼
}
複製程式碼

四、http攔截器使用

http請求時經常需要新增通用的請求引數和請求頭,或是對響應資料進行預處理,通過新增攔截器來實現:

1、定義攔截器

///@date:  2021/2/25 14:22
///@author:  lixu
///@description: http攔截器,新增請求頭和通用引數
class HeaderInterceptor extends InterceptorsWrapper {
  String _tag = 'HeaderInterceptor';

@override
onRequest(RequestOptions options) async {
    LogUtils.d(_tag, 'onRequest()');

    ///通用引數
    var params = {
        'lang': 'zhcn',
        'centerId': loginInfo.getCenterId(),
    };

    ///通過請求引數生成sign,新增到請求頭
    String sign;
    if (XApi.methodGet == options.method) {
          options.queryParameters = 
                  (Map<String, dynamic>.from(
                      options.queryParameters ?? {}))..addAll(params);
          sign = await HttpUtils.getSignEncode(
                              options.queryParameters, 
                              HttpConst.serverKey
                       );
    } else {
          options.data = 
                  (Map<String, dynamic>.from(
                      options.data ?? {}))..addAll(params);
          sign = await HttpUtils.getSignEncode(
                              options.data, 
                              HttpConst.serverKey
                       );
    }

    ///新增請求頭
    Map<String, String> headerMap = 
            loginInfo.token != null ? {'token': loginInfo.token} : {};
            
    headerMap.putIfAbsent('sign', () => sign);
    
    options.headers ??= {};
    options.headers.addAll(headerMap);

    return options;
}

@override
Future onResponse(Response response) async {
    ///從登入介面中獲取token和使用者資訊
    ///TODO 也可以直接在登入響應的物件中獲取使用者資訊和token,此處只是演示http攔截器功能
    if (response.request.path.contains(HttpUrls.loginUrl)) {
          HttpResultBean<LoginResultBean> resultBean = 
              HttpJsonUtils.parseJsonToObject(HttpUrls.loginUrl, 
                                              response.data, 
                                              false
                                              );
          if (resultBean.isSuccess()) {
                loginInfo.token = resultBean.data?.token;
                loginInfo.userBean = resultBean.data?.user;
          }
          LogUtils.i(_tag, '登入獲取的token:${loginInfo.token}');
    }
    return response;
    
}
    
    
}
複製程式碼

2、新增攔截器

///@date:  2021/2/26 14:01
///@author:  lixu
///@description: http相關配置
///當前配置在[XApi]類中被呼叫
class HttpConfigImpl implements IHttpConfig {
    String _tag = 'HttpConfigImpl';
    
    ...省略其它程式碼
    
    ///新增http攔截器
    ///攔截器佇列的執行順序是FIFO,先新增的攔截器先執行
    @override
    List<Interceptor> configInterceptors() {
        List<Interceptor> interceptors = [];
        interceptors.add(HeaderInterceptor());
        return interceptors;
    }

    ...省略其它程式碼
}
複製程式碼

五、http請求獲取單個物件

參考LoginViewModel中onLogin()方法:

///呼叫登入介面,獲取單個物件
Future<bool> onLogin() async {
    Map<String, dynamic> map = {
        'account': '15015001500',
        'pass': '123qwe',
        'appType': 'PATIENT',
        'device': 'ANDROID',
        'push': '13065ffa4e22e63efd2',
    };

    ///http請求方法全部欄位功能說明
    RequestOptions option = RequestOptions();
    option.method = XApi.methodPost;
    option.baseUrl = HttpUrls.httpHost;

    await XApi().request<LoginResultBean>(
            HttpUrls.loginUrl,
            params: map,
            //優先順序最高
            method: XApi.methodPost,
            //針對當前請求的配置選項,優先順序次高
            option: option,
            cancelToken: loginCancelToken,
            //請求前檢測網路連線是否正常,如果連線異常,直接返回錯誤
            isCheckNetwork: true,
            //顯示載入dialog
            isShowLoading: true,
            //載入dialog顯示的提示文字
            loadingText: '正在登入...',
            //請求失敗時顯示toast提示
            isShowFailToast: true,
            //請求過程中可以關閉載入彈窗(請求過程中關閉dialog時自動取消請求)
            isCancelableDialog: true,
            onSuccess: (LoginResultBean bean) {
                _loginResultBean = bean;
                ToastUtils.show('登入成功');
            },
            onError: (HttpErrorBean errorBean) {
                _loginResultBean = null;
                LogUtils.e(getTag(), '登入失敗');
            },
            onComplete: () {
                LogUtils.i(getTag(), '登入完成');
            },
    );
  return _loginResultBean?.token != null && _loginResultBean?.user != null;
}
複製程式碼

六、http請求獲取List物件

參考LoginViewModel中getUserList()方法

///獲取使用者列表List
Future<List<UserDetailBean>> getUserList() async {

    var params = {
        'userId': loginInfo.userBean?.userId,
        'token': loginInfo.token,
    };

    List<UserDetailBean> userList;
    await api.requestList<UserDetailBean>(HttpUrls.userListUrl,
                params: params,
                onSuccess: (List<UserDetailBean> list) {
                      userList = list;
                },
                onError: (HttpErrorBean errorBean) {
                      LogUtils.e(getTag(), '獲取使用者列表失敗');
                },
    );

    return userList;
}
複製程式碼

七、最簡單的http請求

參考LoginViewModel中simplestHttpDemo()方法:

Future simplestHttpDemo(BuildContext context) async {
    var params = {
        'userId': loginInfo.userBean?.userId,
        'token': loginInfo.token,
    };
    
    ///最簡單的http請求,可以滿足大多數場景    
    await api.requestList<UserDetailBean>(HttpUrls.userListUrl,
                params: params,
                onSuccess: (List<UserDetailBean> list) {
                  ToastUtils.show('獲取使用者列表成功,使用者數:${list?.length}');
                },
    );
}
複製程式碼

上面最簡單的http請求包含的功能:

1、自動顯示、隱藏載入dialog,請求過程中dialog不能關閉 ;

2、請求失敗自動顯示Toast提示錯誤資訊 ;

3、使用預設請求方法(IHttpConfig#configBaseOptions()物件配置的方法);

僅3行程式碼就能滿足大多數http請求的場景,Demo地址

相關文章