轻量级多线程断点续传下载框架

我又来了,一个月写了三个小框架我也是屌屌的。

一般的小项目,遇到下载的问题时都是简单的开一个线程然后通过流的方式来实现。少量的下载,文件也比较小的的时候,这样的方式都是OK的。但是如果真要做一款下载为主要功能的app的时候,或者项目中涉及大量下载任务的时候,首先,单线程下载速度慢的感人,其次,用户想要自由的暂停一些任务开始一些任务,而不是开始了就停不下来,另外,即使用户退出了,下次再点击下载的时候,如果又重头下载了,用户一定会卸载你的。基于这几个小需求,我们项目中必须引入多线程断点续传下载机制。

博文很多,github上开源项目也很多,还是那句话,SDK需要的是精简和高效,能用自行车解决的没必要也不允许用SUV来解决,所以与其费劲去读别人的源码,担心未知的bug,不如理解原理之后自己动手写一个。

先看多线程下载的原理。
简单来说,就是把一个大文件切割成几个小文件,然后分别用独立的线程去下载,当然客户端性能有限,一般2-3个线程即可。这里需要用到Http头中的Range属性,具体后面代码会说。

断点续传原理。
普通下载是先本地创建了一个空文件,然后通过流不断向里面写入数据,当网络连接被切断之后,若再次开始下载,只能删除这个文件,然后重新创建再从头下载。
和普通下载不同的是,断点续传下载会首先通过http协议获得文件的大小,然后在本地创建一个同样大小文件,接下来同样会通过流向当中写入数据,当网络切断的时候,只要我们保存好当前已经写入的数据长度,从这个长度重新开始写入数据,就能保证续传后的文件一样是完整可用的文件。这里同样需要用到Http头中的Content-Length和Range属性,同时还有一个重要的java类RandomAccessFile。

讲完原理上代码:

/**
 * Created by Amuro on 2016/10/31.
 */
public class DownloadManager
{

    public interface DownloadListener
    {
        void onStart(String fileName, String realUrl, long fileLength);

        void onProgress(int progress);

        void onFinish(File file);

        void onError(int status, String error);

    }

    private DownloadManager()
    {}

    private static DownloadManager instance;

    public static DownloadManager getInstance()
    {
        if(instance == null)
        {
            synchronized (DownloadManager.class)
            {
                if(instance == null)
                {
                    instance = new DownloadManager();
                }
            }
        }

        return instance;
    }

    public static final int ERROR_CODE_NO_NETWORK = 1000;
    public static final int ERROR_CODE_FILE_LENGTH_ERROR = 1001;
    public static final int ERROR_CODE_BEYOND_MAX_TASK = 1002;
    public static final int ERROR_CODE_INIT_FAILED = 1003;
    public static final int ERROR_CODE_DOWNLOAD_FAILED = 1004;

    private DownloadConfig config;
    private Handler handler = new Handler(Looper.getMainLooper());
    private DLCore dlCore;

    public void init(DownloadConfig config)
    {
        if(config == null)
        {
            throw new RuntimeException("Download config can't be null");
        }

        this.config = config;
        dlCore = new DLCore();
    }

    public DownloadConfig getConfig()
    {
        return config;
    }

    public void invoke(String url, DownloadListener listener)
    {
        invoke(url, null, listener);
    }

    public void invoke(String url, String fileName, DownloadListener listener)
    {
        if(!DLUtil.isNetworkAvailable(config.getContext()))
        {
            if(listener != null)
            {
                listener.onError(ERROR_CODE_NO_NETWORK, "no network");
            }

            return;
        }

        DLFileInfo fileInfo = new DLFileInfo();
        fileInfo.url = url;
        fileInfo.name = fileName;

        dlCore.start(fileInfo, listener);
    }

    public void stop(String url)
    {
        dlCore.stop(url);
    }

    public void postToUIThread(Runnable r)
    {
        handler.post(r);
    }

    public void destroy()
    {
        dlCore.stopAll();
    }
}

