1. 程式人生 > >OkHttp實現多執行緒斷點續傳下載,單例模式下多工下載管理器,一起拋掉sp,sqlite的輔助吧

OkHttp實現多執行緒斷點續傳下載,單例模式下多工下載管理器,一起拋掉sp,sqlite的輔助吧

        最近專案需要使用到斷點下載功能,筆者比較喜歡折騰,想方設法拋棄SharedPreferences,尤其是sqlite作記錄輔助,改用臨時記錄檔案的形式記錄下載進度,本文以斷點下載為例。先看看demo執行效果圖:

     

        斷點續傳:記錄上次上傳(下載)節點位置,下次接著該位置繼續上傳(下載)。多執行緒斷點續傳下載則是根據目標下載檔案長度,儘可能地等分給多個執行緒同時下載檔案塊,當各個執行緒全部完成下載後,將檔案塊合併成一個檔案,即目標檔案。多執行緒斷點續傳不僅為使用者避免了斷網等突發事故需要重新下載浪費流量的尷尬局面,也大大提高了下載速率,當然,不是執行緒越多越好,網路頻寬才是硬道理!以下為原理圖:

    

          java,android中可以使用RandomAccessFile類生成一個同目標檔案大小的佔位檔案,以便於各個執行緒可以同時操作該檔案,並寫入各執行緒實時下載的資料。

          下面貼出OkHttp實現的單個多執行緒下載任務類的DownloadTask.java檔案:

package cn.icheny.download;

import android.os.Handler;
import android.os.Message;
import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.RandomAccessFile;
import okhttp3.Call;
import okhttp3.Response;

/**
 * 多執行緒下載任務
 * Created by Cheny on 2017/05/03.
 */

public class DownloadTask extends Handler {

    private final int THREAD_COUNT = 4;//下載執行緒數量
    private FilePoint mPoint;
    private long mFileLength;//檔案大小

    private boolean isDownloading = false;//是否正在下載
    private int childCanleCount;//子執行緒取消數量
    private int childPauseCount;//子執行緒暫停數量
    private int childFinishCount;//子執行緒完成下載數量
    private HttpUtil mHttpUtil;//http網路通訊工具
    private long[] mProgress;//各個子執行緒下載進度集合
    private File[] mCacheFiles;//各個子執行緒下載快取資料檔案
    private File mTmpFile;//臨時佔位檔案
    private boolean pause;//是否暫停
    private boolean cancel;//是否取消下載

    private final int MSG_PROGRESS = 1;//進度
    private final int MSG_FINISH = 2;//完成下載
    private final int MSG_PAUSE = 3;//暫停
    private final int MSG_CANCEL = 4;//暫停
    private DownloadListner mListner;//下載回撥監聽

    DownloadTask(FilePoint point, DownloadListner l) {
        this.mPoint = point;
        this.mListner = l;
        this.mProgress = new long[THREAD_COUNT];
        this.mCacheFiles = new File[THREAD_COUNT];
        this.mHttpUtil = HttpUtil.getInstance();
    }

    /**
     * 開始下載
     */
    public synchronized void start() {
        try {
            if (isDownloading) return;
            isDownloading = true;
            mHttpUtil.getContentLength(mPoint.getUrl(), new okhttp3.Callback() {
                @Override
                public void onResponse(Call call, Response response) throws IOException {
                    if (response.code() != 200) {
                        close(response.body());
                        resetStutus();
                        return;
                    }
                    // 獲取資源大小
                    mFileLength = response.body().contentLength();
                    close(response.body());
                    // 在本地建立一個與資源同樣大小的檔案來佔位
                    mTmpFile = new File(mPoint.getFilePath(), mPoint.getFileName() + ".tmp");
                    if (!mTmpFile.getParentFile().exists()) mTmpFile.getParentFile().mkdirs();
                    RandomAccessFile tmpAccessFile = new RandomAccessFile(mTmpFile, "rw");
                    tmpAccessFile.setLength(mFileLength);
                    /*將下載任務分配給每個執行緒*/
                    long blockSize = mFileLength / THREAD_COUNT;// 計算每個執行緒理論上下載的數量.

                    /*為每個執行緒配置並分配任務*/
                    for (int threadId = 0; threadId < THREAD_COUNT; threadId++) {
                        long startIndex = threadId * blockSize; // 執行緒開始下載的位置
                        long endIndex = (threadId + 1) * blockSize - 1; // 執行緒結束下載的位置
                        if (threadId == (THREAD_COUNT - 1)) { // 如果是最後一個執行緒,將剩下的檔案全部交給這個執行緒完成
                            endIndex = mFileLength - 1;
                        }
                        download(startIndex, endIndex, threadId);// 開啟執行緒下載
                    }
                }

                @Override
                public void onFailure(Call call, IOException e) {
                    resetStutus();
                }
            });
        } catch (IOException e) {
            e.printStackTrace();
            resetStutus();
        }
    }

