Android原生下載(上篇)基本邏輯+斷點續傳

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

零、前言

1.今天帶來的是Android原生下載的上篇,主要核心是斷點續傳,多執行緒下載將會在下篇介紹
2.本例使用了ActivityServiceBroadcastReceiver三個元件
3.本例使用了兩個執行緒:LinkURLThread做一些初始工作,DownLoadThread進行核心下載工作
4.本例使用SQLite進行暫停時的進度儲存,使用Handler進行訊息的傳遞,使用Intent進行資料傳遞
5.對著程式碼,整理了一下思路,畫了一幅下面的流程圖,感覺思路清晰多了
6.本例比較基礎,但串聯了Android的很多知識點,作為總結還是很不錯的。

2018-11-13更新:

改善了一下介面UI,整個畫風都不同了,個人感覺還不錯,用了以前的自定義進度條:詳見

效果展示.png

斷點續傳邏輯總覽

斷點續傳邏輯總覽.png


一、前置準備工作

先實現上面一半的程式碼:

初始準備.png

1.關於下載的連結:

既然是下載,當然要有連結了,就那掘金的apk來測試吧!檢視方式:

檢視下載地址.png

2.檔案資訊封裝類:FileBean
public class FileBean implements Serializable {
    private int id;//檔案id
    private String url;//檔案下載地址
    private String fileName;//檔名
    private long length;//檔案長度
    private long loadedLen;//檔案已下載長度
    
    //建構函式、get、set、toString省略...
}
複製程式碼
2.關於常量:Cons.java

無論是Intent新增的Action,還是Intent傳遞資料的標示,或Handler傳送訊息的標示
一個專案中肯定會有很多這樣的常量,如果散落各處感覺會很亂,我習慣使用一個Cons類統一處理

//intent傳遞資料----開始下載時,傳遞FileBean到Service 標示
public static final String SEND_FILE_BEAN = "send_file_bean";
//廣播更新進度
public static final String SEND_LOADED_PROGRESS = "send_loaded_length";

//下載地址
public static final String URL = "https://imtt.dd.qq.com/16891/4611E43165D203CB6A52E65759FE7641.apk?fsname=com.daimajia.gold_5.6.2_196.apk&csr=1bbd";

//檔案下載路徑
public static final String DOWNLOAD_DIR =
        Environment.getExternalStorageDirectory().getAbsolutePath() + "/b_download/";

//Handler的Message處理的常量
public static final int MSG_CREATE_FILE_OK = 0x00;
複製程式碼
2.Activity與Service的協作

介面比較簡單,就不貼了

效果.png

1).Activity中:
/**
 * 點選下載時邏輯
 */
private void start() {
    //建立FileBean物件
    FileBean fileBean = new FileBean(0, Cons.URL, "掘金.apk", 0, 0);
    Intent intent = new Intent(MainActivity.this, DownLoadService.class);
    intent.setAction(Cons.ACTION_START);
    intent.putExtra(Cons.SEND_FILE_BEAN, fileBean);//使用intent攜帶物件
    startService(intent);//開啟服務--下載標示
    mIdTvFileName.setText(fileBean.getFileName());
}
複製程式碼
/**
 * 點選停止下載邏輯
 */
private void stop() {
    Intent intent = new Intent(MainActivity.this, DownLoadService.class);
    intent.setAction(Cons.ACTION_STOP);
    startService(intent);//啟動服務---停止標示
}
複製程式碼
2).DownLoadService:下載的服務
public class DownLoadService extends Service {
    @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);
                    L.d("action_start:" + fileBean + L.l());
                    break;
                case Cons.ACTION_STOP:
                    L.d("action_stop:");
                    break;
            }
        }
        return super.onStartCommand(intent, flags, startId);
    }
    @Nullable
    @Override
    public IBinder onBind(Intent intent) {
        return null;
    }
}
複製程式碼

不要忘記註冊Service:<service android:name=".service.DownLoadService"/>
通過點選兩個按鈕,測試可以看出FileBean物件的傳遞和下載開始、停止的邏輯沒有問題

測試.png


二、下載的初始執行緒及使用:

1.LinkURLThread執行緒的實現

1).連線網路檔案
2).獲取檔案長度
3).建立等大的本地檔案:RandomAccessFile
4).從mHandler的訊息池中拿個訊息,附帶mFileBean和MSG_CREATE_FILE_OK標示傳送給mHandler

