# 封装请求

在继续后续的功能开发之前,短信是需要发送请求的,所以,需要考虑一个请求工具类的封装。

需求分析:

要考虑第三方包有没有类似于axios,对RESTful类型的请求进行支持的

  • 拦截器
  • 请求取消
  • FormData,文件上传/下载功能
  • ...

# Dio库介绍

image-20220119141722413

特点:

官方提供了大量的示例,而且使用的人数非常多:

image-20220119141847600

# 基础封装

安装依赖:

dependencies:
  dio: ^4.0.4

使用命令flutter pub get安装最新的包,说明,课程中使用的是4.0.0的版本,但是由于该版本有BUG,后面会有介绍到,大家可以尝试一下最新的版本。

# 需求分析

与前端项目一样,需要创建单例(Get_it,工厂函数来创建 (opens new window))以便后续发送请求可以使用同一个实例。

而且,方便后续进行断线重连或者处理401的RefreshToken的逻辑。

# 工厂函数

class Logger {
  final String name;
  bool mute = false;

  // _cache is library-private, thanks to
  // the _ in front of its name.
  static final Map<String, Logger> _cache =
      <String, Logger>{};

  factory Logger(String name) {
    return _cache.putIfAbsent(
        name, () => Logger._internal(name));
  }

  factory Logger.fromJson(Map<String, Object> json) {
    return Logger(json['name'].toString());
  }

  Logger._internal(this.name);

  void log(String msg) {
    if (!mute) print(msg);
  }
}

对比两个实例:

var logger = Logger('UI');
logger.log('Button clicked');

var logMap = {'name': 'UI'};
var loggerJson = Logger.fromJson(logMap);

// 使用print查验 是否为同一个实例
print(identical(loggerJson, logMap))

# 创建工具类及配置

创建utils/request/dio_http.dart,代码:

// Dio -> http客户端
// 如何在Dart中使用单例 -> 1.Get_it 2.factory
import 'package:dio_http/dio_http.dart';
import 'package:my_app/config.dart';

class DioHttp {
  Dio _client = Dio();
  Dio get client => _client;
  // 1.static -> 作用:让多个相同类型的类共享同一个成员变量
  // 2.构造函数:匿名,命名式 -> ._(名称) -> factory
  static DioHttp _instance = DioHttp._internal();
  factory DioHttp() => _instance;

  DioHttp._internal() {
    // 对dio进行实例化
    _client.options = _client.options.copyWith(
      baseUrl: Config.baseUrl,
      connectTimeout: 1000 * 10,
      receiveTimeout: 1000 * 5,
    );
    // 初始化_client上的拦截器 -> 添加多个
    _client.interceptors
      ..addAll([
        // todo
      ]);
  }

  // DioHttp().公有的方法 or 属性
}

创建config.dart

class Config {
  static const String baseUrl = 'http://localhost:3000';
}

使用的方式:

DioHttp().client // 然后在clinet上有RESTful的方法

// 例如:
DioHttp().client.get

# 常见RESTful方法

# GET/POST/PUT/DELETE

问题:如何知道需要取用的参数?如何确定哪些可用的参数?

可以借助着dart优秀的类型的推导功能来进行选择需要的参数及设置类型。

image-20220119145751308

修改utils/request/dio_http.dart,代码:

// DioHttp().公有的方法 or 属性
// init -> baseUrl headers
void init(
    {String? baseUrl,
    int? connectTimeout,
    int? receiveTimeout,
    List<Interceptor>? interceptors}) {
  _client.options = _client.options.copyWith(
    baseUrl: baseUrl,
    connectTimeout: connectTimeout,
    receiveTimeout: receiveTimeout,
  );
  _client.interceptors..addAll(interceptors!);
}

// RESTful -> get post delete put
// axios -> axios.get(path, params, {headers})
Future<Response<Map<String, dynamic>>> get(String path,
    [Map<String, dynamic>? params, Options? options]) async {
  return await _client.get(
    path,
    queryParameters: params,
    options: options,
  );
}