    /**
     * 下載
     * @param startIndex 下載起始位置
     * @param endIndex  下載結束位置
     * @param threadId 執行緒id
     * @throws IOException
     */
    public void download(final long startIndex, final long endIndex, final int threadId) throws IOException {
        long newStartIndex = startIndex;
        // 分段請求網路連線,分段將檔案儲存到本地.
        // 載入下載位置快取資料檔案
        final File cacheFile = new File(mPoint.getFilePath(), "thread" + threadId + "_" + mPoint.getFileName() + ".cache");
        mCacheFiles[threadId] = cacheFile;
        final RandomAccessFile cacheAccessFile = new RandomAccessFile(cacheFile, "rwd");
        if (cacheFile.exists()) {// 如果檔案存在
            String startIndexStr = cacheAccessFile.readLine();
            try {
                newStartIndex = Integer.parseInt(startIndexStr);//重新設定下載起點
            } catch (NumberFormatException e) {
                e.printStackTrace();
            }
        }
        final long finalStartIndex = newStartIndex;
        mHttpUtil.downloadFileByRange(mPoint.getUrl(), finalStartIndex, endIndex, new okhttp3.Callback() {
            @Override
            public void onResponse(Call call, Response response) throws IOException {
                if (response.code() != 206) {// 206:請求部分資源成功碼,表示伺服器支援斷點續傳
                    resetStutus();
                    return;
                }
                InputStream is = response.body().byteStream();// 獲取流
                RandomAccessFile tmpAccessFile = new RandomAccessFile(mTmpFile, "rw");// 獲取前面已建立的檔案.
                tmpAccessFile.seek(finalStartIndex);// 檔案寫入的開始位置.
                  /*  將網路流中的檔案寫入本地*/
                byte[] buffer = new byte[1024 << 2];
                int length = -1;
                int total = 0;// 記錄本次下載檔案的大小
                long progress = 0;
                while ((length = is.read(buffer)) > 0) {//讀取流
                    if (cancel) {
                        close(cacheAccessFile, is, response.body());//關閉資源
                        cleanFile(cacheFile);//刪除對應快取檔案
                        sendMessage(MSG_CANCEL);
                        return;
                    }
                    if (pause) {
                        //關閉資源
                        close(cacheAccessFile, is, response.body());
                        //傳送暫停訊息
                        sendMessage(MSG_PAUSE);
                        return;
                    }
                    tmpAccessFile.write(buffer, 0, length);
                    total += length;
                    progress = finalStartIndex + total;

                    //將該執行緒最新完成下載的位置記錄並儲存到快取資料檔案中
                    //建議轉成Base64碼,防止資料被修改,導致下載檔案出錯(若真有這樣的情況,這樣的朋友可真是無聊透頂啊)
                    cacheAccessFile.seek(0);
                    cacheAccessFile.write((progress + "").getBytes("UTF-8"));
                    //傳送進度訊息
                    mProgress[threadId] = progress - startIndex;
                    sendMessage(MSG_PROGRESS);
                }
                //關閉資源
                close(cacheAccessFile, is, response.body());
                // 刪除臨時檔案
                cleanFile(cacheFile);
                //傳送完成訊息
                sendMessage(MSG_FINISH);
            }

            @Override
            public void onFailure(Call call, IOException e) {
                isDownloading = false;
            }
        });
    }
    /**
     * 輪迴訊息回撥
     *
     * @param msg
     */
    @Override
    public void handleMessage(Message msg) {
        super.handleMessage(msg);
        if (null == mListner) {
            return;
        }
        switch (msg.what) {
            case MSG_PROGRESS://進度
                long progress = 0;
                for (int i = 0, length = mProgress.length; i < length; i++) {
                    progress += mProgress[i];
                }
                mListner.onProgress(progress * 1.0f / mFileLength);
                break;
            case MSG_PAUSE://暫停
                childPauseCount++;
                if (childPauseCount % THREAD_COUNT != 0) return;//等待所有的執行緒完成暫停,真正意義的暫停,以下同理
                resetStutus();
                mListner.onPause();
                break;
            case MSG_FINISH://完成
                childFinishCount++;
                if (childFinishCount % THREAD_COUNT != 0) return;
                mTmpFile.renameTo(new File(mPoint.getFilePath(), mPoint.getFileName()));//下載完畢後,重新命名目標檔名
                resetStutus();
                mListner.onFinished();
                break;
            case MSG_CANCEL://取消
                childCanleCount++;
                if (childCanleCount % THREAD_COUNT != 0) return;
                resetStutus();
                mProgress = new long[THREAD_COUNT];
                mListner.onCancel();
                break;
        }
    }

