Android 实现一个简单的文件下载工具

下载应该是每个App都必须的一项功能,不采用第三方框架的话,就需要我们自己去实现下载工具了。如果我们自己实现可以怎么做呢?

首先如果服务器文件支持断点续传,则我们需要实现的主要功能点如下:

  • 多线程、断点续传下载
  • 下载管理:开始、暂停、继续、取消、重新开始

如果服务器文件不支持断点续传,则只能进行普通的单线程下载,而且不能暂停、继续。当然一般情况服务器文件都应该支持断点续传吧!

下边分别是单个任务下载、多任务列表下载、以及service下载的效果图:


single_task
task_manage
service_task

PS:如果demo不能正常下载,请替换对应下载地址

基本实现原理:

接下来看看具体的实现原理,由于我们的下载是基于okhttp实现的,首先我们需要一个OkHttpManager类,进行最基本的网络请求封装:

public class OkHttpManager {
    ............省略..............
    /**
     * 异步(根据断点请求)
     *
     * @param url
     * @param start
     * @param end
     * @param callback
     * @return
     */
    public Call initRequest(String url, long start, long end, final Callback callback) {
        Request request = new Request.Builder()
                .url(url)
                .header("Range", "bytes=" + start + "-" + end)
                .build();

        Call call = builder.build().newCall(request);
        call.enqueue(callback);

        return call;
    }

    /**
     * 同步请求
     *
     * @param url
     * @return
     * @throws IOException
     */
    public Response initRequest(String url) throws IOException {
        Request request = new Request.Builder()
                .url(url)
                .header("Range", "bytes=0-")
                .build();

        return builder.build().newCall(request).execute();
    }

    /**
     * 文件存在的情况下可判断服务端文件是否已经更改
     *
     * @param url
     * @param lastModify
     * @return
     * @throws IOException
     */
    public Response initRequest(String url, String lastModify) throws IOException {
        Request request = new Request.Builder()
                .url(url)
                .header("Range", "bytes=0-")
                .header("If-Range", lastModify)
                .build();

        return builder.build().newCall(request).execute();
    }