/**
 * 作者:張風捷特烈<br/>
 * 時間:2018/11/12 0012:13:42<br/>
 * 郵箱:1981462002@qq.com<br/>
 * 說明:連線url做一些準備工作:獲取檔案大小。建立資料夾及等大的檔案
 */
public class LinkURLThread extends Thread {

    private FileBean mFileBean;
    private Handler mHandler;

    public LinkURLThread(FileBean fileBean, Handler handler) {
        mFileBean = fileBean;
        mHandler = handler;
    }

    @Override
    public void run() {
        HttpURLConnection conn = null;
        RandomAccessFile raf = null;
        try {
            //1.連線網路檔案
            URL url = new URL(mFileBean.getUrl());
            conn = (HttpURLConnection) url.openConnection();
            conn.setConnectTimeout(5000);
            conn.setRequestMethod("GET");
            if (conn.getResponseCode() == 200) {
                //2.獲取檔案長度
                long len = conn.getContentLength();
                if (len > 0) {
                    File dir = new File(Cons.DOWNLOAD_DIR);
                    if (!dir.exists()) {
                        dir.mkdir();
                    }
                    //3.建立等大的本地檔案
                    File file = new File(dir, mFileBean.getFileName());
                    //建立隨機操作的檔案流物件,可讀、寫、刪除
                    raf = new RandomAccessFile(file, "rwd");
                    raf.setLength(len);//設定檔案大小
                    mFileBean.setLength(len);
                    //4.從mHandler的訊息池中拿個訊息,附帶mFileBean和MSG_CREATE_FILE_OK標示傳送給mHandler
                    mHandler.obtainMessage(Cons.MSG_CREATE_FILE_OK, mFileBean).sendToTarget();
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (conn != null) {
                conn.disconnect();
            }
            try {
                if (raf != null) {
                    raf.close();
                }
            } catch (IOException e) {
                e.printStackTrace();

            }
        }
    }
}
複製程式碼
2.在Service中的使用:DownLoadService

由於Service也是執行在主執行緒的,訪問網路的耗時操作是進位制的,所以需要新開執行緒
由於子執行緒不能更新UI,這裡使用傳統的Handler進行執行緒間通訊

/**
 * 處理訊息使用的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());
                download(fileBean);
                break;
        }
    }
};

//下載的Action時開啟執行緒:
new LinkURLThread(fileBean, mHandler).start();

複製程式碼

可見開啟執行緒後,拿到檔案大小,Handler傳送訊息到Service,再在Service(主執行緒)進行UI的顯示(吐司)

初始連線執行緒測試.png


三、資料庫相關操作:

資料庫相關.png

先說一下資料庫是幹嘛用的:記錄下載執行緒的資訊資訊資訊!
當暫停時,將當前下載的進度及執行緒資訊儲存到資料庫中,當再點選開始是從資料庫查詢執行緒資訊,恢復下載

1.執行緒資訊封裝類:ThreadBean
private int id;//執行緒id
private String url;//執行緒所下載檔案的url
private long start;//執行緒開始的下載位置(為多執行緒準備)
private long end;//執行緒結束的下載位置
private long loadedLen;//該執行緒已下載的長度

//建構函式、get、set、toString省略...
複製程式碼
2.下載的資料庫幫助類:DownLoadDBHelper

關於SQLite可詳見SI--安卓SQLite基礎使用指南:

/**
 * 作者:張風捷特烈<br/>
 * 時間:2018/11/12 0012:14:19<br/>
 * 郵箱:1981462002@qq.com<br/>
 * 說明:下載的資料庫幫助類
 */
public class DownLoadDBHelper extends SQLiteOpenHelper {

    public DownLoadDBHelper(@Nullable Context context) {
        super(context, Cons.DB_NAME, null, Cons.VERSION);
    }

    @Override
    public void onCreate(SQLiteDatabase db) {
        db.execSQL(Cons.DB_SQL_CREATE);
    }

    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
        db.execSQL(Cons.DB_SQL_DROP);
        db.execSQL(Cons.DB_SQL_CREATE);
    }
}
複製程式碼
3.關於資料庫的常量:Cons.java
/**
 * 資料庫相關常量
 */
public static final String DB_NAME = "download.db";//資料庫名
public static final int VERSION = 1;//版本
public static final String DB_TABLE_NAME = "thread_info";//資料庫名
public static final String DB_SQL_CREATE = //建立表
        "CREATE TABLE " + DB_TABLE_NAME + "(\n" +
                "_id INTEGER PRIMARY KEY AUTOINCREMENT,\n" +
                "thread_id INTEGER,\n" +
                "url TEXT,\n" +
                "start INTEGER,\n" +
                "end INTEGER,\n" +
                "loadedLen INTEGER\n" +
                ")";
public static final String DB_SQL_DROP =//刪除表表
        "DROP TABLE IF EXISTS " + DB_TABLE_NAME;
public static final String DB_SQL_INSERT =//插入
        "INSERT INTO " + DB_TABLE_NAME + " (thread_id,url,start,end,loadedLen) values(?,?,?,?,?)";
public static final String DB_SQL_DELETE =//刪除
        "DELETE FROM " + DB_TABLE_NAME + " WHERE url = ? AND thread_id = ?";
public static final String DB_SQL_UPDATE =//更新
        "UPDATE " + DB_TABLE_NAME + " SET loadedLen = ? WHERE url = ? AND thread_id = ?";
public static final String DB_SQL_FIND =//查詢
        "SELECT * FROM " + DB_TABLE_NAME + " WHERE url = ?";
public static final String DB_SQL_FIND_IS_EXISTS =//查詢是否存在
        "SELECT * FROM " + DB_TABLE_NAME + " WHERE url = ? AND thread_id = ?";
複製程式碼
4.資料訪問介面:DownLoadDao

提供資料庫操作的介面 ,至於為什麼要一個dao的介面,直接用實現類不行嗎,這裡重點說一下

介面體現的是一種能力保證,實現類的物件是具有這種能力的物件之一。
如果你非常確定這種實現不會改變(即這裡確定一種用SQLite),直接使用實現類當然可以。
不過如果你不想存入資料庫了,而是存在檔案裡或SP裡,那所有與實現類相關的部分都要修改,如果散佈各個地方,還不崩潰。
使用介面的好處在於,不管你黑貓白狗(實現方案),幫我抓住耗子(解決問題)就行了。
所以你完全可以寫一套在檔案裡儲存執行緒資訊的方案,然後實現dao裡的方法,
再只要更換程式碼中的dao實現就可以輕鬆地將黑貓(資料庫實現)切換成白狗(檔案操作實現),
當然你也可以準備一頭貓頭鷹(SP實現),或一門滅鼠大炮(網路流實現),這樣就讓下載邏輯和儲存邏輯解耦  
你想上午讓白狗(檔案操作實現)抓老鼠,下午讓白貓(資料庫實現),晚上讓貓頭鷹(SP實現),都不是問題  
這就是面相介面程式設計的好處,如果你遇到類似的情形,很多實現都各有優劣,你完全可以面相介面,後期再根據不同的需求寫實現
複製程式碼
/**
 * 作者:張風捷特烈<br/>
 * 時間:2018/11/12 0012:14:36<br/>
 * 郵箱:1981462002@qq.com<br/>
 * 說明:資料訪問介面
 */
public interface DownLoadDao {
    /**
     * 在資料庫插入執行緒資訊
     *
     * @param threadBean 執行緒資訊
     */
    void insertThread(ThreadBean threadBean);