跟ImageLoader一样,我们先要让使用者初始化的时候传一个配置进去,这里的配置用了同样的建造者模式:


/**
 * Created by Amuro on 2016/10/31.
 */
public class DownloadConfig
{
    private static final String DEFAULT_SAVE_PATH =
            Environment.getExternalStorageDirectory().getAbsolutePath() +
                    "/downloadCompact/";

    private DownloadConfig()
    {}

    private Context context;
    private String savePath;
    private boolean isDebug;
    private int maxTask;

    public String getSavePath()
    {
        return savePath;
    }

    public boolean isDebug()
    {
        return isDebug;
    }

    public Context getContext()
    {
        return context;
    }

    public int getMaxTask()
    {
        return maxTask;
    }

    public static class Builder
    {
        Context context;
        String savePath;
        boolean isDebug = false;
        int maxTask = 1;

        public Builder setContext(Context context)
        {
            this.context = context;
            return this;
        }

        public Builder setSavePath(String savePath)
        {
            this.savePath = savePath;
            return this;
        }

        public Builder setIsDebug(boolean isDebug)
        {
            this.isDebug = isDebug;
            return this;
        }

        public Builder setMaxTask(int maxTask)
        {
            this.maxTask = maxTask;
            return this;
        }

        public DownloadConfig create()
        {
            DownloadConfig config = new DownloadConfig();
            config.context = context;

            if(TextUtils.isEmpty(savePath))
            {
                config.savePath = DEFAULT_SAVE_PATH;
            }
            else
            {
                config.savePath = savePath;
            }

            config.isDebug = isDebug;
            config.maxTask = maxTask;

            return config;
        }
    }

}

其中maxTask是app启动是允许同时下载的最大任务数。
初始化除了保存外部传入的配置外,最重要的是初始化下载核心类DLCore,看代码:

/**
 * Created by Amuro on 2016/10/31.
 */
public class DLCore
{
    protected static ExecutorService threadPool =
                            Executors.newCachedThreadPool();
    protected static AtomicInteger runningTasks = new AtomicInteger(0);;

    private String savePath;
    private Map<String, DLTask> taskMap = new LinkedHashMap<String, DLTask>();


    public DLCore()
    {
        this.savePath =
                DownloadManager.getInstance().getConfig().getSavePath();
    }

    public void start(DLFileInfo fileInfo, DownloadListener listener)
    {
        if(runningTasks.get() < DownloadManager.getInstance().getConfig().getMaxTask())
        {
            runningTasks.incrementAndGet();
            threadPool.execute(new InitThread(fileInfo, listener));
        }
        else
        {
            listener.onError(
                    DownloadManager.ERROR_CODE_BEYOND_MAX_TASK,
                    "waiting for other task completed");
        }
    }

    public void stop(String url)
    {
        DLTask dlTask = taskMap.get(url);
        if(dlTask != null)
        {
            dlTask.pause();
            runningTasks.decrementAndGet();
        }
    }

    public void stopAll()
    {
        for(DLTask dlTask : taskMap.values())
        {
            if(dlTask != null)
            {
                dlTask.pause();
            }
        }

        runningTasks.set(0);
    }

    //run in main thread
    private void onInitCompleted(DLFileInfo fileInfo, DownloadListener listener)
    {
        DLTask dlTask = new DLTask(
                fileInfo, listener, 3);
        dlTask.download();
        taskMap.put(fileInfo.url, dlTask);
    }

    class InitThread extends Thread
    {
        private DLFileInfo fileInfo;
        private DownloadListener listener;

        public InitThread(DLFileInfo fileInfo, DownloadListener listener)
        {
            this.fileInfo = fileInfo;
            this.listener = listener;
        }

        @Override
        public void run()
        {
            doInit();
        }