Future<Response<Map<String, dynamic>>> post(String path,
    [Map<String, dynamic>? data, Options? options]) async {
  return await _client.post(
    path,
    data: data,
    options: options,
  );
}

Future<Response<Map<String, dynamic>>> delete(String path,
    [Map<String, dynamic>? data, Options? options]) async {
  return await _client.delete(
    path,
    data: data,
    options: options,
  );
}

Future<Response<Map<String, dynamic>>> put(String path,
    [Map<String, dynamic>? data, Options? options]) async {
  return await _client.put(
    path,
    data: data,
    options: options,
  );
}

添加了如上的代码之后,可以不用使用client属性才去使用RESTful方法了,可以直接使用DioHttp().get,等。

# 上传文件

上传文件可以参考官方的代码:

// 定义DioFile
class DioFile {
  final String path;
  final String filename;

  DioFile({required this.path, required this.filename});
}

// ...

// formData -> 上传文件
Future<FormData> _prepareFormData(String path, String filename) async {
  return FormData.fromMap({
    'file': await MultipartFile.fromFile(
      path,
      filename: filename,
    ),
  });
}

// 进度条
void _showProgress(received, total) {
  if (total != -1) {
    print((received / total * 100).toStringAsFixed(0) + '%');
  }
}


Future<Response<Map<String, dynamic>>> postFormData(String path,
    DioFile dioFile, void Function(int, int)? onSendProgress) async {
  return await _client.post(
    path,
    data: await _prepareFormData(dioFile.path, dioFile.filename),
    onSendProgress: onSendProgress ?? _showProgress,
  );
}

# 下载文件

可以参考官方的代码:


// 下载文件
// e.g. 示例代码:https://github.com/flutterchina/dio/blob/master/example/download_with_trunks.dart
// await downloadWithChunks(url, savePath, onReceiveProgress: (received, total) {
//     if (total != -1) {
//       print('${(received / total * 100).floor()}%');
//     }
//   });
/// Downloading by spiting as file in chunks
Future downloadWithChunks(
  url,
  savePath, {
  ProgressCallback? onReceiveProgress,
}) async {
  const firstChunkSize = 102;
  const maxChunk = 3;

  var total = 0;
  var dio = Dio();
  var progress = <int>[];

  void Function(int, int) createCallback(no) {
    return (int received, int _) {
      progress[no] = received;
      if (onReceiveProgress != null && total != 0) {
        onReceiveProgress(progress.reduce((a, b) => a + b), total);
      }
    };
  }

  Future<Response> downloadChunk(url, start, end, no) async {
    progress.add(0);
    --end;
    return dio.download(
      url,
      savePath + 'temp$no',
      onReceiveProgress: createCallback(no),
      options: Options(
        headers: {'range': 'bytes=$start-$end'},
      ),
    );
  }

  Future mergeTempFiles(chunk) async {
    var f = File(savePath + 'temp0');
    var ioSink = f.openWrite(mode: FileMode.writeOnlyAppend);
    for (var i = 1; i < chunk; ++i) {
      var _f = File(savePath + 'temp$i');
      await ioSink.addStream(_f.openRead());
      await _f.delete();
    }
    await ioSink.close();
    await f.rename(savePath);
  }

  var response = await downloadChunk(url, 0, firstChunkSize, 0);
  if (response.statusCode == 206) {
    total = int.parse(response.headers
        .value(HttpHeaders.contentRangeHeader)!
        .split('/')
        .last);
    var reserved = total -
        int.parse(response.headers.value(Headers.contentLengthHeader)!);
    var chunk = (reserved / firstChunkSize).ceil() + 1;
    if (chunk > 1) {
      var chunkSize = firstChunkSize;
      if (chunk > maxChunk + 1) {
        chunk = maxChunk + 1;
        chunkSize = (reserved / maxChunk).ceil();
      }
      var futures = <Future>[];
      for (var i = 0; i < maxChunk; ++i) {
        var start = firstChunkSize + i * chunkSize;
        futures.add(downloadChunk(url, start, start + chunkSize, i + 1));
      }
      await Future.wait(futures);
    }
    await mergeTempFiles(chunk);
  }
}

