多线程下载文件(支持暂停取消断点续传)

Posted xyTianZhao

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了多线程下载文件(支持暂停取消断点续传)相关的知识,希望对你有一定的参考价值。

多线程下载文件(支持暂停、取消、断点续传)

多线程同时下载文件即:在同一时间内通过多个线程对同一个请求地址发起多个请求,将需要下载的数据分割成多个部分,同时下载,每个线程只负责下载其中的一部分,最后将每一个线程下载的部分组装起来即可。

涉及的知识及问题

  • 请求的数据如何分段
  • 分段完成后如何下载和下载完成后如何组装到一起
  • 暂停下载和继续下载的实现(wait()、notifyAll()、synchronized的使用)
  • 取消下载和断点续传的实现

一、请求的数据如何分段

首先通过HttpURLConnection请求总文件大小,而后根据线程数计算每一个线程的下载量,在分配给每一个线程去下载

fileLength = conn.getContentLength();
//根据文件大小,先创建一个空文件
//“r“——以只读方式打开。调用结果对象的任何 write 方法都将导致抛出 IOException。
//“rw“——打开以便读取和写入。如果该文件尚不存在,则尝试创建该文件。
//“rws“—— 打开以便读取和写入,对于 “rw”,还要求对文件的内容或元数据的每个更新都同步写入到底层存储设备。
//“rwd“——打开以便读取和写入,对于 “rw”,还要求对文件内容的每个更新都同步写入到底层存储设备。
RandomAccessFile raf = new RandomAccessFile(filePath, "rwd");
raf.setLength(fileLength);
raf.close();
//计算各个线程下载的数据段
int blockLength = fileLength / threadCount;

二、分段完成后如何下载和下载完成后如何组装到一起

分段完成后给每一个线程的请求头设置Range参数,他允许客户端只请求文件的一部分数据,每一个线程只请求下载相应范围内的数据,使用RandomAccessFile(可随机读写的文件)写入到同一个文件里即可组装成目标文件Range,是在 HTTP/1.1里新增的一个 header field,它允许客户端实际上只请求文档的一部分(范围可以相互重叠)

Range的使用形式:

属性解释
bytes=0-499表示头500个字节
bytes=500-999表示第二个500字节
bytes=-500表示最后500个字节
bytes=500-表示500字节以后的范围
bytes=0-0,-1第一个和最后一个字节

HttpUrlConnection中设置请求头

URL url = new URL(loadUrl);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Range", "bytes=" + startPosition + "-" + endPosition);
conn.setConnectTimeout(5000);
//若请求头加上Range这个参数,则返回状态码为206,而不是200
if (conn.getResponseCode() == 206) {
    InputStream is = conn.getInputStream();
    RandomAccessFile raf = new RandomAccessFile(filePath, "rwd");
    raf.seek(startPosition);//跳到指定位置开始写数据
}

三、暂停下载和继续下载的实现(wait()、notifyAll()、synchronized的使用)

关于synchronized只需记住一下五点:

  1. 当两个并发线程访问同一个对象object中的这个synchronized(this)同步代码块时,一个时间内只能有一个线程得到执行。另一个线程必须等待当前线程执行完这个代码块以后才能执行该代码块。
  2. 然而,当一个线程访问object的一个synchronized(this)同步代码块时,另一个线程仍然可以访问该object中的非synchronized(this)同步代码块。
  3. 尤其关键的是,当一个线程访问object的一个synchronized(this)同步代码块时,其他线程对object中所有其它synchronized(this)同步代码块的访问将被阻塞。
  4. 第三个例子同样适用其它同步代码块。也就是说,当一个线程访问object的一个synchronized(this)同步代码块时,它就获得了这个object的对象锁。结果,其它线程对该object对象所有同步代码部分的访问都被暂时阻塞。
  5. 以上规则对其它对象锁同样适用.
    protected void onPause() {
        if (mThreads != null)
            stateDownload = DOWNLOAD_PAUSE;
    }
    protected void onStart() {
        if (mThreads != null)
            synchronized (DOWNLOAD_PAUSE) {
                stateDownload = DOWNLOAD_ING;
                DOWNLOAD_PAUSE.notifyAll();
            }
    }