    /**
     * 在資料庫刪除執行緒資訊
     *
     * @param url      下載的url
     * @param threadId 執行緒的id
     */
    void deleteThread(String url, int threadId);

    /**
     * 在資料庫更新執行緒資訊---下載進度
     *
     * @param url      下載的url
     * @param threadId 執行緒的id
     */
    void updateThread(String url, int threadId ,long loadedLen);

    /**
     * 獲取一個檔案下載的所有執行緒資訊(多執行緒下載)
     * @param url 下載的url
     * @return  執行緒資訊集合
     */
    List<ThreadBean> getThreads(String url);

    /**
     * 判斷資料庫中該執行緒資訊是否存在
     *
     * @param url      下載的url
     * @param threadId 執行緒的id
     */
    boolean isExist(String url, int threadId);
}

複製程式碼
5.資料庫介面實現類:DownLoadDaoImpl

一些基礎的SQL操作,個人習慣原生的SQL,在每次操作之後不要忘記關閉db,以及遊標

/**
 * 作者:張風捷特烈<br/>
 * 時間:2018/11/12 0012:14:43<br/>
 * 郵箱:1981462002@qq.com<br/>
 * 說明:資料訪問介面實現類
 */
public class DownLoadDaoImpl implements DownLoadDao {

    private DownLoadDBHelper mDBHelper;
    private Context mContext;

    public DownLoadDaoImpl(Context context) {
        mContext = context;
        mDBHelper = new DownLoadDBHelper(mContext);
    }