# 拦截器

# 自定义拦截器

首先要了解如何创建dio的拦截器,官方的文档:

dio.interceptors.add(InterceptorsWrapper(
    onRequest:(options, handler){
     // Do something before request is sent
     return handler.next(options); //continue
     // 如果你想完成请求并返回一些自定义数据,你可以resolve一个Response对象 `handler.resolve(response)`。
     // 这样请求将会被终止,上层then会被调用,then中返回的数据将是你的自定义response.
     //
     // 如果你想终止请求并触发一个错误,你可以返回一个`DioError`对象,如`handler.reject(error)`,
     // 这样请求将被中止并触发异常,上层catchError会被调用。
    },
    onResponse:(response,handler) {
     // Do something with response data
     return handler.next(response); // continue
     // 如果你想终止请求并触发一个错误,你可以 reject 一个`DioError`对象,如`handler.reject(error)`,
     // 这样请求将被中止并触发异常,上层catchError会被调用。
    },
    onError: (DioError e, handler) {
     // Do something with response error
     return  handler.next(e);//continue
     // 如果你想完成请求并返回一些自定义数据,可以resolve 一个`Response`,如`handler.resolve(response)`。
     // 这样请求将会被终止,上层then会被调用,then中返回的数据将是你的自定义response.
    }
));

官方的示例:

import 'package:dio/dio.dart';
class CustomInterceptors extends Interceptor {
  
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    print('REQUEST[${options.method}] => PATH: ${options.path}');
    return super.onRequest(options, handler);
  }
  
  Future onResponse(Response response, ResponseInterceptorHandler handler) {
    print('RESPONSE[${response.statusCode}] => PATH: ${response.requestOptions?.path}');
    return super.onResponse(response, handler);
  }
  
  Future onError(DioError err, ErrorInterceptorHandler handler) {
    print('ERROR[${err.response?.statusCode}] => PATH: ${err.requestOptions?.path}');
    return super.onError(err, handler);
  }
}

接下来了解到如何创建拦截器之后,就是自定义统一错误处理的拦截器了。

# 统一错误处理

与前端axios是一样的,可以在一个统一的位置对所有的请求异常进行处理,包括4xx/5xx的错误以及RefreshToken的逻辑。

定义错误的类型utils/request/http_exception.dart,用于扩展基础的HttpException:

// 定义一个基类 -> HttpException
class HttpException implements Exception {
  final String? _message;
  final int? _code;

  // 接收的可选参数 有位置的限制
  // 传一个参数 -> message
  // 传两个参数 -> message code
  // 传参不需要设置属性名
  HttpException([this._message, this._code]);

  String get message => _message ?? this.runtimeType.toString();
  int get code => _code ?? -1;
}

class BadRequestException extends HttpException {
  // message与code需要在设置的时候,传递属性名
  // message与code不是必传参数 -> null
  BadRequestException({String? message, int? code}) : super(message, code);
}

class BadServiceException extends HttpException {
  BadServiceException({String? message, int? code}) : super(message, code);
}

class UnknownException extends HttpException {
  UnknownException({String? message}) : super(message);
}

class CancelException extends HttpException {
  CancelException({String? message}) : super(message);
}

class NetWorkException extends HttpException {
  NetWorkException({String? message, int? code}) : super(message, code);
}

class UnauthorizedException extends HttpException {
  UnauthorizedException({String? message}) : super(message, 401);
}

class BadResponseException extends HttpException {
  BadResponseException({String? message, int? code}) : super(message, code);
}

主要的作用:更加语义化的编码,方便的设置参数,比如错误message消息、错误code代码。

创建一个统一的错误处理的parse方法utils/request/parse_exception.dart

import 'dart:io';

