一个来源于Afinal断点下载BUG的解决方案

一个来自Afinal断点下载BUG的解决方案

    欢迎各位加入我的Android开发群[257053751]

作为国内第一个Android开发框架Afinal,相信有很多开发者都知道的。虽然随着Android版本的迭代,其中有一些方法有了更好的解决办法作者也不再维护,但从来没有人怀疑Afinal的价值。

    最近在重构KJFrameForAndroid框架的一个断点下载的功能,参考了比较多的例子,无意间发现了FinalHttp.download()方法中的一个BUG。

    首先跟大家介绍一下afinal中download下载的实现原理。与其他众多下载方法不同,afinal使用的是一个单线程断点下载,且其中没有数据库或额外的文件操作。那么是如何实现断点续传的呢,主要是使用了FileOutputStream的一个构造方法查看api文档看到一个来源于Afinal断点下载BUG的解决方案

参数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开销也不小,使用多线程的意义并不是很大。