Android原生下載(下篇)多檔案下載+多執行緒下載

張風捷特烈發表於2018-11-14

零、前言

1.上篇實現了單執行緒的單檔案下載,本篇將講述多個檔案的多執行緒下載,在此之前希望你先弄懂上篇
2.本篇將用到上篇之外的技術:
多執行緒、執行緒池(簡)、RecyclerView、資料庫多執行緒訪問下的注意點、volatile AtomicLong(簡)

最終靜態的效果

最終效果.png

最終動態的效果

動態效果圖.gif


一、分析一下多執行緒下載單個檔案的原理:

1.執行緒分工方式
大家都知道,一個檔案是很多的位元組組成的,位元組又是由二進位制的位組成,如果把一個位元組當成一塊磚。
那下載就像把伺服器的磚頭搬到手機裡,然後擺在一個檔案裡擺好,搬完了,檔案滿了,任務就完成了
然後檔案是電影就能播,是圖片就能看,app就能安裝。

對於下載一個檔案,上篇講的單執行緒下載相當於一個人一塊一塊地搬。
而本篇的多執行緒則是僱幾個人來搬,可想而知效率是更高的。
那我開一千個執行緒豈不是秒下?如果你要搬1000塊磚,找1000個人,效率固然高,
但人家也不是白乾活,相對於3個人搬,你要多付333倍的工資,也就是開執行緒要消耗的,適量即可。
複製程式碼

一個位元組的丟失就可能導致一個檔案的損壞,可想而知要多個人一起幹活必須分工明確
不然一塊磚搬錯了,整個檔案就報廢了,下面看一下執行緒怎麼分工,拿3個執行緒下載1000位元組來說:

多執行緒下載分析.png

2.多執行緒下載的流程圖

整體架構和單執行緒的下載類似,最大的改變的是:

由於多執行緒需要管理,使用一個DownLoadTask來管理一個檔案的所有下載執行緒,其中封裝了下載和暫停邏輯。  
在DownLoadTask#download方法裡,如果資料庫沒有資訊,則進行執行緒的任務分配及執行緒資訊的建立,並插入資料庫。
DownLoadThread作為DownLoadTask的內部類,方便使用。最後在download方法一一建立DownLoadThread並開啟,
將DownLoadThread存入集合管理,在DownLoadTask#pause方法裡,將集合中的執行緒全部關閉即可
複製程式碼

多執行緒下載流程圖.png


二、程式碼實現:

1.RecyclerView的使用:

用RecyclerView將單個條目便成一個列表介面

1).增加URL常量
    //掘金下載地址
    public static final String URL_JUEJIN = "https://imtt.dd.qq.com/16891/4611E43165D203CB6A52E65759FE7641.apk?fsname=com.daimajia.gold_5.6.2_196.apk&csr=1bbd";
    //qq下載地址
    public static final String URL_QQ = "https://qd.myapp.com/myapp/qqteam/Androidlite/qqlite_3.7.1.704_android_r110206_GuanWang_537057973_release_10000484.apk";
    //有道雲筆記下載地址
    public static final String URL_YOUDAO = "http://codown.youdao.com/note/youdaonote_android_6.3.5_youdaoweb.apk";
    //微信下載地址
    public static final String URL_WEIXIN = "http://gdown.baidu.com/data/wisegame/3d4de3ae1d2dc7d5/weixin_1360.apk";
    //有道詞典下載地址
    public static final String URL_YOUDAO_CIDIAN = "http://codown.youdao.com/dictmobile/youdaodict_android_youdaoweb.apk";
複製程式碼
2).初始化資料
/**
 * 初始化資料
 *
 * @return
 */
@NonNull
private ArrayList<FileBean> initData() {
    FileBean juejin = new FileBean(0, Cons.URL_JUEJIN, "掘金.apk", 0, 0);
    FileBean yunbiji = new FileBean(1, Cons.URL_YOUDAO, "有道雲筆記.apk", 0, 0);
    FileBean qq = new FileBean(2, Cons.URL_QQ, "QQ.apk", 0, 0);
    FileBean weiChat = new FileBean(3, Cons.URL_WEIXIN, "微信.apk", 0, 0);
    FileBean cidian = new FileBean(4, Cons.URL_YOUDAO_CIDIAN, "有道詞典.apk", 0, 0);
    ArrayList<FileBean> fileBeans = new ArrayList<>();
    fileBeans.add(juejin);
    fileBeans.add(yunbiji);
    fileBeans.add(qq);
    fileBeans.add(weiChat);
    fileBeans.add(cidian);
    return fileBeans;
}
複製程式碼
3).RecyclerView介面卡