        private void doInit()
        {
            HttpURLConnection conn = null;
            RandomAccessFile raf = null;
            try
            {
                URL url = new URL(fileInfo.url);
                conn = (HttpURLConnection) url.openConnection();
                conn.setConnectTimeout(3000);
                conn.setRequestMethod("GET");

                int fileTotalLength = -1;
                int rspCode = conn.getResponseCode();
                if(rspCode == HttpURLConnection.HTTP_OK ||
                        rspCode == HttpURLConnection.HTTP_PARTIAL)
                {
                    fileTotalLength = conn.getContentLength();
                }

                DLogUtils.e("content length: " + fileTotalLength);

                if(fileTotalLength <= 0)
                {
                    DownloadManager.getInstance().postToUIThread(new Runnable()
                    {
                        @Override
                        public void run()
                        {
                            if(listener != null)
                            {
                                listener.onError(
                                        DownloadManager.ERROR_CODE_FILE_LENGTH_ERROR,
                                        "obtain file length error");
                            }
                        }
                    });
                    return;
                }

                //下载dir确认,不存在则创建
                File dir = new File(savePath);
                if(!dir.exists())
                {
                    dir.mkdir();
                }

                DLogUtils.e("before: " + fileInfo.name);
                //建立下载文件,文件名可以从外面传入,如果没有传入则通过网络获取
                File file = null;
                if(TextUtils.isEmpty(fileInfo.name))
                {
                    String disposition = conn.getHeaderField("Content-Disposition");
                    String location = conn.getHeaderField("Content-Location");
                    String generatedFileName =
                            DLUtil.obtainFileName(
                                    fileInfo.url, disposition, location);

                    file = new File(savePath, generatedFileName);
                    fileInfo.name = generatedFileName;
                }
                else
                {
                    file = new File(savePath, fileInfo.name);
                }

                DLogUtils.e("after: " + fileInfo.name);

                //设置下载文件,并回调onStart
                raf = new RandomAccessFile(file, "rwd");
                raf.setLength(fileTotalLength);
                fileInfo.totalLength = fileTotalLength;
                DownloadManager.getInstance().postToUIThread(new Runnable()
                {
                    @Override
                    public void run()
                    {
                        if (listener != null)
                        {
                            listener.onStart(
                                    fileInfo.name, fileInfo.url, fileInfo.totalLength);

                        }
                        onInitCompleted(fileInfo, listener);
                    }
                });
            }
            catch (final Exception e)
            {
                runningTasks.decrementAndGet();
                DownloadManager.getInstance().postToUIThread(new Runnable()
                {
                    @Override
                    public void run()
                    {
                        if(listener != null)
                        {
                            listener.onError(DownloadManager.ERROR_CODE_INIT_FAILED,
                                    "init failed: " + e.getMessage());
                        }
                    }
                });
            }
            finally
            {
                try
                {
                    conn.disconnect();
                    raf.close();
                }
                catch (Exception e)
                {
                    e.printStackTrace();
                }

            }
        }

    }
}

DLCore中初始化了线程池,并利用AtomInteger来给当前运行的任务进行计数,控制下载的最大任务数。这里我们选用了缓存线程池,因为下载会开启大量的线程,用固定数量的线程池会经常导致线程浪费或不够用,所以把这件事动态化是一个明智的选择。start方法是启动下载的核心方法,会先开启一个线程发送http请求做一些初始化的工作,主要是获取文件名和文件长度,并在本地通过RandomAccessFile类创建一个对应的文件,原理在前面已经讲过了。一切没有异常的话,初始化完成,然后就会启动多线程下载。我们来看DLTask这个类:

/**
 * Created by Amuro on 2016/10/20.
 */
public class DLTask
{
    private DLFileInfo fileInfo;
    private ThreadInfoDAO dao;
    private int threadCount = 1;
    private long totalFinishedLength = 0;
    private boolean isPause = false;
    private List<DownloadThread> threadList;
    private DownloadListener listener;

    public DLTask(DLFileInfo fileInfo, DownloadListener listener, int threadCount)
    {
        this.fileInfo = fileInfo;
        this.listener = listener;
        this.threadCount = threadCount;

        this.dao = new ThreadInfoDAOImpl(
                DownloadManager.getInstance().getConfig().getContext());
    }