    /**
     * 傳送訊息到輪迴器
     *
     * @param what
     */
    private void sendMessage(int what) {
        //傳送暫停訊息
        Message message = new Message();
        message.what = what;
        sendMessage(message);
    }


    /**
     * 關閉資源
     *
     * @param closeables
     */
    private void close(Closeable... closeables) {
        int length = closeables.length;
        try {
            for (int i = 0; i < length; i++) {
                Closeable closeable = closeables[i];
                if (null != closeable)
                    closeables[i].close();
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            for (int i = 0; i < length; i++) {
                closeables[i] = null;
            }
        }
    }

    /**
     * 暫停
     */
    public void pause() {
        pause = true;
    }

    /**
     * 取消
     */
    public void cancel() {
        cancel = true;
        cleanFile(mTmpFile);
        if (!isDownloading) {//針對非下載狀態的取消,如暫停
            if (null != mListner) {
                cleanFile(mCacheFiles);
                resetStutus();
                mListner.onCancel();
            }
        }
    }

    /**
     * 重置下載狀態
     */
    private void resetStutus() {
        pause = false;
        cancel = false;
        isDownloading = false;
    }

    /**
     * 刪除臨時檔案
     */
    private void cleanFile(File... files) {
        for (int i = 0, length = files.length; i < length; i++) {
            if (null != files[i])
                files[i].delete();
        }
    }

    /**
     * 獲取下載狀態
     * @return boolean
     */
    public boolean isDownloading() {
        return isDownloading;
    }
}

           先網路請求獲取檔案的長度mFileLength,根據長度藉助RandomAccessFile類在本地生成相同長度的佔位檔案mTmpFile,再根據執行緒數量THREAD_COUNT拆分下載任務,最後for迴圈出THREAD_COUNT數量的非同步請求下載拆分內容(位元組)並從mTmpFile的對應位置寫入mTmpFile,每個執行緒(任務)每寫入一定的資料後將任務的下載進度寫入通過RandomAccessFile生成的對應任務的記錄快取檔案中,以便於下次下載讀取該執行緒已下載的進度。註釋比較多,好像也沒啥好解釋的,有問題的朋友下方留言。

          在貼上由OkHttp簡單封裝的網路請求工具類HttpUtil的.java檔案:

package cn.icheny.download;

import java.io.IOException;
import java.util.concurrent.TimeUnit;
import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;

/**
 * Http網路工具,基於OkHttp
 * Created by Cheny on 2017/05/03.
 */

public class HttpUtil {
    private OkHttpClient mOkHttpClient;
    private static HttpUtil mInstance;
    private final static long CONNECT_TIMEOUT = 60;//超時時間,秒
    private final static long READ_TIMEOUT = 60;//讀取時間,秒
    private final static long WRITE_TIMEOUT = 60;//寫入時間,秒