上篇在Activity中的按鈕中實現的下載和暫停intent,這裡放在RVAdapter裡

/**
 * 作者:張風捷特烈<br/>
 * 時間:2018/11/13 0013:11:58<br/>
 * 郵箱:1981462002@qq.com<br/>
 * 說明:RecyclerView介面卡
 */
public class RVAdapter extends RecyclerView.Adapter<MyViewHolder> {

    private Context mContext;
    private List<FileBean> mData;

    public RVAdapter(Context context, List<FileBean> data) {
        mContext = context;
        mData = data;
    }

    @NonNull
    @Override
    public MyViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        View view = LayoutInflater.from(mContext).inflate(R.layout.item_pb, parent, false);
        view.setOnClickListener(v -> {
            //TODO 點選條目
        });
        return new MyViewHolder(view);
    }

    @Override
    public void onBindViewHolder(@NonNull MyViewHolder holder, int position) {
        FileBean fileBean = mData.get(position);
        holder.mBtnStart.setOnAlphaListener(v -> {
            ToastUtil.showAtOnce(mContext, "開始下載: " + fileBean.getFileName());
            Intent intent = new Intent(mContext, DownLoadService.class);
            intent.setAction(Cons.ACTION_START);
            intent.putExtra(Cons.SEND_FILE_BEAN, fileBean);//使用intent攜帶物件
            mContext.startService(intent);//開啟服務--下載標示
        });
        holder.mBtnStop.setOnAlphaListener(v -> {
            Intent intent = new Intent(mContext, DownLoadService.class);
            intent.setAction(Cons.ACTION_STOP);
            intent.putExtra(Cons.SEND_FILE_BEAN, fileBean);//使用intent攜帶物件
            mContext.startService(intent);//啟動服務---停止標示
            ToastUtil.showAtOnce(mContext, "停止下載: " + fileBean.getFileName());
        });
        holder.mTVFileName.setText(fileBean.getFileName());
        holder.mPBH.setProgress((int) fileBean.getLoadedLen());
        holder.mPBV.setProgress((int) fileBean.getLoadedLen());

    }

    @Override
    public int getItemCount() {
        return mData.size();
    }

    /**
     * 更新進度
     * @param id 待更新的檔案id
     * @param progress 進度數
     */
    public void updateProgress(int id, int progress) {
        mData.get(id).setLoadedLen(progress);
        notifyDataSetChanged();//通知資料修改
    }
}

/**
 * ViewHolder
 */
class MyViewHolder extends RecyclerView.ViewHolder {
    public ProgressBar mPBH;
    public ProgressBar mPBV;
    public AlphaImageView mBtnStart;
    public AlphaImageView mBtnStop;
    public TextView mTVFileName;
    
    public MyViewHolder(View itemView) {
        super(itemView);
        mPBH = itemView.findViewById(R.id.id_pb_h);
        mPBV = itemView.findViewById(R.id.id_pb_v);
        mBtnStart = itemView.findViewById(R.id.id_btn_start);
        mBtnStop = itemView.findViewById(R.id.id_btn_stop);
        mTVFileName = itemView.findViewById(R.id.id_tv_file_name);
    }
}
複製程式碼
4).設定介面卡:MainActivity中
mAdapter = new RVAdapter(this, fileBeans);
mIdRvPage.setAdapter(mAdapter);
mIdRvPage.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false));
複製程式碼

2.DownLoadTask的分析:

DownLoadTask最重要的在於:管理一個檔案下載的所有執行緒,download是暴漏出的下載方法。pause停止。
比如開三個執行緒,該類的mDownLoadThreads就將執行緒存到集合裡,以便使用
DownLoadThread 和上篇核心邏輯基本一至,這裡作為DownLoadTask內部類,方便使用其中的變數
還有就是由於是多執行緒,每個執行的快慢不定,判斷結束的標識必須三個執行緒都結束才代表下載結束
另外使用Timer定時器來傳送進度,在DownLoadThread傳送會導致幾個執行緒中的進度不統一,影響視覺

  • 三個執行緒共同工作

