【Flutter】基于Dio下载组件实现

1,110 阅读4分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第3天,点击查看活动详情

前言

由于项目包体积越来越大,主因是大量的资源文件引入和迭代需求中大量使用动画效果(动画效果实现主要是Lottie动画)。单纯都使用用本地资源文件只会让应用包不断增加,另外一些资源包内容只在某几个版本活动中使用在后续业务中不再使用作为临时文件嵌入在应用包中也不是最佳方案。因此有必要将部分不重要资源文件改为网络下发形式满足业务需求尽量减少应用包体积大小。

功能实现

功能实现依赖以下基础库:文件解压、加密和dio网络库。

  archive: 3.3.0
  crypto: 3.0.0
  dio: 4.0.6

通用下载组件实现

dio网络库非常强大内部自带支持文件下载功能,简易下载代码如下:

_dio.download(url, savePath, onReceiveProgress: (currentSize, allSize) async{
        if(currentSize == allSize){
          File tempFile = File(savePath);
          String resultPath = savePath.replaceAll('.temp', '');
          await tempFile.rename(resultPath);
          String key = EncryptUtils.toMD5(url);
          _callbackDownloadPool(
              key , DownloadResult.success(key,resultPath));
        }else{
          _callbackDownloadPool(
              EncryptUtils.toMD5(url), DownloadResult.loading(allSize, currentSize));
        }
      });

url是资源网络下载地址,savePath是本地缓存地址,onReceiveProgress是资源下载进度。

PS:需要注意的是savePath其实是非最终下载成功的保存文件命名,下载过程中文件命名后缀用.temp修饰表示还只是下载缓存并非最终下载成功的文件。当下载成功后把文件名重命名去掉.temp以此标记文件下载成功了。

本地缓存

采用本地缓存时先创建两个方法来满足缓存需求。rootPath用来获取应用沙盒路径,由于AndroidiOS沙盒路径有所不同需要做区分判断(资源缓存尽量存储在应用内部路径可防止用户误杀操作)。checkFileExist方法可判断文件是否存在,若本地已存在该文件就不必再做资源下载操作直接使用本地文件即可。

static Future<String> rootPath({String resourcePath = "FlutterRes"}) async {
    /// android 根目录:getExternalFilesDir/wdbSticky/version
    /// iOS 根目录:Caches/WDStickyResource/version
    Directory tempDirectory;
    if (Platform.isIOS) {
      tempDirectory = await getTemporaryDirectory();
    } else {
      tempDirectory = await getExternalStorageDirectory();
    }
    return path.join(tempDirectory.path, resourcePath);
  }

  /// 文件是否已存在
  static bool checkFileExist(String filePath){
    File file = File(filePath);
    // 源文件已下载存在
    return file != null && file.existsSync();
  }

断点续传

断点续传能力主要依附于服务端是否支持Range请求。当支持Range请求时在请求头Options中增加range字段,value是"bytes=$downloadFileSize-"表示downloadFileSize表示下载位置起点到最大值。回调通过stream来读取文件数据并写入到文件中。

 void _breakPointDownload(
      String url, String savePath) async {
    File tempFile = File(savePath);
    int downloadFileSize = tempFile.lengthSync();
   // 入参
    Map<String, dynamic> map = {
      "range": "bytes=$downloadFileSize-",
    };
    try {
      Response<ResponseBody> responseBody = await _dio.get<ResponseBody>(
        url,
        options: Options(
          responseType: ResponseType.stream,
          followRedirects: false,
          headers: map,
        ),
      );

      RandomAccessFile raf = tempFile.openSync(mode: FileMode.append);
      // 文件大小
      int total = int.parse(responseBody.headers
          .value(HttpHeaders.contentRangeHeader)
          .split("/")
          .last);
      // 已下载大小
      int received = downloadFileSize;

      Stream<Uint8List> stream = responseBody.data.stream;
      StreamSubscription<Uint8List> subscription;
      subscription = stream.listen(
        (data) {
          // 写文件数据
          raf.writeFromSync(data);
          received += data.length;
          _callbackDownloadPool(
              EncryptUtils.toMD5(url), DownloadResult.loading(total, received));
        },
        onDone: () async {
          String resultPath = savePath.replaceAll('.temp', '');
          await tempFile.rename(resultPath);
          await raf.close();
          String key = EncryptUtils.toMD5(url);
          _callbackDownloadPool(
              key , DownloadResult.success(key,resultPath));
          await subscription?.cancel();
        },
        onError: (e) async {
          _callbackDownloadPool(
              EncryptUtils.toMD5(url), DownloadResult.fail(" _breakPointDownload download fail"));
          await subscription?.cancel();
        },
        cancelOnError: true,
      );
    } on DioError catch (error) {
      _callbackDownloadPool(
          EncryptUtils.toMD5(url), DownloadResult.fail("DioError ${error?.message ?? ""}" ));
    }
  }

分片下载

对于大文件而言,分片下载是将一个文件拆分多块同时并行执行下载。这样能够提高文件下载速度,高效实现压缩下载耗时在业务功能呈现上更快(分片下载可以理解为断点续传进阶能力)。