    @Override
    public void insertThread(ThreadBean threadBean) {
        SQLiteDatabase db = mDBHelper.getWritableDatabase();
        db.execSQL(Cons.DB_SQL_INSERT,
                new Object[]{threadBean.getId(), threadBean.getUrl(),
                        threadBean.getStart(), threadBean.getEnd(), threadBean.getLoadedLen()});
        db.close();
    }

    @Override
    public void deleteThread(String url, int threadId) {
        SQLiteDatabase db = mDBHelper.getWritableDatabase();
        db.execSQL(Cons.DB_SQL_DELETE,
                new Object[]{url, threadId});
        db.close();
    }

    @Override
    public void updateThread(String url, int threadId, long loadedLen) {
        SQLiteDatabase db = mDBHelper.getWritableDatabase();
        db.execSQL(Cons.DB_SQL_UPDATE,
                new Object[]{loadedLen, url, threadId});
        db.close();
    }

    @Override
    public List<ThreadBean> getThreads(String url) {
        SQLiteDatabase db = mDBHelper.getWritableDatabase();
        Cursor cursor = db.rawQuery(Cons.DB_SQL_FIND, new String[]{url});
        List<ThreadBean> threadBeans = new ArrayList<>();
        while (cursor.moveToNext()) {
            ThreadBean threadBean = new ThreadBean();
            threadBean.setId(cursor.getInt(cursor.getColumnIndex("thread_id")));
            threadBean.setUrl(cursor.getString(cursor.getColumnIndex("url")));
            threadBean.setStart(cursor.getLong(cursor.getColumnIndex("start")));
            threadBean.setEnd(cursor.getLong(cursor.getColumnIndex("end")));
            threadBean.setLoadedLen(cursor.getLong(cursor.getColumnIndex("loadedLen")));
            threadBeans.add(threadBean);
        }
        cursor.close();
        db.close();
        return threadBeans;
    }

    @Override
    public boolean isExist(String url, int threadId) {
        SQLiteDatabase db = mDBHelper.getWritableDatabase();
        Cursor cursor = db.rawQuery(Cons.DB_SQL_FIND_IS_EXISTS, new String[]{url, threadId + ""});
        boolean exists = cursor.moveToNext();
        cursor.close();
        db.close();

        return exists;
    }
}
複製程式碼

四、核心下載執行緒:DownLoadThread 與進度廣播:BroadcastReceiver

下載核心執行緒.png

1.下載執行緒:

注意請求中使用Range後,伺服器返回的成功狀態碼是206:不是200,表示:部分內容和範圍請求成功 註釋寫的很詳細了,就不贅述了

/**
 * 作者:張風捷特烈<br/>
 * 時間:2018/11/12 0012:15:10<br/>
 * 郵箱:1981462002@qq.com<br/>
 * 說明:下載執行緒
 */
public class DownLoadThread extends Thread {

    private ThreadBean mThreadBean;//下載執行緒的資訊
    private FileBean mFileBean;//下載檔案的資訊
    private long mLoadedLen;//已下載的長度
    public boolean isDownLoading;//是否在下載
    private DownLoadDao mDao;//資料訪問介面
    private Context mContext;//上下文

    public DownLoadThread(ThreadBean threadBean, FileBean fileBean, Context context) {
        mThreadBean = threadBean;
        mDao = new DownLoadDaoImpl(context);
        mFileBean = fileBean;
        mContext = context;
    }

    @Override
    public void run() {
        if (mThreadBean == null) {//1.下載執行緒的資訊為空,直接返回
            return;
        }
        //2.如果資料庫沒有此下載執行緒的資訊,則向資料庫插入該執行緒資訊
        if (!mDao.isExist(mThreadBean.getUrl(), mThreadBean.getId())) {
            mDao.insertThread(mThreadBean);
        }

        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.下載的核心邏輯
            Intent intent = new Intent(Cons.ACTION_UPDATE);//更新進度的廣播intent
            mLoadedLen += mThreadBean.getLoadedLen();
            //206-----部分內容和範圍請求  不要200寫順手了...
            if (conn.getResponseCode() == 206) {
                //讀取資料
                is = conn.getInputStream();
                byte[] buf = new byte[1024 * 4];
                int len = 0;
                long time = System.currentTimeMillis();
                while ((len = is.read(buf)) != -1) {
                    //寫入檔案
                    raf.write(buf, 0, len);
                    //傳送廣播給Activity,通知進度
                    mLoadedLen += len;
                    if (System.currentTimeMillis() - time > 500) {//減少UI的渲染速度
                        mContext.sendBroadcast(intent);
                        intent.putExtra(Cons.SEND_LOADED_PROGRESS,
                                (int) (mLoadedLen * 100 / mFileBean.getLength()));
                        mContext.sendBroadcast(intent);
                        time = System.currentTimeMillis();
                    }
                    //暫停儲存進度到資料庫
                    if (!isDownLoading) {
                        mDao.updateThread(mThreadBean.getUrl(), mThreadBean.getId(), mLoadedLen);
                        return;
                    }
                }
            }
            //下載完成,刪除執行緒資訊
            mDao.deleteThread(mThreadBean.getUrl(), mThreadBean.getId());
            //下載完成後,傳送完成度100%的廣播
            intent.putExtra(Cons.SEND_LOADED_PROGRESS, 100);
            mContext.sendBroadcast(intent);
        } 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();
            }
        }
    }
}