    /**
     * @param url        下載連結
     * @param startIndex 下載起始位置
     * @param endIndex   結束為止
     * @param callback   回撥
     * @throws IOException
     */
    public void downloadFileByRange(String url, long startIndex, long endIndex, Callback callback) throws IOException {
        // 建立一個Request
        // 設定分段下載的頭資訊。 Range:做分段資料請求,斷點續傳指示下載的區間。格式: Range bytes=0-1024或者bytes:0-1024
        Request request = new Request.Builder().header("RANGE", "bytes=" + startIndex + "-" + endIndex)
                .url(url)
                .build();
        doAsync(request, callback);
    }

    public void getContentLength(String url, Callback callback) throws IOException {
        // 建立一個Request
        Request request = new Request.Builder()
                .url(url)
                .build();
        doAsync(request, callback);
    }

    /**
     * 同步GET請求
     */
    public void doGetSync(String url) throws IOException {
        //建立一個Request
        Request request = new Request.Builder()
                .url(url)
                .build();
        doSync(request);
    }

    /**
     * 非同步請求
     */
    private void doAsync(Request request, Callback callback) throws IOException {
        //建立請求會話
        Call call = mOkHttpClient.newCall(request);
        //同步執行會話請求
        call.enqueue(callback);
    }

    /**
     * 同步請求
     */
    private Response doSync(Request request) throws IOException {
        //建立請求會話
        Call call = mOkHttpClient.newCall(request);
        //同步執行會話請求
        return call.execute();
    }


    /**
     * @return HttpUtil例項物件
     */
    public static HttpUtil getInstance() {
        if (null == mInstance) {
            synchronized (HttpUtil.class) {
                if (null == mInstance) {
                    mInstance = new HttpUtil();
                }
            }
        }
        return mInstance;
    }

    /**
     * 構造方法,配置OkHttpClient
     */
    private HttpUtil() {
        //建立okHttpClient物件
        OkHttpClient.Builder builder = new OkHttpClient.Builder()
                .connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS)
                .writeTimeout(READ_TIMEOUT, TimeUnit.SECONDS)
                .readTimeout(WRITE_TIMEOUT, TimeUnit.SECONDS);
        mOkHttpClient = builder.build();
    }
}

           header("RANGE", "bytes=" + startIndex + "-" + endIndex),在OkHttp請求頭中新增RANGE(範圍)引數,告訴伺服器需要下載檔案內容的始末位置。鑑於OkHttp的火熱程度,好像人人都會使用OkHttp,我就不贅言了。

         為了更清晰的教程思路,這裡也貼出FilePoint.java:
package cn.icheny.download;

/**
 * 目標檔案
 * Created by Cheny on 2017/05/03.
 */

public class FilePoint {
    private String fileName;//檔名
    private String url;//檔案url
    private String filePath;//檔案下載路徑

    public FilePoint(String url) {
        this.url = url;
    }

    public FilePoint(String filePath, String url) {
        this.filePath = filePath;
        this.url = url;
    }

    public FilePoint(String url, String filePath, String fileName) {
        this.url = url;
        this.filePath = filePath;
        this.fileName = fileName;
    }

    public String getFileName() {
        return fileName;
    }

    public void setFileName(String fileName) {
        this.fileName = fileName;
    }

    public String getUrl() {
        return url;
    }

    public void setUrl(String url) {
        this.url = url;
    }

    public String getFilePath() {
        return filePath;
    }

    public void setFilePath(String filePath) {
        this.filePath = filePath;
    }

}
      下面是下載管理器DownloadManager 程式碼,統一管理所有檔案的下載任務:
package cn.icheny.download;

import android.os.Environment;
import android.text.TextUtils;
import java.io.File;
import java.util.HashMap;
import java.util.Map;

/**
 * 下載管理器,斷點續傳
 *
 * @author Cheny
 */
