作为国内第一个Android开发框架Afinal,相信有很多开发者都知道的。虽然随着Android版本的迭代,其中有一些方法有了更好的解决办法但从来没有人怀疑Afinal的价值。
最近在做一个断点下载的功能,参考了比较多的例子,无意间发现了FinalHttp.download()方法中的一个BUG。
首先跟大家介绍一下afinal中download下载的实现原理。与其他众多下载方法不同,afinal使用的是一个单线程断点下载,且其中没有数据库或额外的文件操作。那么是如何实现断点续传的呢,主要是使用了FileOutputStream的一个构造方法,查看api文档看到
参数append可以在一个文件的结尾处续写数据,这样就实现了断点续传功能。
知道了实现原理,我们来看代码(参数名略有改动)你可以在这里看到完整的代码
public Object handleEntity(HttpEntity entity, EntityCallBack callback, String target, boolean isResume) throws IOException { if (TextUtils.isEmpty(target) || target.trim().length() == 0) return null; File targetFile = new File(target); if (!targetFile.exists()) targetFile.createNewFile(); if (mStop) { return targetFile; } long current = 0; FileOutputStream os = null; if (isResume) { current = targetFile.length(); os = new FileOutputStream(target, true); } else { os = new FileOutputStream(target); } if (mStop) { return targetFile; } InputStream input = entity.getContent(); long count = entity.getContentLength() + current; if (current >= count || mStop) { return targetFile; } int readLen = 0; byte[] buffer = new byte[1024]; while (!mStop && !(current >= count) && ((readLen = input.read(buffer, 0, 1024)) > 0)) {// 未全部读取 os.write(buffer, 0, readLen); current += readLen; callback.callBack(count, current, false); } callback.callBack(count, current, true); return targetFile; }
根据代码,我们可以看到一个明显的问题——流没有关闭。这个问题好改,自己发现了关闭就行我就不多解释了。还有一个问题,就是这一句
long count = entity.getContentLength() + current;
远端文件的大小是被不断增大的,下载任务每被暂停一次,远端文件的大小就被增大一次,增加的大小等于本地已下载的碎片文件的大小。这么做的后果有两个:1、这个断点下载功能完全没有使用,每次下载都是从0开始。2、本地文件由于是续传,越来越大。
举个例子,远端文件大小是1024 b,本地已经读取了512b的大小,而此时用户暂停下载。下次重新下载时,程序重新读取远端文件大小,变成了1024+512大小,而本地文件是512,input.read再次从0开始读取,而本地文件从512开始写,之后下载完成。两次下载共下载了512+1024个字节,本地文件由于是续传,第一次下载512碎片文件,而第二次又下载完整的1024文件,最后下载变成了1536个字节大小的文件。(顺便一说,这个文件是可以正常打开的,但是打开的速度会比源文件慢)
找到了问题,解决办法有了吗,单纯的将 count = entity.getContentLength() + current;改成count = entity.getContentLength() ;然后while循环里面的current<count去掉就行了?你可以尝试一下,这样是不行的。首先这样依旧不能达到断点续传的目的,只要在续传的时候
InputStream input = entity.getContent();
这一句获取的流没有跳过已经下载的部分,就达不到节省流量续传下载的目的,我想这一点应该可以理解吧。
OK,那么InputStream有一个skip方法,跳过已下载的部分总可以吧,NO,也不行,因为负责续传写入文件的FileOutPutStream中有一部分数据是损坏的,不能被使用,而这时你跳过的字节数也就不是真正需要跳过的字节数了。
继续,看代码,这里是我的解决办法:
public File handleEntity(HttpEntity entity, DownloadProgress callback, File save, boolean isResume) throws IOException { long current = 0; RandomAccessFile file = new RandomAccessFile(save, "rw"); if (isResume) { current = file.length(); } InputStream input = entity.getContent(); long count = entity.getContentLength(); if (mStop) { FileUtils.closeIO(file); return save; } current = input.skip(current); file.seek(current); int readLen = 0; byte[] buffer = new byte[1024]; while ((readLen = input.read(buffer, 0, 1024)) != -1) { if (mStop) { break; } else { file.write(buffer, 0, readLen); current += readLen; callback.onProgress(count, current); } } callback.onProgress(count, current); if (mStop && current < count) { // 用户主动停止 FileUtils.closeIO(file); throw new IOException("user stop download thread"); } FileUtils.closeIO(file); return save; }
可以看到使用一个支持对随机访问文件的读取和写入的RandomAccessFile类来替换可以续传的FileOutPutStream,同时通过对current重新赋值
current = input.skip(current);
完美解决了碎片文件中不可用部分造成的文件损坏问题。
以上方法其实还是有一个小问题,在Android中我们都知道,下载的时候一般都会伴随着一个进度条展示。这个小问题就是当暂停后继续下载的时候,由于暂停前那一段不可用的损坏的文件占用了大小,可能会在恢复下载的时候发生进度条大幅反弹的现象,这对用户体验是很糟糕的,毕竟谁想我辛辛苦苦等了半天的进度读条,又一下子退回去那么多。
那么解决办法其实是给个心理安慰,看如下代码
public File handleEntity(HttpEntity entity, DownloadProgress callback, File save, boolean isResume) throws IOException { long current = 0; RandomAccessFile file = new RandomAccessFile(save, "rw"); if (isResume) { current = file.length(); } InputStream input = entity.getContent(); long count = entity.getContentLength() + current; if (mStop) { FileUtils.closeIO(file); return save; } // 在这里其实这样写是不对的,之所以如此是为了用户体验,谁都不想自己下载时进度条都走了一大半了,就因为一个暂停一下子少了一大串 /** * 这里实际的写法应该是: <br> * current = input.skip(current); <br> * file.seek(current); <br> * 根据JDK文档中的解释:Inputstream.skip(long i)方法跳过i个字节,并返回实际跳过的字节数。<br> * 导致这种情况的原因很多,跳过 n 个字节之前已到达文件末尾只是其中一种可能。这里我猜测可能是碎片文件的损害造成的。 */ file.seek(input.skip(current)); int readLen = 0; byte[] buffer = new byte[1024]; while ((readLen = input.read(buffer, 0, 1024)) != -1) { if (mStop) { break; } else { file.write(buffer, 0, readLen); current += readLen; callback.onProgress(count, current); } } callback.onProgress(count, current); if (mStop && current < count) { // 用户主动停止 FileUtils.closeIO(file); throw new IOException("user stop download thread"); } FileUtils.closeIO(file); return save; }
这里顺带提一下,android的下载我个人并不提倡使用多线程。主要是因为手机一般不会下载多么大的文件,而多线程本身的线程开销加上使用数据库或额外的记录文件产生的IO开销也不小,使用多线程的意义并不是很大。