大致分片下载流程:对资源做分片评估(默认都认为可分片)-> 尝试下载第一块分片 -> 下载成功后分析文件大小信息等 -> 计算出最终可分片下载数 -> 对每块分片分片对应的"range":"start-end"执行下载操作->分片下载成功后将每个分片做合并处理 -> 最终合并返回最终文件资源。

//部分代码
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'},
      ),
    );
  }
// 分片下载分析文件大小
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);
  }

压缩组件实现

若下载文件是zip资源就需要对下载成功后的资源再做解压操作,这里采用开源库archive来实现该操作。

解压功能比较简单 核心方法调用extractFileToDisk,入参为需要解压文件地址和解压后的文件地址即可。

 void toUnzip(String zipPath,String fileKey,String findFileName,UnzipCallback unzipCallback) async{
    _addUnzipPool("$zipPath$findFileName",unzipCallback);
    String unzipFilePath;
    File file = File(zipPath);
    String parentPath = file.parent.path;
    String filePath = "$parentPath/${fileKey}zip";

    // 逻辑需要再加一个temp命名保证是压缩结束成功后
    File unzipFile = File(filePath);
    bool exist = await unzipFile.existsSync();
    if(exist){
      unzipFilePath = "$filePath/$findFileName";
      _callbackUnzipPool("$zipPath$findFileName",unzipFilePath);
      return;
    }

    InputFileStream inputStream = InputFileStream(zipPath);
    Archive archive = ZipDecoder().decodeBuffer(inputStream);
    List<ArchiveFile> files = archive.files;
    String contentJsonFilename;
    for (ArchiveFile file in files) {
      if (file.isFile && file.name.endsWith(findFileName)) {
        contentJsonFilename = file.name;
      }
    }
    try{
      await extractFileToDisk(zipPath, filePath,asyncWrite: true);
    }catch(e){
    }finally{
      unzipFilePath = "$filePath/$contentJsonFilename";
      _callbackUnzipPool("$zipPath$findFileName",unzipFilePath);
    }

  }

组件封装

为了让下载组件更为统一可满足例如图片加载或者Lottie资源加载,需要封装一个下载组件满足所有其他组件使用。

下载组件

自定义下载组件支持加载状态设置:下载中、下载成功、下载失败。

class DownloadContainer extends StatefulWidget {

  /// 加载过程中的状态
  final ImageLoadingBuilder loadingBuilder;

  /// 加载成功后
  final ChildLayoutBuilder layoutBuilder;

  /// 加载失败后
  final ImageErrorWidgetBuilder errorBuilder;

  /// 下载器
  final DownloadController downloadController;
  /// 初始化布局
  final Widget initChild;
  ///
  final Widget errorChild;


  ///
  DownloadContainer({
    @required this.layoutBuilder,
    @required this.downloadController,
    this.loadingBuilder,
    this.errorBuilder,
    this.initChild,
    this.errorChild,
  });

  @override
  State<DownloadContainer> createState() => _DownloadContainerState();
}

下载控制器

实现DownloadController继承ChangeNotifier,作用是管理下载逻辑,具体下载还是依赖DownloadManager.getInstance()实现的。它的主要功能是将更新状态同步到组件并更新组件状态。

class DownloadController extends ChangeNotifier {
  ///
  final String networkUrl;

  ///
  String key;

  ///
  String filePath;

  ///
  double per;

  ///
  int currentSize;

  ///
  int allSize;

  ///
  DownloadStatus state = DownloadStatus.init;

  ///
  DownloadController({@required this.networkUrl});

  ///
  void load({String suffix}) async {
    DownloadManager.getInstance().requestDownloadByChunks(
        url: networkUrl,
        suffix: suffix,
        callback: (downloadResult) {
          switch (downloadResult.status) {
            case DownloadStatus.success:
              state = DownloadStatus.success;
              filePath = downloadResult.filePath;
              key = downloadResult.urlKey;
              break;
            case DownloadStatus.loading:
              state = DownloadStatus.loading;
              per = downloadResult.currentSize / downloadResult.allSize;
              currentSize = downloadResult.currentSize;
              allSize = downloadResult.allSize;
              break;
            case DownloadStatus.fail:
              state = DownloadStatus.fail;
              break;
          }
          notifyListeners();
        });

  }
}

功能补充说明

下载组件实现功能还是比较全面的,一些细节功能点在此说明,具体功能不一一赘述实现代码可通过链接地址查看详情。

  1. 为了保证每个资源的唯一性,将url作为每个资源唯一值通过MD5加密将url进行转换作为
  2. 有了资源唯一标识后,对本地文件进行检查判断该文件是否已存在
  3. 若本地资源文件命名是以.temp后缀结尾说明文件还未下载完全,可通过断点续传继续下载
  4. 下载器内部做了回调缓存池功能多次调用下载同一个资源只会触发一次下载但每个调用方都能收到最终结果回调

🚀实例代码看这里🚀

Video_20220825_082207_794.gif

参考