Flutter资源下载如何实现断点续传?

作者:Karl_wei
链接:https://juejin.cn/post/7123965772132515877
来源:稀土掘金

在项目开发中,特别是C端的产品,资源下载实现断点续传是非常有必要的。今天我们不讲过多原理的知识,分享下简单实用的资源断点续传。

协议梳理

一般情况下,下载的功能模块,至少需要提供如下基础功能:资源下载、取消当前下载、资源是否下载成功、资源文件的大小、清除缓存文件。
而断点续传主要体现在取消当前下载后,再次下载时能在之前已下载的基础上继续下载。这个能极大程度的减少我们服务器的带宽损耗,而且还能为用户减少流量,避免重复下载,提高用户体验。
前置条件:资源必须支持断点续传。如何确定可否支持?看看你的服务器是否支持Range请求即可

实现步骤

  1. 定好协议。我们用的http库是dio;通过校验md5检测文件缓存完整性;关于代码中的subDir,设计上认为资源会有多种:音频、视频、安装包等,每种资源分开目录进行存储。
import 'package:dio/dio.dart';typedef ProgressCallBack = void Function(int count, int total);typedef CancelTokenProvider = void Function(CancelToken cancelToken);abstract class AssetRepositoryProtocol {  /// 下载单一资源  Future downloadAsset(String url,      {String? subDir,      ProgressCallBack? onReceiveProgress,      CancelTokenProvider? cancelTokenProvider,      Function(String)? done,      Function(Exception)? failed});  /// 取消下载,Dio中通过CancelToken可控制  void cancelDownload(CancelToken cancelToken);  /// 获取文件的缓存地址  Future filePathForAsset(String url, {String? subDir});  /// 检查文件是否缓存成功,简单对比md5  Future checkCachedSuccess(String url, {String? md5Str});  /// 查看缓存文件的大小  Future cachedFileSize({String? subDir});  /// 清除缓存  Future clearCache({String? subDir});}
  1. 实现抽象协议,其中HttpManagerProtocol内部封装了dio的相关请求。
class AssetRepository implements AssetRepositoryProtocol {  AssetRepository(this.httpManager);  final HttpManagerProtocol httpManager;  @override  Future downloadAsset(String url,      {String? subDir,      ProgressCallBack? onReceiveProgress,      CancelTokenProvider? cancelTokenProvider,      Function(String)? done,      Function(Exception)? failed}) async {    CancelToken cancelToken = CancelToken();    if (cancelTokenProvider != null) {      cancelTokenProvider(cancelToken);    }    final savePath = await _getSavePath(url, subDir: subDir);    try {      httpManager.downloadFile(          url: url,          savePath: savePath + '.temp',          onReceiveProgress: onReceiveProgress,          cancelToken: cancelToken,          done: () {            done?.call(savePath);          },          failed: (e) {            print(e);            failed?.call(e);          });      return savePath;    } catch (e) {      print(e);      rethrow;    }  }  @override  void cancelDownload(CancelToken cancelToken) {    try {      if (!cancelToken.isCancelled) {        cancelToken.cancel();      }    } catch (e) {      print(e);    }  }  @override  Future filePathForAsset(String url, {String? subDir}) async {    final path = await _getSavePath(url, subDir: subDir);    final file = File(path);    if (!(await file.exists())) {      return null;    }    return path;  }  @override  Future checkCachedSuccess(String url, {String? md5Str}) async {    String? path = await _getSavePath(url, subDir: FileType.video.dirName);    bool isCached = await File(path).exists();    if (isCached && (md5Str != null && md5Str.isNotEmpty)) {      // 存在但是md5验证不通过      File(path).readAsBytes().then((Uint8List str) {        if (md5.convert(str).toString() != md5Str) {          path = null;        }      });    } else if (isCached) {      return path;    } else {      path = null;    }    return path;  }  @override  Future cachedFileSize({String? subDir}) async {    final dir = await _getDir(subDir: subDir);    if (!(await dir.exists())) {      return 0;    }    int totalSize = 0;    await for (var entity in dir.list(recursive: true)) {      if (entity is File) {        try {          totalSize += await entity.length();        } catch (e) {          print('Get size of $entity failed with exception: $e');        }      }    }    return totalSize;  }  @override  Future clearCache({String? subDir}) async {    final dir = await _getDir(subDir: subDir);    if (!(await dir.exists())) {      return;    }    dir.deleteSync(recursive: true);  }  Future _getSavePath(String url, {String? subDir}) async {    final saveDir = await _getDir(subDir: subDir);    if (!saveDir.existsSync()) {      saveDir.createSync(recursive: true);    }    final uri = Uri.parse(url);    final fileName = uri.pathSegments.last;    return saveDir.path + fileName;  }  Future _getDir({String? subDir}) async {    final cacheDir = await getTemporaryDirectory();    late final Directory saveDir;    if (subDir == null) {      saveDir = cacheDir;    } else {      saveDir = Directory(cacheDir.path + '/$subDir/');    }    return saveDir;  }}
  1. 封装dio下载,实现资源断点续传。
    这里的逻辑比较重点,首先未缓存100%的文件,我们以.temp后缀进行命名,在每次下载时检测下是否有.temp的文件,拿到其文件字节大小;传入在header中的range字段,服务器就会去解析需要从哪个位置继续下载;下载全部完成后,再把文件名改回正确的后缀即可。
final downloadDio = Dio();Future downloadFile({  required String url,  required String savePath,  required CancelToken cancelToken,  ProgressCallback? onReceiveProgress,  void Function()? done,  void Function(Exception)? failed,}) async {  int downloadStart = 0;  File f = File(savePath);  if (await f.exists()) {    // 文件存在时拿到已下载的字节数    downloadStart = f.lengthSync();  }  print("start: $downloadStart");  try {    var response = await downloadDio.get(      url,      options: Options(        /// Receive response data as a stream        responseType: ResponseType.stream,        followRedirects: false,        headers: {          /// 加入range请求头,实现断点续传          "range": "bytes=$downloadStart-",        },      ),    );    File file = File(savePath);    RandomAccessFile raf = file.openSync(mode: FileMode.append);    int received = downloadStart;    int total = await _getContentLength(response);    Stream stream = response.data!.stream;    StreamSubscription? subscription;    subscription = stream.listen(      (data) {        /// Write files must be synchronized        raf.writeFromSync(data);        received += data.length;        onReceiveProgress?.call(received, total);      },      onDone: () async {        file.rename(savePath.replaceAll('.temp', ''));        await raf.close();        done?.call();      },      onError: (e) async {        await raf.close();        failed?.call(e);      },      cancelOnError: true,    );    cancelToken.whenCancel.then((_) async {      await subscription?.cancel();      await raf.close();    });  } on DioError catch (error) {    if (CancelToken.isCancel(error)) {      print("Download cancelled");    } else {      failed?.call(error);    }  }}

写在最后

这篇文章确实没有技术含量,水一篇,但其实是实用的。这个断点续传的实现有几个注意的点:

  • 使用文件操作的方式,区分后缀名来管理缓存的资源;
  • 安全性使用md5校验,这点非常重要,断点续传下载的文件,在完整性上可能会因为各种突发情况而得不到保障;
  • 在资源管理协议上,我们将下载、检测、获取大小等方法都抽象出去,在业务调用时比较灵活。
发表评论
留言与评论(共有 0 条评论) “”
   
验证码:

相关文章

推荐文章