对于wait()、notify()、notifyAll()需要注意的是

  1. 调用任何对象的wait()方法时,都必须先获得该对象的锁,即调用的wait()方法必须得写在synchronized(obj){…}之内
  2. 当调用对象的wait()方法后,该线程若想继续执行,必须得再次获得该对象的锁才可以
  3. 如果A1,A2,A3线程都在obj.wait(),则B调用object.notify()只能唤醒A1,A2,A3中的一个(具体哪一个由JVM决定)
  4. 当B调用object.notify/notifyAll的时候,B正持有object锁,因此,A1,A2,A3虽被唤醒,但是仍无法获得object锁直到B退出synchronized块,释放object锁后,A1,A2,A3中的一个/全部才有机会获得锁继续执行
    synchronized (DOWNLOAD_PAUSE) {
        if (stateDownload.equals(DOWNLOAD_PAUSE)) {
            DOWNLOAD_PAUSE.wait();
        }
    }

四、取消下载和断点续传的实现

取消下载即取消每个线程的执行,不建议直接使用Thread.stop()方法,安全的取消线程即run方法执行结束。只要控制住循环,就可以让run方法结束,也就是线程结束

    while ((len = is.read(buffer)) != -1) {
        //是否继续下载
        if (!isGoOn)
            break;
    }

断点续传即其实和重新下载是一样的,不过文件的大小和每一个线程下载时的起始位置和结束位置都不是重新计算的。而是上次取消下载时,每一个线程保存的当前位置和结束位置,让每一个线程接着上次的地方继续下载即可

    SharedPreferences sp = mContext.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE);
    //获取上次取消下载的进度,若没有则返回0
    currLength = sp.getInt(CURR_LENGTH, 0);
    for (int i = 0; i < threadCount; i++) {
        //开始位置,获取上次取消下载的进度,默认返回i*blockLength,即第i个线程开始下载的位置
        int startPosition = sp.getInt(SP_NAME + (i + 1), i * blockLength);
        //结束位置,-1是为了防止上一个线程和下一个线程重复下载衔接处数据
        int endPosition = (i + 1) * blockLength - 1;
        //将最后一个线程结束位置扩大,防止文件下载不完全,大了不影响,小了文件失效
        if ((i + 1) == threadCount)
        endPosition = endPosition * 2;
        mThreads[i] = new DownThread(i + 1, startPosition, endPosition);
        mThreads[i].start();
    }

网络获取和读写SD卡都需要添加相应权限

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

下面贴上全部的代码,里面有详细的注释DownLoadFile.java

import android.content.Context;
import android.content.SharedPreferences;
import android.os.Handler;
import android.os.Message;

import java.io.InputStream;
import java.io.RandomAccessFile;
import java.net.HttpURLConnection;
import java.net.URL;

/**
 * Created by tianzhao on 2017/2/21 09:25.
 * 多线程下载文件
 */

public class DownLoadFile {


    private static final String SP_NAME = "download_file";
    private static final String CURR_LENGTH = "curr_length";
    private static final int DEFAULT_THREAD_COUNT = 4;//默认下载线程数
    //以下为线程状态
    private static final String DOWNLOAD_INIT = "1";
    private static final String DOWNLOAD_ING = "2";
    private static final String DOWNLOAD_PAUSE = "3";

    private Context mContext;

    private String loadUrl;//网络获取的url
    private String filePath;//下载到本地的path
    private int threadCount = DEFAULT_THREAD_COUNT;//下载线程数

    private int fileLength;//文件总大小
    //使用volatile防止多线程不安全
    private volatile int currLength;//当前总共下载的大小
    private volatile int runningThreadCount;//正在运行的线程数
    private Thread[] mThreads;
    private String stateDownload = DOWNLOAD_INIT;//当前线程状态

    private DownLoadListener mDownLoadListener;

    public void setOnDownLoadListener(DownLoadListener mDownLoadListener) {
        this.mDownLoadListener = mDownLoadListener;
    }

    interface DownLoadListener {
        //返回当前下载进度的百分比
        void getProgress(int progress);

        void onComplete();

        void onFailure();
    }

    public DownLoadFile(Context mContext, String loadUrl, String filePath) {
        this(mContext, loadUrl, filePath, DEFAULT_THREAD_COUNT, null);
    }

    public DownLoadFile(Context mContext, String loadUrl, String filePath, DownLoadListener mDownLoadListener) {
        this(mContext, loadUrl, filePath, DEFAULT_THREAD_COUNT, mDownLoadListener);
    }

    public DownLoadFile(Context mContext, String loadUrl, String filePath, int threadCount) {
        this(mContext, loadUrl, filePath, threadCount, null);
    }

    public DownLoadFile(Context mContext, String loadUrl, String filePath, int threadCount, DownLoadListener mDownLoadListener) {
        this.mContext = mContext;
        this.loadUrl = loadUrl;
        this.filePath = filePath;
        this.threadCount = threadCount;
        runningThreadCount = 0;
        this.mDownLoadListener = mDownLoadListener;
    }