public class DownloadManager {

    private String DEFAULT_FILE_DIR;//預設下載目錄
    private Map<String, DownloadTask> mDownloadTasks;//檔案下載任務索引,String為url,用來唯一區別並操作下載的檔案
    private static DownloadManager mInstance;

    /**
     * 下載檔案
     */
    public void download(String... urls) {
        for (int i = 0, length = urls.length; i < length; i++) {
            String url = urls[i];
            if (mDownloadTasks.containsKey(url)) {
                mDownloadTasks.get(url).start();
            }
        }
    }

    /**
     * 通過url獲取下載檔案的名稱
     */
    public String getFileName(String url) {
        return url.substring(url.lastIndexOf("/") + 1);
    }

    /**
     * 暫停
     */
    public void pause(String... urls) {
        for (int i = 0, length = urls.length; i < length; i++) {
            String url = urls[i];
            if (mDownloadTasks.containsKey(url)) {
                mDownloadTasks.get(url).pause();
            }
        }
    }

    /**
     * 取消下載
     */
    public void cancel(String... urls) {
        for (int i = 0, length = urls.length; i < length; i++) {
            String url = urls[i];
            if (mDownloadTasks.containsKey(url)) {
                mDownloadTasks.get(url).cancel();
            }
        }
    }

    /**
     * 新增下載任務
     */
    public void add(String url, DownloadListner l) {
        add(url, null, null, l);
    }

    /**
     * 新增下載任務
     */
    public void add(String url, String filePath, DownloadListner l) {
        add(url, filePath, null, l);
    }

    /**
     * 新增下載任務
     */
    public void add(String url, String filePath, String fileName, DownloadListner l) {
        if (TextUtils.isEmpty(filePath)) {//沒有指定下載目錄,使用預設目錄
            filePath = getDefaultDirectory();
        }
        if (TextUtils.isEmpty(fileName)) {
            fileName = getFileName(url);
        }
        mDownloadTasks.put(url, new DownloadTask(new FilePoint(url, filePath, fileName), l));
    }

    /**
     * 獲取預設下載目錄
     *
     * @return
     */
    private String getDefaultDirectory() {
        if (TextUtils.isEmpty(DEFAULT_FILE_DIR)) {
            DEFAULT_FILE_DIR = Environment.getExternalStorageDirectory().getAbsolutePath()
                    + File.separator + "icheny" + File.separator;
        }
        return DEFAULT_FILE_DIR;
    }

    /**
     * 是否正在下載
     * @param urls
     * @return boolean
     */
    public boolean isDownloading(String... urls) {
        boolean result = false;
        for (int i = 0, length = urls.length; i < length; i++) {
            String url = urls[i];
            if (mDownloadTasks.containsKey(url)) {
                result = mDownloadTasks.get(url).isDownloading();
            }
        }
        return result;
    }

    public static DownloadManager getInstance() {
        if (mInstance == null) {
            synchronized (DownloadManager.class) {
                if (mInstance == null) {
                    mInstance = new DownloadManager();
                }
            }
        }
        return mInstance;
    }
    /**
     * 初始化下載管理器
     */
    private DownloadManager() {
        mDownloadTasks = new HashMap<>();
    }
}
           下載管理器通過一個Map將下載連結(url,教程圖方便使用url的方式。建議使用其他唯一標識,畢竟一般url長度都很長,會影響一定效能。另外,考慮一個專案中可能需要下載同一個檔案到不同的目錄,url做索引顯得生硬)與對應的下載任務( DownloadTask )繫結在一起,以便於根據url判斷或獲取對應的下載任務,進行下載,取消和暫停等操作。

         OK,時間關係,文章到此結束,有問題或需要Demo原始碼的朋友下方留言。半夜了。。。濃濃的倦意。。。

        2017年6月2日更新:鑑於CSDN庫無緣無故把我以前文章上傳的Demo原始碼以及庫弄沒了,決定還是傳github靠譜,下面貼上Demo原始碼地址:

臨時趕時間寫的,難免有些bug,有問題請及時下方反饋。。。