三個執行緒共同工作.png

  • 暫停時資料庫情況

暫停時資料庫情況.png

/**
 * 作者:張風捷特烈<br/>
 * 時間:2018/11/13 0013:15:21<br/>
 * 郵箱:1981462002@qq.com<br/>
 * 說明:下載一個檔案的任務(mDownLoadThreads儲存該檔案任務的所有執行緒)
 */
public class DownLoadTask {
    private FileBean mFileBean;//下載檔案的資訊
    private DownLoadDao mDao;//資料訪問介面
    private Context mContext;//上下文
    private int mThreadCount;//執行緒數量
    public boolean isDownLoading;//是否正在下載
    private Timer mTimer;//定時器


    private List<DownLoadThread> mDownLoadThreads;//該檔案所有執行緒的集合
    //已下載的長度:共享變數----使用volatile和Atomic進行同步
    private volatile AtomicLong mLoadedLen = new AtomicLong();
    //使用執行緒池
    public static ExecutorService sExe = Executors.newCachedThreadPool();


    public DownLoadTask(FileBean fileBean, Context context, int threadCount) {
        mFileBean = fileBean;
        mContext = context;
        mThreadCount = threadCount;
        mDao = new DownLoadDaoImpl(context);
        mDownLoadThreads = new ArrayList<>();
        mTimer = new Timer();
    }

    /**
     * 下載邏輯
     */
    public void download() {
        //從資料獲取執行緒資訊
        List<ThreadBean> threads = mDao.getThreads(mFileBean.getUrl());
        if (threads.size() == 0) {//如果沒有執行緒資訊,就新建執行緒資訊
            //------獲取每個程式下載長度
            long len = mFileBean.getLength() / mThreadCount;
            for (int i = 0; i < mThreadCount; i++) {
                //建立threadCount個執行緒資訊
                ThreadBean threadBean = null;
                if (i != mThreadCount - 1) {
                    threadBean = new ThreadBean(
                            i, mFileBean.getUrl(), len * i, (i + 1) * len - 1, 0);
                } else {
                    threadBean = new ThreadBean(
                            i, mFileBean.getUrl(), len * i, mFileBean.getLength(), 0);
                }
                //建立後新增到執行緒集合中
                threads.add(threadBean);
                //2.如果資料庫沒有此下載執行緒的資訊,則向資料庫插入該執行緒資訊
                mDao.insertThread(threadBean);
            }
        }

        //啟動多個執行緒
        for (ThreadBean info : threads) {
            DownLoadThread thread = new DownLoadThread(info);//建立下載執行緒
            sExe.execute(thread);//開始執行緒
            thread.isDownLoading = true;
            isDownLoading = true;
            mDownLoadThreads.add(thread);//開始下載時將該執行緒加入集合
        }

        mTimer.schedule(new TimerTask() {//啟動定時器傳送廣播
            @Override
            public void run() {
                Intent intent = new Intent(Cons.ACTION_UPDATE);//更新進度的廣播intent
                mContext.sendBroadcast(intent);
                intent.putExtra(Cons.SEND_LOADED_PROGRESS,
                        (int) (mLoadedLen.get() * 100 / mFileBean.getLength()));
                intent.putExtra(Cons.SEND_FILE_ID, mFileBean.getId());
                mContext.sendBroadcast(intent);
            }
        }, 1000, 1000);
    }

    public void pause() {
        for (DownLoadThread downLoadThread : mDownLoadThreads) {
            downLoadThread.isDownLoading = false;
            isDownLoading = false;

        }
    }

    /**
     * 下載的核心執行緒類
     */
    public class DownLoadThread extends Thread {
        private ThreadBean mThreadBean;//下載執行緒的資訊
        public boolean isDownLoading;//是否在下載

        public DownLoadThread(ThreadBean threadBean) {
            mThreadBean = threadBean;
        }