    /**
     * https请求时初始化证书
     *
     * @param certificates
     * @return
     */
    public void setCertificates(InputStream... certificates) {
        try {
            CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
            KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
            keyStore.load(null);
            int index = 0;
            for (InputStream certificate : certificates) {
                String certificateAlias = Integer.toString(index++);
                keyStore.setCertificateEntry(certificateAlias, certificateFactory.generateCertificate(certificate));
                try {
                    if (certificate != null)
                        certificate.close();
                } catch (IOException e) {
                }
            }

            SSLContext sslContext = SSLContext.getInstance("TLS");
            TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());

            trustManagerFactory.init(keyStore);
            sslContext.init(null, trustManagerFactory.getTrustManagers(), new SecureRandom());

            builder.sslSocketFactory(sslContext.getSocketFactory());

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

这个类里包含了基本的超时配置、根据断点信息发起异步请求、校验服务器文件是否有更新、https证书配置等。这样网络请求部分就有了。

接下来,我们还需要数据库的支持,以便记录下载文件的基本信息,这里我们使用SQLite,只有一张表:

/**
     * download_info表建表语句
     */
    public static final String CREATE_DOWNLOAD_INFO = "create table download_info ("
            + "id integer primary key autoincrement, "
            + "url text, "
            + "path text, "
            + "name text, "
            + "child_task_count integer, "
            + "current_length integer, "
            + "total_length integer, "
            + "percentage real, "
            + "last_modify text, "
            + "date text)";

当然还有对应表的增删改查工具类,具体的可参考源码。

由于需要下载管理,所以线程池也是必不可少的,这样可以避免过多的创建子线程,达到复用的目的,当然线程池的大小可以根据需求进行配置,主要代码如下:

public class ThreadPool {
    //可同时下载的任务数(核心线程数)
    private int CORE_POOL_SIZE = 3;
    //缓存队列的大小(最大线程数)
    private int MAX_POOL_SIZE = 20;
    //非核心线程闲置的超时时间(秒),如果超时则会被回收
    private long KEEP_ALIVE = 10L;

    private ThreadPoolExecutor THREAD_POOL_EXECUTOR;

    private ThreadFactory sThreadFactory = new ThreadFactory() {
        private final AtomicInteger mCount = new AtomicInteger();

        @Override
        public Thread newThread(@NonNull Runnable runnable) {
            return new Thread(runnable, "download_task#" + mCount.getAndIncrement());
        }
    };

    ...................省略................

    public void setCorePoolSize(int corePoolSize) {
        if (corePoolSize == 0) {
            return;
        }
        CORE_POOL_SIZE = corePoolSize;
    }

    public void setMaxPoolSize(int maxPoolSize) {
        if (maxPoolSize == 0) {
            return;
        }
        MAX_POOL_SIZE = maxPoolSize;
    }

    public int getCorePoolSize() {
        return CORE_POOL_SIZE;
    }

    public int getMaxPoolSize() {
        return MAX_POOL_SIZE;
    }

    public ThreadPoolExecutor getThreadPoolExecutor() {
        if (THREAD_POOL_EXECUTOR == null) {
            THREAD_POOL_EXECUTOR = new ThreadPoolExecutor(
                    CORE_POOL_SIZE, MAX_POOL_SIZE,
                    KEEP_ALIVE, TimeUnit.SECONDS,
                    new LinkedBlockingDeque<Runnable>(),
                    sThreadFactory);
        }
        return THREAD_POOL_EXECUTOR;
    }
}

接下来就是我们核心的下载类FileTask了,它实现了Runnable接口,这样就能在线程池中执行,首先看下run()方法的逻辑:

@Override
    public void run() {
        try {
            File saveFile = new File(path, name);
            File tempFile = new File(path, name + ".temp");
            DownloadData data = Db.getInstance(context).getData(url);
            if (Utils.isFileExists(saveFile) && Utils.isFileExists(tempFile) && data != null) {
                Response response = OkHttpManager.getInstance().initRequest(url, data.getLastModify());
                if (response != null && response.isSuccessful() && Utils.isNotServerFileChanged(response)) {
                    TEMP_FILE_TOTAL_SIZE = EACH_TEMP_SIZE * data.getChildTaskCount();
                    onStart(data.getTotalLength(), data.getCurrentLength(), "", true);
                } else {
                    prepareRangeFile(response);
                }
                saveRangeFile();
            } else {
                Response response = OkHttpManager.getInstance().initRequest(url);
                if (response != null && response.isSuccessful()) {
                    if (Utils.isSupportRange(response)) {
                        prepareRangeFile(response);
                        saveRangeFile();
                    } else {
                        saveCommonFile(response);
                    }
                }
            }
        } catch (IOException e) {
            onError(e.toString());
        }
    }

如果下载的目标文件、记录断点的临时文件、数据库记录都存在,则我们先判断服务器文件是否有更新,如果没有更新则根据之前的记录直接开始下载,否则需要先进行断点下载前的准备。如果记录文件不全部存在则需要先判断是否支持断点续传,如果支持则按照断点续传的流程进行,否则采用普通下载。

首先看下prepareRangeFile()方法,在这里进行断点续传的准备工作:

private void prepareRangeFile(Response response) {
      .................省略.................
        try {
            File saveFile = Utils.createFile(path, name);
            File tempFile = Utils.createFile(path, name + ".temp");

            long fileLength = response.body().contentLength();
            onStart(fileLength, 0, Utils.getLastModify(response), true);

            Db.getInstance(context).deleteData(url);
            Utils.deleteFile(saveFile, tempFile);

            saveRandomAccessFile = new RandomAccessFile(saveFile, "rws");
            saveRandomAccessFile.setLength(fileLength);

            tempRandomAccessFile = new RandomAccessFile(tempFile, "rws");
            tempRandomAccessFile.setLength(TEMP_FILE_TOTAL_SIZE);
            tempChannel = tempRandomAccessFile.getChannel();
            MappedByteBuffer buffer = tempChannel.map(READ_WRITE, 0, TEMP_FILE_TOTAL_SIZE);

            long start;
            long end;
            int eachSize = (int) (fileLength / childTaskCount);
            for (int i = 0; i < childTaskCount; i++) {
                if (i == childTaskCount - 1) {
                    start = i * eachSize;
                    end = fileLength - 1;
                } else {
                    start = i * eachSize;
                    end = (i + 1) * eachSize - 1;
                }
                buffer.putLong(start);
                buffer.putLong(end);
            }
        } catch (Exception e) {
            onError(e.toString());
        } finally {
            .............省略............
        }
    }

首先是清除历史记录,创建新的目标文件和临时文件,childTaskCount代表文件需要通过几个子任务去下载,这样就可以得到每个子任务需要下载的任务大小,进而得到具体的断点信息并记录到临时文件中。文件下载我们采用MappedByteBuffer 类,相比RandomAccessFile 更加的高效。同时执行onStart()方法将代表下载的准备阶段,具体细节后面会说到。

接下来看saveRangeFile()方法:

private void saveRangeFile() {

         .................省略..............

        for (int i = 0; i < childTaskCount; i++) {
            final int tempI = i;
            Call call = OkHttpManager.getInstance().initRequest(url, range.start[i], range.end[i], new Callback() {
                @Override
                public void onFailure(Call call, IOException e) {
                    onError(e.toString());
                }

                @Override
                public void onResponse(Call call, Response response) throws IOException {
                    startSaveRangeFile(response, tempI, range, saveFile, tempFile);
                }
            });
            callList.add(call);
        }
        .................省略..............
    }

就是根据临时文件保存的断点信息发起childTaskCount数量的异步请求,如果响应成功则通过startSaveRangeFile()方法分段保存文件:

private void startSaveRangeFile(Response response, int index, Ranges range, File saveFile, File tempFile) {
      .................省略..............
        try {
            saveRandomAccessFile = new RandomAccessFile(saveFile, "rws");
            saveChannel = saveRandomAccessFile.getChannel();
            MappedByteBuffer saveBuffer = saveChannel.map(READ_WRITE, range.start[index], range.end[index] - range.start[index] + 1);

            tempRandomAccessFile = new RandomAccessFile(tempFile, "rws");
            tempChannel = tempRandomAccessFile.getChannel();
            MappedByteBuffer tempBuffer = tempChannel.map(READ_WRITE, 0, TEMP_FILE_TOTAL_SIZE);

            inputStream = response.body().byteStream();
            int len;
            byte[] buffer = new byte[BUFFER_SIZE];

            while ((len = inputStream.read(buffer)) != -1) {
                //取消
                if (IS_CANCEL) {
                    handler.sendEmptyMessage(CANCEL);
                    callList.get(index).cancel();
                    break;
                }

                saveBuffer.put(buffer, 0, len);
                tempBuffer.putLong(index * EACH_TEMP_SIZE, tempBuffer.getLong(index * EACH_TEMP_SIZE) + len);
                onProgress(len);
                
                //退出保存记录
                if (IS_DESTROY) {
                    handler.sendEmptyMessage(DESTROY);
                    callList.get(index).cancel();
                    break;
                }
                //暂停
                if (IS_PAUSE) {
                    handler.sendEmptyMessage(PAUSE);
                    callList.get(index).cancel();
                    break;
                }
            }
            addCount();
        } catch (Exception e) {
            onError(e.toString());
        } finally {
            .................省略..............
        }

在while循环中进行目前文件的写入和将当前下载到的位置保存到临时文件:

 saveBuffer.put(buffer, 0, len);
 tempBuffer.putLong(index * EACH_TEMP_SIZE, tempBuffer.getLong(index * EACH_TEMP_SIZE) + len);

同时调用onProgress()方法将进度发送出去,其中取消、退出保存记录、暂停需要中断while循环。

因为下载是在子线程进行的,但我们一般需要在UI线程根据下载状态来更新UI,所以我们通过Handler将下载过程的状态数据发送到UI线程:即调用handler.sendEmptyMessage()方法。

最后FileTask类还有一个saveCommonFile()方法,即进行不支持断点续传的普通下载。

前边我们提到了通过Handler将下载过程的状态数据发送到UI线程,接下看下ProgressHandler类基本的处理:

private Handler mHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            super.handleMessage(msg);
            switch (mCurrentState) {
                case START:
                    break;
                case PROGRESS:
                    break;
                case CANCEL:
                    break;
                case PAUSE:
                    break;
                case FINISH:
                    break;
                case DESTROY:
                    break;
                case ERROR:
                    break;
            }
        }
    };

handleMessage()方法中,我们根据当前的下载状态进行相应的操作。
如果是START则需要将下载数据插入数据库,执行初始化回调等;如果是PROGRESS则执行下载进度回调;如果是CANCEL则删除目标文件、临时文件、数据库记录并执行对应回调等;如果是PAUSE则更新数据库文件记录并执行暂停的回调等;如果是FINISH则删除临时文件和数据库记录并执行完成的回调;如果是DESTROY则代表直接在Activity中下载,退出Activity则会更新数据库记录;最后的ERROR则对应出错的情况。具体的细节可参考源码。

最后在DownloadManger类里使用线程池执行下载操作:

ThreadPool.getInstance().getThreadPoolExecutor().execute(fileTask);