複製程式碼
3.進度廣播:BroadcastReceiver

注意這裡並非只能用BroadcastReceiver,任何執行緒間通訊都可以,只是將進度從下載執行緒拿過來而已

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

    public UpdateReceiver(ProgressBar... progressBar) {
        mProgressBar = progressBar;
    }

    @Override
    public void onReceive(Context context, Intent intent) {
        if (Cons.ACTION_UPDATE.equals(intent.getAction())) {

            int progress = intent.getIntExtra(Cons.SEND_LOADED_PROGRESS, 0);
            for (ProgressBar progressBar : mProgressBar) {
                progressBar.setProgress(progress);

            }
        }
    }
}
複製程式碼

五、將兩大部分拼合一起

1.DownLoadService:下載服務

在接收到Handler的資訊後呼叫下載函式

/**
 * 下載邏輯
 *
 * @param fileBean 檔案資訊物件
 */
public void download(FileBean fileBean) {
    //從資料獲取執行緒資訊
    List<ThreadBean> threads = mDao.getThreads(fileBean.getUrl());
    if (threads.size() == 0) {//如果沒有執行緒資訊,就新建執行緒資訊
        mThreadBean = new ThreadBean(
                0, fileBean.getUrl(), 0, fileBean.getLength(), 0);//初始化執行緒資訊物件
    } else {
        mThreadBean = threads.get(0);//否則取第一個
    }
    mDownLoadThread = new DownLoadThread(mThreadBean, fileBean, this);//建立下載執行緒
    mDownLoadThread.start();//開始執行緒
    mDownLoadThread.isDownLoading = true;
}
複製程式碼
2.開始與停止下載的優化:
@Override//每次啟動服務會走此方法
public int onStartCommand(Intent intent, int flags, int startId) {
    mDao = new DownLoadDaoImpl(this);
    if (intent.getAction() != null) {
        switch (intent.getAction()) {
            case Cons.ACTION_START:
                FileBean fileBean = (FileBean) intent.getSerializableExtra(Cons.SEND_FILE_BEAN);
                if (mDownLoadThread != null) {
                    if (mDownLoadThread.isDownLoading) {
                        return super.onStartCommand(intent, flags, startId);
                    }
                }
                new LinkURLThread(fileBean, mHandler).start();
                break;
            case Cons.ACTION_STOP:
                if (mDownLoadThread != null) {
                    mDownLoadThread.isDownLoading = false;
                }
                break;
        }
    }
    return super.onStartCommand(intent, flags, startId);
}
複製程式碼
3.Activity中註冊和登出廣播
/**
 * 註冊廣播接收者
 */
private void register() {
    //註冊廣播接收者
    mUpdateReceiver = new UpdateReceiver(mProgressBar,mIdRoundPb);
    IntentFilter filter = new IntentFilter();
    filter.addAction(Cons.ACTION_UPDATE);
    registerReceiver(mUpdateReceiver, filter);
}

@Override
protected void onDestroy() {
    super.onDestroy();
    if (mUpdateReceiver != null) {//登出廣播
        unregisterReceiver(mUpdateReceiver);
    }
}
複製程式碼

資料庫.png


下載完後,安裝正常,開啟正常,下載OK

掘金.png


後記:捷文規範

1.本文成長記錄及勘誤表
專案原始碼 日期 備註
V0.1--無 2018-11-12 Android原生下載(上篇)基本邏輯+斷點續傳
V0.1--無 2018-11-13 UI介面優化
2.更多關於我
筆名 QQ 微信 愛好
張風捷特烈 1981462002 zdl1994328 語言
我的github 我的簡書 我的CSDN 個人網站
3.宣告

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

相關文章