    /**
     * 开始下载
     */
    protected void downLoad() {
        //在线程中运行,防止anr
        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    //初始化数据
                    if (mThreads == null)
                        mThreads = new Thread[threadCount];

                    //建立连接请求
                    URL url = new URL(loadUrl);
                    HttpURLConnection conn = (HttpURLConnection) url.openConnection();
                    conn.setConnectTimeout(5000);
                    conn.setRequestMethod("GET");
                    int code = conn.getResponseCode();//获取返回码
                    if (code == 200) {//请求成功,根据文件大小开始分多线程下载
                        fileLength = conn.getContentLength();
                        //根据文件大小,先创建一个空文件
                        //“r“——以只读方式打开。调用结果对象的任何 write 方法都将导致抛出 IOException。
                        //“rw“——打开以便读取和写入。如果该文件尚不存在,则尝试创建该文件。
                        //“rws“—— 打开以便读取和写入,对于 “rw”,还要求对文件的内容或元数据的每个更新都同步写入到底层存储设备。
                        //“rwd“——打开以便读取和写入,对于 “rw”,还要求对文件内容的每个更新都同步写入到底层存储设备。
                        RandomAccessFile raf = new RandomAccessFile(filePath, "rwd");
                        raf.setLength(fileLength);
                        raf.close();
                        //计算各个线程下载的数据段
                        int blockLength = fileLength / threadCount;

                        SharedPreferences sp = mContext.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE);
                        //获取上次取消下载的进度,若没有则返回0
                        currLength = sp.getInt(CURR_LENGTH, 0);
                        for (int i = 0; i < threadCount; i++) {
                            //开始位置,获取上次取消下载的进度,默认返回i*blockLength,即第i个线程开始下载的位置
                            int startPosition = sp.getInt(SP_NAME + (i + 1), i * blockLength);
                            //结束位置,-1是为了防止上一个线程和下一个线程重复下载衔接处数据
                            int endPosition = (i + 1) * blockLength - 1;
                            //将最后一个线程结束位置扩大,防止文件下载不完全,大了不影响,小了文件失效
                            if ((i + 1) == threadCount)
                                endPosition = endPosition * 2;

                            mThreads[i] = new DownThread(i + 1, startPosition, endPosition);
                            mThreads[i].start();
                        }
                    } else {
                        handler.sendEmptyMessage(FAILURE);
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                    handler.sendEmptyMessage(FAILURE);
                }
            }
        }).start();
    }

    /**
     * 取消下载
     */
    protected void cancel() {
        if (mThreads != null) {
            //若线程处于等待状态,则while循环处于阻塞状态,无法跳出循环,必须先唤醒线程,才能执行取消任务
            if (stateDownload.equals(DOWNLOAD_PAUSE))
                onStart();
            for (Thread dt : mThreads) {
                ((DownThread) dt).cancel();
            }
        }
    }

    /**
     * 暂停下载
     */
    protected void onPause() {
        if (mThreads != null)
            stateDownload = DOWNLOAD_PAUSE;
    }

    /**
     * 继续下载
     */
    protected void onStart() {
        if (mThreads != null)
            synchronized (DOWNLOAD_PAUSE) {
                stateDownload = DOWNLOAD_ING;
                DOWNLOAD_PAUSE.notifyAll();
            }
    }

    protected void onDestroy() {
        if (mThreads != null)
            mThreads = null;
    }

    private class DownThread extends Thread {

        private boolean isGoOn = true;//是否继续下载
        private int threadId;
        private int startPosition;//开始下载点
        private int endPosition;//结束下载点
        private int currPosition;//当前线程的下载进度

        private DownThread(int threadId, int startPosition, int endPosition) {
            this.threadId = threadId;
            this.startPosition = startPosition;
            currPosition = startPosition;
            this.endPosition = endPosition;
            runningThreadCount++;
        }

        @Override
        public void run() {
            SharedPreferences sp = mContext.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE);
            try {
                URL url = new URL(loadUrl);
                HttpURLConnection conn = (HttpURLConnection) url.openConnection();
                conn.setRequestMethod("GET");
                conn.setRequestProperty("Range", "bytes=" + startPosition + "-" + endPosition);
                conn.setConnectTimeout(5000);
                //若请求头加上Range这个参数,则返回状态码为206,而不是200
                if (conn.getResponseCode() == 206) {
                    InputStream is = conn.getInputStream();
                    RandomAccessFile raf = new RandomAccessFile(filePath, "rwd");
                    raf.seek(startPosition);//跳到指定位置开始写数据
                    int len;
                    byte[] buffer = new byte[1024];
                    while ((len = is.read(buffer)) != -1) {
                        //是否继续下载
                        if (!isGoOn)
                            break;
                        //回调当前进度
                        if (mDownLoadListener != null) {
                            currLength += len;
                            int progress = (int) ((float) currLength / (float) fileLength * 100);
                            handler.sendEmptyMessage(progress);
                        }

                        raf.write(buffer, 0, len);
                        //写完后将当前指针后移,为取消下载时保存当前进度做准备
                        currPosition += len;
                        synchronized (DOWNLOAD_PAUSE) {
                            if (stateDownload.equals(DOWNLOAD_PAUSE)) {
                                DOWNLOAD_PAUSE.wait();
                            }
                        }
                    }
                    is.close();
                    raf.close();
                    //线程计数器-1
                    runningThreadCount--;
                    //若取消下载,则直接返回
                    if (!isGoOn) {
                        //此处采用SharedPreferences保存每个线程的当前进度,和三个线程的总下载进度
                        if (currPosition < endPosition) {
                            sp.edit().putInt(SP_NAME + threadId, currPosition).apply();
                            sp.edit().putInt(CURR_LENGTH, currLength).apply();
                        }
                        return;
                    }
                    if (runningThreadCount == 0) {
                        sp.edit().clear().apply();
                        handler.sendEmptyMessage(SUCCESS);
                        handler.sendEmptyMessage(100);
                        mThreads = null;
                    }
                } else {
                    sp.edit().clear().apply();
                    handler.sendEmptyMessage(FAILURE);
                }
            } catch (Exception e) {
                sp.edit().clear().apply();
                e.printStackTrace();
                handler.sendEmptyMessage(FAILURE);
            }
        }

        public void cancel() {
            isGoOn = false;
        }
    }

    private final int SUCCESS = 0x00000101;
    private final int FAILURE = 0x00000102;

    private Handler handler = new Handler() {
        @Override
        public void handleMessage(Message msg) {

            if (mDownLoadListener != null) {
                if (msg.what == SUCCESS) {
                    mDownLoadListener.onComplete();
                } else if (msg.what == FAILURE) {

                    mDownLoadListener.onFailure();
                } else {
                    mDownLoadListener.getProgress(msg.what);
                }
            }
        }
    };
}