 //如果正在下载的任务数量等于线程池的核心线程数,则新添加的任务处于等待状态
        if (ThreadPool.getInstance().getThreadPoolExecutor().getActiveCount() == ThreadPool.getInstance().getCorePoolSize()) {
            downloadCallback.onWait();
        }

以及判断新添加的任务是否处于等待的状态,方便在UI层处理。到这里核心的实现原理就完了,更多的细节可以参考源码。

如何使用:

DownloadManger是个单例类,在这里封装在了具体的使用操作,我们可以根据url进行下载的开始、暂停、继续、取消、重新开始、线程池配置、https证书配置、查询数据的记录数据、获得当前某个下载状态的数据:

  • 开始一个下载任务我们可以通过三种方式来进行:
    1、通过DownloadManager类的start(DownloadData downloadData, DownloadCallback downloadCallback)方法,data可以设置url、保存路径、文件名、子任务数量:

2、先执行DownloadManager类的setOnDownloadCallback(DownloadData downloadData, DownloadCallback downloadCallback)方法,绑定data和callback,再执行start(String url)方法。

3、链式调用,需要通过DUtil类来进行:例如

DUtil.init(mContext)
                .url(url)
                .path(Environment.getExternalStorageDirectory() + "/DUtil/")
                .name(name.xxx)
                .childTaskCount(3)
                .build()
                .start(callback);

start()方法会返回DownloadManager类的实例,如果你不关心返回值,使用DownloadManger.getInstance(context)同样可以得到DownloadManager类的实例,以便进行后续的暂停、继续、取消等操作。

关于callback可以使用DownloadCallback接口实现完整的回调:

new DownloadCallback() {
                    //开始
                    @Override
                    public void onStart(long currentSize, long totalSize, float progress) {
                    }
                    //下载中
                    @Override
                    public void onProgress(long currentSize, long totalSize, float progress) { 
                    }
                    //暂停
                    @Override
                    public void onPause() {
                    }
                    //取消
                    @Override
                    public void onCancel() {
                    }
                    //下载完成
                    @Override
                    public void onFinish(File file) { 
                    }
                    //等待
                    @Override
                    public void onWait() {
                    }
                    //下载出错
                    @Override
                    public void onError(String error) {
                    }
                }

也可以使用SimpleDownloadCallback接口只实现需要的回调方法。