import 'package:dio_http/dio_http.dart';

import 'http_exception.dart';

HttpException parseException(DioError error) {
  switch (error.type) {
    case DioErrorType.connectTimeout:
    case DioErrorType.receiveTimeout:
    case DioErrorType.sendTimeout:
      return NetWorkException(message: error.message);
    case DioErrorType.cancel:
      return CancelException(message: error.message);
    case DioErrorType.response:
      try {
        int? errCode = error.response?.statusCode;
        switch (errCode) {
          case 400:
            return BadRequestException(
                message: '发出的请求有错误,服务器没有进行新建或修改数据的操作。', code: errCode);
          case 401:
            return UnauthorizedException(message: '用户没有权限(令牌、用户名、密码错误)。');
          case 403:
            return BadRequestException(
                message: '用户得到授权,但是访问是被禁止的。', code: errCode);
          case 404:
            return BadRequestException(
                message: '发出的请求针对的是不存在的记录,服务器没有进行操作。', code: errCode);
          case 405:
            return BadRequestException(
                message: '请求方法不存在,请检查路由!', code: errCode);
          case 406:
            return BadRequestException(message: '请求的格式不可得。', code: errCode);
          case 410:
            return BadRequestException(
                message: '请求的资源被永久删除,且不会再得到的。', code: errCode);
          case 422:
            return BadRequestException(
                message: '当创建一个对象时,发生一个验证错误。', code: errCode);
          case 500:
            return BadServiceException(
                message: '服务器发生错误,请检查服务器。', code: errCode);
          case 502:
            return BadServiceException(message: '网关错误。', code: errCode);
          case 503:
            return BadServiceException(
                message: '服务不可用,服务器暂时过载或维护。', code: errCode);
          case 504:
            return BadServiceException(message: '网关超时。', code: errCode);
          default:
            return UnknownException(message: '不支持HTTP协议请求');
        }
      } on Exception catch (_) {
        return UnknownException(message: error.message);
      }
    case DioErrorType.other:
      if (error.error is SocketException) {
        return NetWorkException(message: error.message);
      } else {
        return UnknownException(message: error.message);
      }
    default:
      return UnknownException(message: error.message);
  }
}

创建utils/request/dio_error_interceptor.dart文件:

import 'package:dio_http/dio_http.dart';
import 'package:my_app/utils/request/http_exception.dart';
import 'package:my_app/utils/request/parse_exception.dart';

// 异常的类型
// 网络异常
// 客户端异常
// 服务端异常
class ErrorInterceptor extends Interceptor {
  ErrorInterceptor();

  
  void onError(DioError err, ErrorInterceptorHandler handler) {
    // print('onError: $err');
    // 401 -> refreshToken
    HttpException httpException = parseException(err);
    // 4xx 5xx -> parseException -> 日志 -> 本地数据库&缓存
    // todo 发送错误日志的请求
    handler.resolve(Response(
        requestOptions: err.requestOptions,
        statusCode: httpException.code,
        data: {
          'code': httpException.code,
          'message': httpException.message,
        }));
    // super.onError(err, handler);
  }
}

在dio_http.dart中引用该拦截器:

DioHttp._internal() {
  // 对dio进行实例化
  _client.options = _client.options.copyWith(
    baseUrl: Config.baseUrl,
    connectTimeout: 1000 * 10,
    receiveTimeout: 1000 * 5,
  );
  // 初始化_client上的拦截器 -> 添加多个
  _client.interceptors
    ..addAll([
      // 统一的错误处理
      ErrorInterceptor(),
    ]);
}

# 取消网络请求

关键逻辑:

// 取消请求
void cancelRequests({CancelToken? cancelToken}) {
  cancelToken ?? _cancelToken.cancel('Request Canceled');
  cancelToken?.cancel('Request Canceled');
}

这里有一个语法糖:

cancelToken ??是判断前面的方法是否存在,如果存在,则执行之后的代码。

修改过后的dio_http.dart文件:

// Dio -> http客户端
// 如何在Dart中使用单例 -> 1.Get_it 2.factory
import 'dart:io';

import 'package:dio_http/dio_http.dart';
import 'package:my_app/config.dart';

import 'dio_error_interceptor.dart';

class DioFile {
  final String path;
  final String filename;

  DioFile({required this.path, required this.filename});
}

class DioHttp {
  Dio _client = Dio();
  Dio get client => _client;
  CancelToken _cancelToken = CancelToken();

  // 1.static -> 作用:让多个相同类型的类共享同一个成员变量
  // 2.构造函数:匿名,命名式 -> ._(名称) -> factory
  static DioHttp _instance = DioHttp._internal();
  factory DioHttp() => _instance;

  DioHttp._internal() {
    // 对dio进行实例化
    _client.options = _client.options.copyWith(
      baseUrl: Config.baseUrl,
      connectTimeout: 1000 * 10,
      receiveTimeout: 1000 * 5,
    );
    // 初始化_client上的拦截器 -> 添加多个
    _client.interceptors
      ..addAll([
        // todo
        ErrorInterceptor(),
      ]);
  }

  // DioHttp().公有的方法 or 属性
  // init -> baseUrl headers
  void init(
      {String? baseUrl,
      int? connectTimeout,
      int? receiveTimeout,
      List<Interceptor>? interceptors}) {
    _client.options = _client.options.copyWith(
      baseUrl: baseUrl,
      connectTimeout: connectTimeout,
      receiveTimeout: receiveTimeout,
    );
    _client.interceptors..addAll(interceptors!);
  }

  // 取消请求
  void cancelRequests({CancelToken? cancelToken}) {
    cancelToken ?? _cancelToken.cancel('Request Canceled');
    cancelToken?.cancel('Request Canceled');
  }

  // RESTful -> get post delete put
  // axios -> axios.get(path, params, {headers})
  Future<Response<Map<String, dynamic>>> get(String path,
      [Map<String, dynamic>? params, Options? options, CancelToken? cancelToken,]) async {
    return await _client.get(
      path,
      queryParameters: params,
      options: options,
      cancelToken: cancelToken ?? _cancelToken,
    );
  }

  Future<Response<Map<String, dynamic>>> post(String path,
      [Map<String, dynamic>? data, Options? options, CancelToken? cancelToken,]) async {
    return await _client.post(
      path,
      data: data,
      options: options,
      cancelToken: cancelToken ?? _cancelToken,
    );
  }

  Future<Response<Map<String, dynamic>>> delete(String path,
      [Map<String, dynamic>? data, Options? options, CancelToken? cancelToken,
  ]) async {
    return await _client.delete(
      path,
      data: data,
      options: options,
      cancelToken: cancelToken ?? _cancelToken,
    );
  }

  Future<Response<Map<String, dynamic>>> put(String path,
      [Map<String, dynamic>? data, Options? options,
    CancelToken? cancelToken,
  ]) async {
    return await _client.put(
      path,
      data: data,
      options: options,
      cancelToken: cancelToken ?? _cancelToken,
    );
  }

  // formData -> 上传文件
  Future<FormData> _prepareFormData(String path, String filename) async {
    return FormData.fromMap({
      'file': await MultipartFile.fromFile(
        path,
        filename: filename,
      ),
    });
  }

  void _showProgress(received, total) {
    if (total != -1) {
      print((received / total * 100).toStringAsFixed(0) + '%');
    }
  }

  Future<Response<Map<String, dynamic>>> postFormData(String path,
      DioFile dioFile, void Function(int, int)? onSendProgress, CancelToken? cancelToken,
  ) async {
    return await _client.post(
      path,
      data: await _prepareFormData(dioFile.path, dioFile.filename),
      onSendProgress: onSendProgress ?? _showProgress,
      cancelToken: cancelToken ?? _cancelToken,
    );
  }

