在通过网络进行图片或者文件的下载时,为保证内存和磁盘资源的合理利用,我们一般会对此次请求进行断点续传。断点续传,顾名思义就是在一次图片或文件下载的网络请求过程中,因异常情况此次操作被迫中断,那么下一次请求相同资源的网络请求会继续上一次的进度继续下载图片或文件资源。
1. 如何保证两次请求的内容为同一资源
为保证这一前提,xUtils3 实现了两种策略:
- RequestParams#saveFilePath不为空时, 目标文件保存在saveFilePath;
- 否则由Cache策略分配文件下载路径.
saveFilePath = params.getSaveFilePath();
if (TextUtils.isEmpty(saveFilePath)) {
if (progressHandler != null && !progressHandler.updateProgress(0, 0, false)) {
throw new Callback.CancelledException("download stopped!");
}
// 保存路径为空, 存入磁盘缓存.
initDiskCacheFile(request);
} else {
tempSaveFilePath = saveFilePath + ".tmp";
}
private void initDiskCacheFile(final UriRequest request) throws Throwable {
DiskCacheEntity entity = new DiskCacheEntity();
entity.setKey(request.getCacheKey());
diskCacheFile = LruDiskCache.getDiskCache(params.getCacheDirName()).createDiskCacheFile(entity);
if (diskCacheFile != null) {
saveFilePath = diskCacheFile.getAbsolutePath();
// diskCacheFile is a temp path, diskCacheFile.commit() return the dest file.
tempSaveFilePath = saveFilePath;
isAutoRename = false;
} else {
throw new IOException("create cache file error:" + request.getCacheKey());
}
}
这两种策略最终都是最终都是生成后缀为 .tmp
的临时文件,这样就保证了在下载完成之前所下载的文件资源在同一位置(当然分为自定义下载位置和缓存自分配位置)。
2. 文件校验
为了保证两次下载的文件资源相同,我们要进行文件校验工作,此步骤发生在第二次网络请求时。
2.1 校验第一步:判断文件可用性
若第一次对文件的下载进度小于 512(CHECK_SIZE) 字节,那么该次下载被忽略,对相应的文件进行删除操作。
FileLoad#load(UriRequest urirequest)
params = request.getParams();
{
long range = 0;
if (isAutoResume) {
File tempFile = new File(tempSaveFilePath);
long fileLen = tempFile.length();
if (fileLen <= CHECK_SIZE) {
IOUtil.deleteFileOrDir(tempFile);
range = 0;
} else {
range = fileLen - CHECK_SIZE;
}
}
// retry 时需要覆盖RANGE参数
params.setHeader("RANGE", "bytes=" + range + "-");
}
.....
request.sendRequest();
2.2 校验第二步:服务器支持
网络请求的实质其实是对网络请求的发送和网络数据的传输,那么我们要想实现网络的断点续传,那必须是需要服务端进行支持的,不然的我们所做的工作到头来不过也是一场空。我们怎样将这次断点续传的请求告知服务端呢,当然是通过 Header
,客户端对网络请求的一些参数大部分都是通过 Header
来实现的,我们需要做的是发送我们想要的文件长度。具体的关于 Range
的请参看 -- HTTP之Range理解、关于Rang 的stack overflow 的回答。那么我们对本次的 Header
的设置如下
//设置本次请求的 Header
params.setHeader("RANGE", "bytes=" + range + "-");
//发送本次请求
request.sendRequest();
//获取本次网络请求的文件字节数
contentLength = request.getContentLength();
2.3 校验第三步:文件校验
断点续传功能的最终实现当然不能少的就是对文件的校验,不然你最终实现的文件是两个文件的拼接,那岂不是很尴尬。
针对文件校验的代码如下:
long targetFileLen = targetFile.length();
if (isAutoResume && targetFileLen > 0) {
FileInputStream fis = null;
try {
long filePos = targetFileLen - CHECK_SIZE;
/**
* 完成断点续传的校验 6666 学到了
*/
if (filePos > 0) {
fis = new FileInputStream(targetFile);
byte[] fileCheckBuffer = IOUtil.readBytes(fis, filePos, CHECK_SIZE);
byte[] checkBuffer = IOUtil.readBytes(in, 0, CHECK_SIZE);
if (!Arrays.equals(checkBuffer, fileCheckBuffer)) {
IOUtil.closeQuietly(fis); // 先关闭文件流, 否则文件删除会失败.
IOUtil.deleteFileOrDir(targetFile);
throw new RuntimeException("need retry");
} else {
contentLength -= CHECK_SIZE;
}
} else {
IOUtil.deleteFileOrDir(targetFile);
throw new RuntimeException("need retry");
}
} finally {
IOUtil.closeQuietly(fis);
}
}
为更好的理解这一过程我们看一下我自己针对代码绘制一份流程图:
通过上图我们要做的工作是比对 A 字节码和 B 字节码是否相同
3、续写入文件
// 开始下载
long current = 0;
FileOutputStream fileOutputStream = null;
if (isAutoResume) {
current = targetFileLen;
fileOutputStream = new FileOutputStream(targetFile, true);
} else {
fileOutputStream = new FileOutputStream(targetFile);
}
此处存在一个无比大的坑:按照图一我们可以看到:第一次和第二次下载的文件长度有大小为 Cache_Size
长度的重合字节,通过fileOutputStream = new FileOutputStream(targetFile, true);
我们可以知道的是第二次下载的字节会直接拼在第一次下载的文件之后,这样来说的话岂不是多了 Cache_Size
个长度数据的字节,这破坏了文件的完整性啊,那 xUtils 的断点续传功能不是报废了吗?不对,于是自己就一遍又一遍的看相关代码,根本没有发现相应功能的代码,一时间懵逼、急躁。自己静下心了重新顺了几遍流程,发现分析过程没错,于是着手排查,发现对 第二次下载字节码的进行字节数为Cache_Size
操作的地方只有一个:文件校验,代码如下:
byte[] checkBuffer = IOUtil.readBytes(in, 0, CHECK_SIZE);
马上跳转相应代码查看了IOUtil#readBytes()
相关代码
public static byte[] readBytes(InputStream in, long skip, int size) throws IOException {
byte[] result = null;
if (skip > 0) {
long skipped = 0;
while (skip > 0 && (skipped = in.skip(skip)) > 0) {
skip -= skipped;
}
}
result = new byte[size];
for (int i = 0; i < size; i++) {
result[i] = (byte) in.read();
}
return result;
}
这里有一个对InputStream字节流的操作 --skip()
,此 api 的最终实现了对 InputStream
丢弃 Cache_Size
个字节的数据,一切流程终于走通了。关于 InputStream#skip()的相关内容可以查看 InputStream方法详解
4. 文件重命名和 CacheFile#commit() 写入内存
通过以上步骤我们已经完成了断点续传功能,但是最终我们下载的文件是以 .tmp 为名的临时文件,我们最终要实现的是将临时文件重名为指定文件名的文件,具体实现如下:
private File autoRename(File loadedFile) {
if (isAutoRename && loadedFile.exists() && !TextUtils.isEmpty(responseFileName)) {
File newFile = new File(loadedFile.getParent(), responseFileName);
while (newFile.exists()) {
newFile = new File(loadedFile.getParent(), System.currentTimeMillis() + responseFileName);
}
return loadedFile.renameTo(newFile) ? newFile : loadedFile;
} else if (!saveFilePath.equals(tempSaveFilePath)) {
File newFile = new File(saveFilePath);
return loadedFile.renameTo(newFile) ? newFile : loadedFile;
} else {
return loadedFile;
}
}
获取文件名:
private static String getResponseFileName(UriRequest request) {
if (request == null) return null;
String disposition = request.getResponseHeader("Content-Disposition");
if (!TextUtils.isEmpty(disposition)) {
int startIndex = disposition.indexOf("filename=");
if (startIndex > 0) {
startIndex += 9; // "filename=".length()
int endIndex = disposition.indexOf(";", startIndex);
if (endIndex < 0) {
endIndex = disposition.length();
}
if (endIndex > startIndex) {
try {
String name = URLDecoder.decode(
disposition.substring(startIndex, endIndex),
request.getParams().getCharset());
if (name.startsWith("\"") && name.endsWith("\"")) {
name = name.substring(1, name.length() - 1);
}
return name;
} catch (UnsupportedEncodingException ex) {
LogUtil.e(ex.getMessage(), ex);
}
}
}
}
return null;
}
5. 总结
断点续传需要的基本步骤:
- 保证下载的目标文件的相同(目标文件的唯一性)
- 文件字节的校验(字节码的唯一性)
- 继续上次下载位置下载(字节的连续性)