  • 暂停下载中的任务:pause(String url)

  • 继续暂停的任务:resume(String url)
    ps:不支持断点续传的文件无法进行暂停和继续操作。

  • 取消任务:cancel(String url),可以取消下载中、或暂停的任务。

  • 重新开始下载:restart(String url),暂停、下载中、已取消、已完成的任务均可重新开始下载。

  • 下载数据保存:destroy(String url)、destroy(String... urls),如在Activity中直接下载,直接退出时可在onDestroy()方法中调用,以保存数据。

  • 配置线程池:setTaskPoolSize(int corePoolSize, int maxPoolSize),设置核心线程数以及总线程数。

  • 配置okhttp证书:setCertificates(InputStream... certificates)

  • 在数据库查询单个数据DownloadData getDbData(String url),查询全部数据:List<DownloadData> getAllDbData()
    ps:数据库不保存已下载完成的数据

  • 获得下载队列中的某个文件数据:DownloadData getCurrentData(String url)

2017.4.7补充:如果下载过程中 进行清理后台等操作导致App进程被杀死,则下次会重新下载对应文件。

到这里基本的就介绍完了,更多的细节和具体的使用都在demo中,不合理的地方还请多多指教哦。

如果想了解文件上传可以看这里:Android 实现一个简单的文件上传工具

github地址https://github.com/SheHuan/DUtil

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,108评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,699评论 1 296
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,812评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,236评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,583评论 3 288
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,739评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,957评论 2 315
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,704评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,447评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,643评论 2 249
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,133评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,486评论 3 256
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,151评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,108评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,889评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,782评论 2 277
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,681评论 2 272

推荐阅读更多精彩内容