前段時(shí)間面試楞抡,被問(wèn)到 app 的自動(dòng)更新是怎么做的伟众,文件下載怎么實(shí)現(xiàn)的?用了多線程嗎召廷?是否支持?jǐn)帱c(diǎn)續(xù)傳凳厢?一下蒙逼,因?yàn)橹苯佑玫谌娇蚣軐?shí)現(xiàn)的文件下載竞慢,這些問(wèn)題完全沒(méi)想過(guò)先紫。
回來(lái)后覺(jué)得這里面其實(shí)涉及很多知識(shí)點(diǎn),就打算自己動(dòng)手封裝一個(gè)支持多線程多任務(wù)斷點(diǎn)續(xù)傳的庫(kù)梗顺,用了一個(gè)星期的業(yè)余時(shí)間泡孩,目前主要功能基本完成,所以記錄一下這個(gè)過(guò)程中遇到的問(wèn)題和收獲
1. 涉及知識(shí)點(diǎn)
聽(tīng)起來(lái)并不復(fù)雜的一個(gè)功能寺谤,但是實(shí)際動(dòng)手做起來(lái)發(fā)現(xiàn)還是涉及了很多知識(shí)點(diǎn)的仑鸥,概括一下主要涉及以下幾個(gè)部分:
- HTTP 請(qǐng)求:為了盡量的減少對(duì)第三方框架的依賴,這個(gè)庫(kù)里的 HTTP 請(qǐng)求部分就直接用
HttpURLConnection
來(lái)實(shí)現(xiàn) - 斷點(diǎn)續(xù)傳:主要借助
RandomAccessFile
這個(gè)類來(lái)實(shí)現(xiàn)变屁,這個(gè)類可以從文件的任意指定位置開(kāi)始進(jìn)行讀寫(xiě) - 多線程下載:涉及到多個(gè)子線程的同步眼俊,中斷異常的處理,線程池的使用等
- 多任務(wù)下載:這里涉及到任務(wù)的調(diào)度和同步粟关,比如限制同時(shí)下載的任務(wù)數(shù)疮胖,達(dá)到這個(gè)上限以后,再添加任務(wù)要等待闷板,如何處理澎灸。暫停或者取消一個(gè)任務(wù)后如何自動(dòng)啟動(dòng)等待的任務(wù)等遮晚。這里使用了阻塞隊(duì)列和信號(hào)量來(lái)實(shí)現(xiàn)任務(wù)的調(diào)度
- 事件的發(fā)布:可以用廣播性昭, EventBus,Handler县遣,回調(diào)
- 數(shù)據(jù)的持久化:退出程序后糜颠,保存下載任務(wù)的狀態(tài),再次打開(kāi)后加載所有的任務(wù)萧求,并能夠繼續(xù)下載其兴。這里可以用數(shù)據(jù)庫(kù),臨時(shí)文件 或者
SharedPreference
實(shí)現(xiàn)夸政。
2. 整體設(shè)計(jì)
1. 下載請(qǐng)求的封裝
用一個(gè) JavaBean 類封裝一個(gè)下載請(qǐng)求元旬,包括了下載的 url 地址,文件保存的目錄以及文件名,基本的參數(shù)其實(shí)就這三個(gè)法绵,還可以根據(jù)需要添加更多的配置參數(shù)箕速,比如指定并發(fā)的線程數(shù)等。如果參數(shù)比較多的情況朋譬,可以使用 Builder 模式
2. 任務(wù)的調(diào)度
回想我們使用迅雷下載時(shí)盐茎,填入下載鏈接,選好保存路徑之后點(diǎn)擊開(kāi)始徙赢,任務(wù)就自動(dòng)開(kāi)始下載了字柠。如果此時(shí)任務(wù)數(shù)已經(jīng)達(dá)到上限,那么就會(huì)等待狡赐,直到有任務(wù)結(jié)束窑业,再自動(dòng)開(kāi)始等待的任務(wù)。仔細(xì)思考之后枕屉,有以下幾個(gè)要點(diǎn):
- 因?yàn)樘砑尤蝿?wù)是在主線程進(jìn)行常柄,所以應(yīng)該是非阻塞的,任何時(shí)候執(zhí)行添加一個(gè)任務(wù)都應(yīng)該立即返回搀擂。所以這里考慮使用一個(gè)無(wú)上限的阻塞隊(duì)列 LinkedBlockingQueue
- 任務(wù)一旦添加就自動(dòng)開(kāi)始西潘,這里參考了 Volley 的 NetworkDispatcher 的設(shè)計(jì),開(kāi)啟一個(gè)專門(mén)的任務(wù)調(diào)度線程哨颂,用一個(gè)死循環(huán)不斷的從阻塞隊(duì)列取出任務(wù)來(lái)執(zhí)行喷市,當(dāng)隊(duì)列為空時(shí)就阻塞,非空時(shí)就被喚醒并執(zhí)行任務(wù)威恼。其實(shí)就是一個(gè)典型的生產(chǎn)-消費(fèi)模型
- 達(dá)到最大任務(wù)數(shù)后要等待有任務(wù)停止(包括成功品姓,失敗,暫停箫措,取消幾種情況)才能開(kāi)始腹备,這里很自然的想到用 Semaphore 來(lái)實(shí)現(xiàn),當(dāng)從阻塞隊(duì)列取出一個(gè)任務(wù)后斤蔓,還需要先成功獲取一個(gè)信號(hào)量馏谨,才能繼續(xù)開(kāi)始執(zhí)行,否則就阻塞附迷。當(dāng)任務(wù)停止的時(shí)候,釋放一個(gè)信號(hào)量哎媚,之前等待的任務(wù)就可以自動(dòng)開(kāi)始執(zhí)行了喇伯。
當(dāng)然這里也可以不用信號(hào)量,通過(guò)一個(gè)計(jì)數(shù)器加一個(gè)等待隊(duì)列實(shí)現(xiàn)調(diào)度拨与。一個(gè)任務(wù)結(jié)束后稻据,需要檢查當(dāng)前正在下載的任務(wù)數(shù)寇窑,以及是否有任務(wù)在等待隊(duì)列渡冻,如果有并且計(jì)數(shù)器值小于上限值,就從等待隊(duì)列取出一個(gè)任務(wù)執(zhí)行。個(gè)人感覺(jué)使用信號(hào)量在概念上更加清晰栽惶。
3. 下載任務(wù)的執(zhí)行
一個(gè)支持多線程斷點(diǎn)續(xù)傳的任務(wù)開(kāi)始后,其實(shí)是分成了串行的兩步執(zhí)行的:
(1) 發(fā)起一次 HTTP 請(qǐng)求漫蛔,獲取下載文件的長(zhǎng)度信息
(2) 根據(jù)文件的長(zhǎng)度以及設(shè)置的線程數(shù)N抛蚤,把下載任務(wù)分成N個(gè)子任務(wù),每個(gè)子任務(wù)再分別發(fā)起HTTP請(qǐng)求姓言,負(fù)責(zé)下載自己那一部分的數(shù)據(jù)并寫(xiě)入同一個(gè)文件中(RandomAccessFile
已經(jīng)處理了同步問(wèn)題)瞬项。
所以這里我的設(shè)計(jì)是先使用一個(gè) AsyncTask
獲取文件長(zhǎng)度,再異步的回調(diào)里何荚,開(kāi)啟N個(gè)子任務(wù)線程進(jìn)行下載囱淋。
這里當(dāng)然是使用線程池來(lái)執(zhí)行子任務(wù)了,子任務(wù)都實(shí)現(xiàn) Runnable
丟到線程池里餐塘。另外由于 AsyncTask
默認(rèn)的實(shí)現(xiàn)是串行的妥衣,也可以讓 AsyncTask
在默認(rèn)的線程池上執(zhí)行,這樣就可以實(shí)現(xiàn)多個(gè)任務(wù)同時(shí)開(kāi)始下載了戒傻。
4. 下載任務(wù)的封裝和管理
首先要用一個(gè)類來(lái)描述一個(gè)下載任務(wù)税手,這個(gè)類的設(shè)計(jì)要考慮以下幾點(diǎn):
- 每一個(gè)下載任務(wù)和一個(gè)下載請(qǐng)求一一對(duì)應(yīng),所以下載任務(wù)中應(yīng)該包含一個(gè)下載請(qǐng)求的字段
- 每個(gè)任務(wù)需要一個(gè)唯一的ID稠鼻,這里考慮使用url+保存路徑+文件名的字符串進(jìn)行MD5運(yùn)算冈止,來(lái)作為一個(gè)任務(wù)的ID
- 需要記錄下載文件的大小
- 需要一個(gè)字段標(biāo)示當(dāng)前任務(wù)所處的狀態(tài),比如正在下載候齿,暫停熙暴,失敗等,操作該字段需要同步
- 需要一個(gè)字段標(biāo)示當(dāng)前任務(wù)已經(jīng)下載的字節(jié)數(shù)慌盯,操作該字段也需要同步
- 需要一個(gè)List字段保存已經(jīng)開(kāi)始下載的任務(wù)的子任務(wù)的信息周霉,每個(gè)子任務(wù)中保存當(dāng)前寫(xiě)入文件的位置以及結(jié)束寫(xiě)入的位置
然后就是需要一個(gè)集合來(lái)保存所有的已添加的任務(wù),因?yàn)楦鞣N對(duì)任務(wù)的操作亚皂,比如暫停俱箱,取消,刪除等都是要根據(jù)ID來(lái)找到一個(gè)對(duì)應(yīng)任務(wù)灭必,所以使用Map來(lái)保存可以保證查找的效率狞谱。
5. 事件發(fā)布設(shè)計(jì)
所有的事件都通過(guò) LocalBroadcastManager
發(fā)布,然后使用者可以有兩種方法實(shí)現(xiàn)對(duì)事件的監(jiān)聽(tīng)禁漓,一種是定義自己的 Receiver 接收處理各種廣播事件跟衅。還有一種是注冊(cè) Listener,然后我們?cè)诳蚣軆?nèi)部實(shí)現(xiàn)一個(gè) BroadcastReceiver
播歼,根據(jù)不同的事件調(diào)用 Listener 的不同的方法伶跷,這樣封裝的更好,不過(guò)某些場(chǎng)景自己注冊(cè)Receiver還是更靈活一些,可以在 switch 里面對(duì)多個(gè) case 合并處理
6. 任務(wù)狀態(tài)的切換
最主要的部分就是如何暫桶饶或者取消一個(gè)正在進(jìn)行的任務(wù)蹈集。在下載的子任務(wù)線程里,會(huì)有一個(gè)循環(huán)從InputStream讀取數(shù)據(jù)并寫(xiě)入文件的操作雇初,我們就在這個(gè)循環(huán)這里加入對(duì)任務(wù)狀態(tài)的判斷拢肆,當(dāng)狀態(tài)是Downloading時(shí),就繼續(xù)下載抵皱,當(dāng)狀態(tài)被設(shè)為 Paused 時(shí)善榛,就跳出循環(huán),這樣就實(shí)現(xiàn)了任務(wù)的暫停呻畸。
當(dāng)然也可以用 FutureTask.cancel()
移盆,在循環(huán)里判斷 isInterrupted()
來(lái)實(shí)現(xiàn),不過(guò)因?yàn)槲覀円呀?jīng)有了一個(gè)表示任務(wù)狀態(tài)的字段伤为,直接使用這個(gè)字段可以達(dá)到同樣的效果咒循。
當(dāng)恢復(fù)一個(gè)暫停的任務(wù)時(shí),不能讓它直接開(kāi)始绞愚,要把重新加到任務(wù)隊(duì)列里面去叙甸,然后等待調(diào)度。因?yàn)榭赡芤呀?jīng)達(dá)到任務(wù)上限位衩,所以還是要重新拿到信號(hào)量才可以開(kāi)始裆蒸。
7. 任務(wù)的持久化
不考慮大量任務(wù)管理的場(chǎng)景的話,可以直接用 SharedPreference
配合 Gson 的序列化和反序列化糖驴,實(shí)現(xiàn)任務(wù)的持久化僚祷。用數(shù)據(jù)庫(kù)的話就麻煩一點(diǎn),要自己讀寫(xiě)各個(gè)字段贮缕,當(dāng)然也可以用 GreenDao辙谜,Realm 等orm框架,不過(guò)作為一個(gè)實(shí)驗(yàn)性項(xiàng)目感昼,這塊暫時(shí)先不做那么復(fù)雜吧装哆。
3. 總結(jié)
初步的分析結(jié)束,整體的思路已經(jīng)清楚了定嗓,主要的難點(diǎn)應(yīng)該是在任務(wù)的調(diào)度蜕琴,多線程的協(xié)作和同步。最后從用戶的角度總結(jié)一下最終要實(shí)現(xiàn)的功能:
- 定義一個(gè)下載請(qǐng)求并加入下載隊(duì)列宵溅,獲得任務(wù)的ID以便后續(xù)的操作奸绷。任務(wù)自動(dòng)開(kāi)始下載,如果達(dá)到上限就等待
- 通過(guò)任務(wù)ID可以暫停层玲,取消,恢復(fù)一個(gè)任務(wù)的執(zhí)行
- 任何情況下退出都應(yīng)該能保存任務(wù)的下載狀態(tài)
下一篇就寫(xiě)具體的代碼實(shí)現(xiàn)。