在MainActivity中的使用

import android.os.Bundle;
import android.os.Environment;
import android.support.v7.app.AppCompatActivity;
import android.view.View;
import android.widget.TextView;
import android.widget.Toast;

public class MainActivity extends AppCompatActivity {
    DownLoadFile downLoadFile;
    private String loadUrl = "http://gdown.baidu.com/data/wisegame/d2fbbc8e64990454/wangyiyunyinle_87.apk";
    private String filePath = Environment.getExternalStorageDirectory()+"/"+"网易云音乐.apk";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        final TextView tvprogress = (TextView) findViewById(R.id.tv_progress);
        downLoadFile = new DownLoadFile(this,loadUrl, filePath, 3);
        downLoadFile.setOnDownLoadListener(new DownLoadFile.DownLoadListener() {
            @Override
            public void getProgress(int progress) {
                tvprogress.setText("当前进度 :"+progress+" %");
            }

            @Override
            public void onComplete() {
                Toast.makeText(MainActivity.this,"下载完成",Toast.LENGTH_SHORT).show();
            }

            @Override
            public void onFailure() {
                Toast.makeText(MainActivity.this,"下载失败",Toast.LENGTH_SHORT).show();
            }
        });
        findViewById(R.id.bt).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                downLoadFile.downLoad();
            }
        });
        findViewById(R.id.bt_pause).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                downLoadFile.onPause();
            }
        });
        findViewById(R.id.bt_start).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                downLoadFile.onStart();
            }
        });
        findViewById(R.id.bt_cancel).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                downLoadFile.cancel();
            }
        });
    }

    @Override
    protected void onDestroy() {
        downLoadFile.onDestroy();
        super.onDestroy();
    }
}

关于多线程下载文件就说道这,若有不正之处,多多指教!

以上是关于多线程下载文件(支持暂停取消断点续传)的主要内容,如果未能解决你的问题,请参考以下文章

Android 多线程下载,断点续传,线程池

断点续传基本原理初了解

Android多线程断点续传下载原理及实现

RandomAccessFile 解决多线程下载及断点续传

java 文件下载,支持任务暂停,恢复,断点续传;任务状态查询;任务并发控制

⭐Python实现多线程并发下载大文件(制作支持断点续传的下载器的绝佳参考⁉️)⭐