作者:Karl_wei
链接:https://juejin.cn/post/7123965772132515877
来源:稀土掘金
在项目开发中,特别是C端的产品,资源下载实现断点续传是非常有必要的。今天我们不讲过多原理的知识,分享下简单实用的资源断点续传。
协议梳理
一般情况下,下载的功能模块,至少需要提供如下基础功能:资源下载、取消当前下载、资源是否下载成功、资源文件的大小、清除缓存文件。
而断点续传主要体现在取消当前下载后,再次下载时能在之前已下载的基础上继续下载。
这个能极大程度的减少我们服务器的带宽损耗,而且还能为用户减少流量,避免重复下载,提高用户体验。
前置条件:资源必须支持断点续传。
如何确定可否支持?看看你的服务器是否支持Range请求即可。
实现步骤
- 定好协议。我们用的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});
}
- 实现抽象协议,其中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;
}
}
- 封装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校验,这点非常重要,断点续传下载的文件,在完整性上可能会因为各种突发情况而得不到保障;
- 在资源管理协议上,我们将下载、检测、获取大小等方法都抽象出去,在业务调用时比较灵活。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/118844.html