  // 下载文件
  // e.g. 示例代码:https://github.com/flutterchina/dio/blob/master/example/download_with_trunks.dart
  // await downloadWithChunks(url, savePath, onReceiveProgress: (received, total) {
  //     if (total != -1) {
  //       print('${(received / total * 100).floor()}%');
  //     }
  //   });
  /// Downloading by spiting as file in chunks
  Future downloadWithChunks(
    url,
    savePath, {
    ProgressCallback? onReceiveProgress,
    CancelToken? cancelToken,
  }) async {
    const firstChunkSize = 102;
    const maxChunk = 3;

    var total = 0;
    var dio = Dio();
    var progress = <int>[];

    void Function(int, int) createCallback(no) {
      return (int received, int _) {
        progress[no] = received;
        if (onReceiveProgress != null && total != 0) {
          onReceiveProgress(progress.reduce((a, b) => a + b), total);
        }
      };
    }

    Future<Response> downloadChunk(url, start, end, no) async {
      progress.add(0);
      --end;
      return dio.download(
        url,
        savePath + 'temp$no',
        onReceiveProgress: createCallback(no),
        options: Options(
          headers: {'range': 'bytes=$start-$end'},
        ),
        cancelToken: cancelToken ?? _cancelToken,
      );
    }

    Future mergeTempFiles(chunk) async {
      var f = File(savePath + 'temp0');
      var ioSink = f.openWrite(mode: FileMode.writeOnlyAppend);
      for (var i = 1; i < chunk; ++i) {
        var _f = File(savePath + 'temp$i');
        await ioSink.addStream(_f.openRead());
        await _f.delete();
      }
      await ioSink.close();
      await f.rename(savePath);
    }

    var response = await downloadChunk(url, 0, firstChunkSize, 0);
    if (response.statusCode == 206) {
      total = int.parse(response.headers
          .value(HttpHeaders.contentRangeHeader)!
          .split('/')
          .last);
      var reserved = total -
          int.parse(response.headers.value(Headers.contentLengthHeader)!);
      var chunk = (reserved / firstChunkSize).ceil() + 1;
      if (chunk > 1) {
        var chunkSize = firstChunkSize;
        if (chunk > maxChunk + 1) {
          chunk = maxChunk + 1;
          chunkSize = (reserved / maxChunk).ceil();
        }
        var futures = <Future>[];
        for (var i = 0; i < maxChunk; ++i) {
          var start = firstChunkSize + i * chunkSize;
          futures.add(downloadChunk(url, start, start + chunkSize, i + 1));
        }
        await Future.wait(futures);
      }
      await mergeTempFiles(chunk);
    }
  }
}

调用cancel方法:

DioHttp().cancelRequest() // 即可取消当前请求

取消之后会收到一个Request被取消的Error,会交由统一的错误处理:

image-20220119160132630

特别说明:

Dio取消请求与其他比如axios取消请求是一样的,只是取消了前端接收的数据,但是只要是该请求被服务端接收到,服务端该处理的还是会处理,只是返回的数据被丢弃掉了。

一般在发送请求之前,会在Button上加上延迟处理,这样可以防止用户频繁重复的请求。

# 请求日志

在Dio中有一个官方的拦截器:

修改dio_http.dart文件:

DioHttp._internal() {
  // 对dio进行实例化
  _client.options = _client.options.copyWith(
    baseUrl: Config.baseUrl,
    connectTimeout: 1000 * 10,
    receiveTimeout: 1000 * 5,
  );
  // 初始化_client上的拦截器 -> 添加多个
  _client.interceptors
    ..addAll([
      // todo
      ErrorInterceptor(),
      // 关键是这里需要判断一下是否是开发&生产环境
      if(Config.env == Env.Dev)LogInterceptor(responseBody: true),
    ]);
}

配置一个环境变量:

enum Env { Dev, Prod }

class Config {
  static const String baseUrl = 'http://localhost:3000';
  static const Env env = Env.Dev;
}

方便区别测试&生产环境。效果:

image-20220119161412411