        @Override
        public void run() {
            if (mThreadBean == null) {//1.下載執行緒的資訊為空,直接返回
                return;
            }
            HttpURLConnection conn = null;
            RandomAccessFile raf = null;
            InputStream is = null;
            try {
                //3.連線執行緒的url
                URL url = new URL(mThreadBean.getUrl());
                conn = (HttpURLConnection) url.openConnection();
                conn.setConnectTimeout(5000);
                conn.setRequestMethod("GET");
                //4.設定下載位置
                long start = mThreadBean.getStart() + mThreadBean.getLoadedLen();//開始位置
                //conn設定屬性,標記資源的位置(這是給伺服器看的)
                conn.setRequestProperty("Range", "bytes=" + start + "-" + mThreadBean.getEnd());
                //5.尋找檔案的寫入位置
                File file = new File(Cons.DOWNLOAD_DIR, mFileBean.getFileName());
                //建立隨機操作的檔案流物件,可讀、寫、刪除
                raf = new RandomAccessFile(file, "rwd");
                raf.seek(start);//設定檔案寫入位置
                //6.下載的核心邏輯
                mLoadedLen.set(mLoadedLen.get() + mThreadBean.getLoadedLen());
                //206-----部分內容和範圍請求  不要200寫順手了...
                if (conn.getResponseCode() == 206) {
                    //讀取資料
                    is = conn.getInputStream();
                    byte[] buf = new byte[1024 * 4];
                    int len = 0;
                    while ((len = is.read(buf)) != -1) {
                        //寫入檔案
                        raf.write(buf, 0, len);
                        //傳送廣播給Activity,通知進度
                        mLoadedLen.set(mLoadedLen.get() + len);//累加整個檔案的完成進度
                        //累加每個執行緒完成的進度
                        mThreadBean.setLoadedLen(mThreadBean.getLoadedLen() + len);
                        //暫停儲存進度到資料庫
                        if (!this.isDownLoading) {
                            mDao.updateThread(mThreadBean.getUrl(), mThreadBean.getId(),
                                    mThreadBean.getLoadedLen());
                            return;
                        }
                    }
                }
                //是否所有執行緒都已經下載完成
                isDownLoading = false;
                checkIsAllOK();
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                if (conn != null) {
                    conn.disconnect();
                }
                try {
                    if (raf != null) {
                        raf.close();
                    }
                    if (is != null) {
                        is.close();
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }

        /**
         * 檢查是否所有執行緒都已經完成了
         */
        private synchronized void checkIsAllOK() {
            boolean allFinished = true;
            for (DownLoadThread downLoadThread : mDownLoadThreads) {
                if (downLoadThread.isDownLoading) {
                    allFinished = false;
                    break;
                }
            }
            if (allFinished) {
                mTimer.cancel();//下載完成,取消定時器
                //下載完成,刪除執行緒資訊
                mDao.deleteThread(mThreadBean.getUrl());
                //通知下載結束
                Intent intent = new Intent();
                intent.setAction(Cons.ACTION_FINISH);//加完成的Action
                intent.putExtra(Cons.SEND_FILE_BEAN, mFileBean);
                mContext.sendBroadcast(intent);
            }
        }
    }
}
複製程式碼

3.Service 的修改

稍微不同的就是一個下載任務變成了多個下載任務,這裡使用安卓特有的SparseArray來儲存

/**
 * 作者:張風捷特烈<br/>
 * 時間:2018/11/12 0012:12:23<br/>
 * 郵箱:1981462002@qq.com<br/>
 * 說明:下載的服務
 */
public class DownLoadService extends Service {
    //由於多檔案,維護一個Task集合:使用SparseArray儲存int型的鍵---的鍵值對
    private SparseArray<DownLoadTask> mTaskMap = new SparseArray<>();
    /**
     * 處理訊息使用的Handler
     */
    private Handler mHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            switch (msg.what) {
                case Cons.MSG_CREATE_FILE_OK:
                    FileBean fileBean = (FileBean) msg.obj;
                    //已在主執行緒,可更新UI
                    ToastUtil.showAtOnce(DownLoadService.this, "檔案長度:" + fileBean.getLength());
                    DownLoadTask task = new DownLoadTask(fileBean, DownLoadService.this, 3);
                    task.download();
                    mTaskMap.put(fileBean.getId(), task);
                    break;
            }
        }
    };