    public void download()
    {
        List<ThreadInfo> threadInfoList = dao.queryThreadInfo(fileInfo.url);
        if(threadInfoList.size() == 0)
        {
            long length = fileInfo.totalLength / threadCount;
            for (int i = 0; i < threadCount; i++)
            {
                ThreadInfo threadInfo = new ThreadInfo(
                        i, fileInfo.url, length * i, length * (i + 1) - 1, 0);

                if(i == threadCount - 1)
                {
                    threadInfo.setEnd(fileInfo.totalLength);
                }

                threadInfoList.add(threadInfo);
                dao.insertThreadInfo(threadInfo);
            }
        }

        threadList = new ArrayList<DownloadThread>();

        for(ThreadInfo info : threadInfoList)
        {
            DownloadThread downloadThread = new DownloadThread(info);
            DLCore.threadPool.execute(downloadThread);
            threadList.add(downloadThread);
        }
    }

    public void pause()
    {
        this.isPause = true;
    }

    class DownloadThread extends Thread
    {
        private ThreadInfo threadInfo;
        private boolean isFinished = false;

        public DownloadThread(ThreadInfo threadInfo)
        {
            this.threadInfo = threadInfo;
        }

        @Override
        public void run()
        {
            HttpURLConnection conn = null;
            RandomAccessFile raf = null;
            InputStream inputStream = null;

            try
            {
                URL url = new URL(threadInfo.getUrl());
                conn = (HttpURLConnection)url.openConnection();
                conn.setConnectTimeout(3000);
                conn.setRequestMethod("GET");

                long start = threadInfo.getStart() + threadInfo.getFinished();
                conn.setRequestProperty(
                        "Range", "bytes=" + start + "-" + threadInfo.getEnd());

                final File file = new File(
                        DownloadManager.getInstance().getConfig().getSavePath(),
                        fileInfo.name);
                raf = new RandomAccessFile(file, "rwd");
                raf.seek(start);

                totalFinishedLength += threadInfo.getFinished();

                if(conn.getResponseCode() == HttpURLConnection.HTTP_OK
                        || conn.getResponseCode() == HttpURLConnection.HTTP_PARTIAL)
                {
                    inputStream = conn.getInputStream();
                    byte[] buffer = new byte[1024 * 4];
                    int len = -1;
                    long time = System.currentTimeMillis();
                    while ((len = inputStream.read(buffer)) != -1)
                    {
                        raf.write(buffer, 0, len);

                        totalFinishedLength += len;

                        //每个线程自己的进度
                        threadInfo.setFinished(threadInfo.getFinished() + len);

                        if(System.currentTimeMillis() - time > 1000)
                        {
                            time = System.currentTimeMillis();

                            DownloadManager.getInstance().postToUIThread(new Runnable()
                            {
                                @Override
                                public void run()
                                {
                                    if(listener != null)
                                    {
                                        Long l = totalFinishedLength * 100 / fileInfo.totalLength;
                                        int finishedPercent = l.intValue();

                                        DLogUtils.e(
                                                "当前下载完成的文件长度:" + totalFinishedLength + "\n" +
                                                "fileTotalLength: " + fileInfo.totalLength + "\n" +
                                                "finishedPercent: " + finishedPercent);
                                        listener.onProgress(finishedPercent);
//                                        fileInfo.finishedPercent
                                    }
                                }
                            });
                        }

                        if(isPause)
                        {
                            dao.updateThreadInfo(
                                    threadInfo.getUrl(), threadInfo.getId(),
                                    threadInfo.getFinished());
                            return;
                        }
                    }

                    isFinished = true;
                    checkAllThreadsFinished();
                }
            }
            catch (final Exception e)
            {
                e.printStackTrace();
                DLCore.runningTasks.decrementAndGet();
                DownloadManager.getInstance().postToUIThread(new Runnable()
                {
                    @Override
                    public void run()
                    {
                        if(listener != null)
                        {
                            listener.onError(DownloadManager.ERROR_CODE_DOWNLOAD_FAILED,
                                    "download failed: " + e.getMessage());
                        }
                    }
                });
            }
            finally
            {
                try
                {
                    conn.disconnect();
                    inputStream.close();
                    raf.close();
                }
                catch (Exception e)
                {
                    e.printStackTrace();
                }
            }
        }
    }

