Flutter资源下载断点续传
Karl_wei 人气:0协议梳理
一般情况下,下载的功能模块,至少需要提供如下基础功能:资源下载、取消当前下载、资源是否下载成功、资源文件的大小、清除缓存文件。而断点续传主要体现在取消当前下载后,再次下载时能在之前已下载的基础上继续下载。这个能极大程度的减少我们服务器的带宽损耗,而且还能为用户减少流量,避免重复下载,提高用户体验。
前置条件:资源必须支持断点续传。如何确定可否支持?看看你的服务器是否支持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<String> downloadAsset(String url, {String? subDir, ProgressCallBack? onReceiveProgress, CancelTokenProvider? cancelTokenProvider, Function(String)? done, Function(Exception)? failed}); /// 取消下载,Dio中通过CancelToken可控制 void cancelDownload(CancelToken cancelToken); /// 获取文件的缓存地址 Future<String?> filePathForAsset(String url, {String? subDir}); /// 检查文件是否缓存成功,简单对比md5 Future<String?> checkCachedSuccess(String url, {String? md5Str}); /// 查看缓存文件的大小 Future<int> cachedFileSize({String? subDir}); /// 清除缓存 Future<void> clearCache({String? subDir}); }
2.实现抽象协议,其中HttpManagerProtocol内部封装了dio的相关请求。
class AssetRepository implements AssetRepositoryProtocol { AssetRepository(this.httpManager); final HttpManagerProtocol httpManager; @override Future<String> 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<String?> 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<String?> 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<int> 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<void> clearCache({String? subDir}) async { final dir = await _getDir(subDir: subDir); if (!(await dir.exists())) { return; } dir.deleteSync(recursive: true); } Future<String> _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<Directory> _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; } }
3.封装dio下载,实现资源断点续传。
这里的逻辑比较重点,首先未缓存100%的文件,我们以.temp后缀进行命名,在每次下载时检测下是否有.temp的文件,拿到其文件字节大小;传入在header中的range字段,服务器就会去解析需要从哪个位置继续下载;下载全部完成后,再把文件名改回正确的后缀即可。
final downloadDio = Dio(); Future<void> 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<ResponseBody>( 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<Uint8List> stream = response.data!.stream; StreamSubscription<Uint8List>? 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校验,这点非常重要,断点续传下载的文件,在完整性上可能会因为各种突发情况而得不到保障;
- 在资源管理协议上,我们将下载、检测、获取大小等方法都抽象出去,在业务调用时比较灵活。
加载全部内容