    @Override//每次啟動服務會走此方法
    public int onStartCommand(Intent intent, int flags, int startId) {
        if (intent.getAction() != null) {
            switch (intent.getAction()) {
                case Cons.ACTION_START:
                    FileBean fileBean = (FileBean) intent.getSerializableExtra(Cons.SEND_FILE_BEAN);
                    DownLoadTask start = mTaskMap.get(fileBean.getId());
                    if (start != null) {
                        if (start.isDownLoading) {
                            return super.onStartCommand(intent, flags, startId);
                        }
                    }
                    DownLoadTask.sExe.execute(new LinkURLThread(fileBean, mHandler));
                    break;
                case Cons.ACTION_STOP:
                    FileBean stopFile = (FileBean) intent.getSerializableExtra(Cons.SEND_FILE_BEAN);
                    //獲取停止的下載執行緒
                    DownLoadTask task = mTaskMap.get(stopFile.getId());
                    if (task != null) {
                        task.pause();
                    }
                    break;
            }
        }
        return super.onStartCommand(intent, flags, startId);
    }

    @Nullable
    @Override
    public IBinder onBind(Intent intent) {
        return null;
    }
}

複製程式碼
4.廣播的處理:

這裡多了一個下載完成的Action,並且由MainActivity傳入進度條,改為mAdapter.updateProgress重新整理檢視

/**
 * 作者:張風捷特烈<br/>
 * 時間:2018/11/12 0012:16:05<br/>
 * 郵箱:1981462002@qq.com<br/>
 * 說明:更新ui的廣播接收者
 */
public class UpdateReceiver extends BroadcastReceiver {

    private RVAdapter mAdapter;

    public UpdateReceiver(RVAdapter adapter) {
        mAdapter = adapter;
    }
    @Override
    public void onReceive(Context context, Intent intent) {
        if (Cons.ACTION_UPDATE.equals(intent.getAction())) {//進度更新
            int loadedProgress = intent.getIntExtra(Cons.SEND_LOADED_PROGRESS, 0);
            int id = intent.getIntExtra(Cons.SEND_FILE_ID, 0);
            mAdapter.updateProgress(id, loadedProgress);
        } else if (Cons.ACTION_FINISH.equals(intent.getAction())) {//下載結束
            FileBean fileBean = (FileBean) intent.getSerializableExtra(Cons.SEND_FILE_BEAN);
            mAdapter.updateProgress(fileBean.getId(), 0);
            ToastUtil.showAtOnce(context, "文佳下載完成:" + fileBean.getFileName());
        }
    }
}
複製程式碼

三、資料庫的多執行緒操作注意點:

1.DownLoadDBHelper的單例

為了避免不同執行緒拿到的DownLoadDBHelper物件不同,這裡使用單例模式

    private static DownLoadDBHelper sDownLoadDBHelper;

    public static DownLoadDBHelper newInstance(Context context) {
        if (sDownLoadDBHelper == null) {
            synchronized (DownLoadDBHelper.class) {
                if (sDownLoadDBHelper == null) {
                    sDownLoadDBHelper = new DownLoadDBHelper(context);
                }
            }
        }
        return sDownLoadDBHelper;
    }
複製程式碼
2.在變動資料庫的方法上加同步:db.DownLoadDaoImpl

避免多個執行緒修改資料庫產生衝突

 public synchronized void insertThread(ThreadBean threadBean)
 public synchronized void deleteThread(String url)
 public synchronized void updateThread(String url, int threadId, long loadedLen)
複製程式碼

你看完上下兩篇,基本上就能夠實現這樣的效果了: 回過頭來看一看,也並非難到無法承受的地步,多想想,思路貫通之後還是很好理解的。

動態效果圖.gif


後記:捷文規範

1.本文成長記錄及勘誤表
專案原始碼 日期 備註
V0.1--無 2018-11-13 Android原生下載(下篇)多檔案下載+多執行緒下載
2.更多關於我
筆名 QQ 微信 愛好
張風捷特烈 1981462002 zdl1994328 語言
我的github 我的簡書 我的CSDN 個人網站
3.宣告

1----本文由張風捷特烈原創,轉載請註明
2----歡迎廣大程式設計愛好者共同交流
3----個人能力有限,如有不正之處歡迎大家批評指證,必定虛心改正
4----看到這裡,我在此感謝你的喜歡與支援

相關文章