    private synchronized void checkAllThreadsFinished()
    {
        boolean allFinished = true;

        for(DownloadThread downloadThread : threadList)
        {
            if(!downloadThread.isFinished)
            {
                allFinished = false;
                break;
            }
        }

        if(allFinished)
        {
            dao.deleteThreadInfo(fileInfo.url);
            DLCore.runningTasks.decrementAndGet();

            DownloadManager.getInstance().postToUIThread(new Runnable()
            {
                @Override
                public void run()
                {
                    if(listener != null)
                    {
                        listener.onFinish(
                                new File(DownloadManager.getInstance().getConfig().getSavePath(),
                                        fileInfo.name));
                    }
                }
            });

        }

    }
}

代码比较多,也是最复杂的一个类了,但是原理就是上面的分段下载的思想,按这个分析就很简单了。初始化的时候主要是接收外面配置的线程数量,同时把数据库操作的DAO类初始化。数据库操作是基本功了,这里就不贴代码了,主要就是保存停止下载时所有下载线程的缓存信息,再下一次启动下载的时候读取。再来看核心的download方法,这个方法首先会通过url去查询对应的ThreadInfo信息,如果查询不到,就创建对应数量的新ThreadInfo,并入库;如果查询到,则取出对应的ThreadInfo继续操作。ThreadInfo准备好之后,这时候就创建对应数量的下载线程去做下载的工作。
下载的代码里有几个重点的地方,首先是之前说的要用到Http请求头的Range属性,也就是每个线程要设置自己的开始和结束位置。然后同样的事情也要对本地的文件进行操作,这里用到了RandomAccessFile的seek方法,而总进度则是三个线程进度的总和。下载开始后,就是最普通的流读取,缓存写入等操作,同时通过handler更新进度给界面回调。看一下pause里的代码,其实就是保存了当前所有的ThreadInfo信息,然后退出线程的run,是不是很简单。最后,当某个线程的下载完成后,要去确认是不是所有的线程都下载完成。所有线程下载完成,才是真正下载完成,这时候才能回调给界面层。别忘了把我们前面的下载任务计数器减一。

一共只有九个类就基本完成了简单的多线程断点续传,后面有新需求再拓展就好了。

最后贴一下用法,基本也是标准化的东西了。
初始化:

DownloadConfig config = new DownloadConfig.Builder().   setContext(this).setIsDebug(true).setMaxTask(2).create();
        DownloadManager.getInstance().init(config);

下载与回调:

private class DownloadStartOnClickListener implements View.OnClickListener
    {
        private ViewHolder viewHolder;
        private DLFileInfo fileInfo;

        protected DownloadStartOnClickListener(ViewHolder viewHolder, DLFileInfo fileInfo)
        {
            this.viewHolder = viewHolder;
            this.fileInfo = fileInfo;
        }

        @Override
        public void onClick(View v)
        {
            DownloadManager.getInstance().invoke(
                    fileInfo.url,
                    fileInfo.name,
                    new DownloadManager.DownloadListener()
                    {

                        @Override
                        public void onStart(String fileName, String realUrl, long fileLength)
                        {
                            viewHolder.textViewFileName.setText(fileName);
                            showToast(fileName + "开始下载");
                        }

                        @Override
                        public void onProgress(int progress)
                        {
                            viewHolder.progressBar.setProgress(progress);
                        }

                        @Override
                        public void onFinish(File file)
                        {                            
                       viewHolder.progressBar.setProgress(100);
                            showToast(fileInfo.name + "下载完成");
                        }

                        @Override
                        public void onError(int status, String error)
                        {
                            showToast("error -> " + status + " : " + error);
                        }
                    });
        }
    }

销毁:

DownloadManager.getInstance().destroy();

就酱,谢谢观赏。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值