完成開發(fā)任務(wù)的同時(shí)兔簇,我們總希望自己能夠交付高質(zhì)量的代碼。代碼質(zhì)量的測度有很多方法硬耍,可擴(kuò)展性垄琐、可復(fù)用性是其中的兩項(xiàng)指標(biāo)。設(shè)計(jì)模式的理論能夠非常有效地指導(dǎo)代碼設(shè)計(jì)经柴,但是光談這些理論是非常抽象的狸窘,本文針對下載這個場景,結(jié)合設(shè)計(jì)模式的一些理論坯认,談一談如何設(shè)計(jì)一個結(jié)構(gòu)較為合理的下載模塊翻擒。
一、明確需求
在著手編碼之前牛哺,先明確功能需求韭寸、技術(shù)需求,然后進(jìn)行初步的思考荆隘。
從目標(biāo)出發(fā)
從目標(biāo)出發(fā)恩伺,能夠幫助明確設(shè)計(jì)過程中的側(cè)重點(diǎn)。對于下載這個場景椰拒,很直觀可以想到晶渠,它涉及到的文件操作、持久化存儲等步驟是會頻繁出現(xiàn)在一個項(xiàng)目中的燃观。所以我會希望為下載模塊寫的大量代碼能夠被良好復(fù)用褒脯。同時(shí)可以預(yù)見,下載這一場景是非常容易出現(xiàn)后續(xù)需求變更或者增加的缆毁,沒準(zhǔn)今天只下載視頻番川,明天又需要添加對音頻、對 zip 文件的支持;對于數(shù)據(jù)庫存儲框架颁督,可能目前在使用 FMDB
践啄,后續(xù)又要更換為 WCDB
。所以沉御,也對這個模塊的可擴(kuò)展性屿讽、易修改性提出了要求。
結(jié)合一點(diǎn)點(diǎn)理論
設(shè)計(jì)模式中有幾大原則吠裆,剛開始接觸我們總感到難以把握伐谈。因?yàn)樗鼈兒喍痰孟駧鬃终嫜裕鴮?shí)際的場景卻有千千萬萬種试疙。那么诵棵,就從最易理解的“單一職責(zé)原則”開始。簡單來說祝旷,一個單獨(dú)的模塊應(yīng)該只負(fù)責(zé)一個單獨(dú)的任務(wù)履澳,任務(wù)的粒度越細(xì),它和其他模塊的耦合性越低缓屠,它也越容易被復(fù)用。而遵循“依賴倒置原則”护侮,則會有效提高代碼的易修改性敌完。比如對于數(shù)據(jù)庫模塊,在實(shí)際使用某一數(shù)據(jù)庫框架進(jìn)行存取操作的實(shí)現(xiàn)類之上羊初,再抽象出一層接口類滨溉。在下載過程中只使用接口類中提供的方法,而接口類中方法的具體實(shí)現(xiàn)长赞,則由下層的實(shí)現(xiàn)類完成晦攒。這樣,當(dāng)我們把數(shù)據(jù)庫框架由 FMDB
替換為 WCDB
時(shí)得哆,只需對實(shí)現(xiàn)類的代碼進(jìn)行修改脯颜,修改的目標(biāo)則是使用新框架再次實(shí)現(xiàn)接口類中聲明的方法,這也就是所謂的“針對接口編程”贩据,而非”針對實(shí)現(xiàn)編程“栋操。它帶來的好處是顯而易見的:在數(shù)據(jù)庫框架的替換過程中,最上層的業(yè)務(wù)代碼完全無需改動饱亮,只需對數(shù)據(jù)庫操作的實(shí)現(xiàn)類進(jìn)行修改即可矾芙。
模塊化的目的
有一件事是需要明確的,我們常談的“模塊化”近上,并非對所有模塊都追求任意場景下的可復(fù)用剔宪。因?yàn)槟K會分為業(yè)務(wù)模塊和通用模塊,通用模塊力求做到任意場景下的可復(fù)用,而業(yè)務(wù)模塊則專注于完成某一需求場景葱绒。雖然“下載”這個詞在很多項(xiàng)目中會出現(xiàn)感帅,但不同的項(xiàng)目中對它的定義是不同的。有的“下載”僅僅意指下載單個的文件哈街,而有的下載則指的是某一場景下所有內(nèi)容的本地緩存留瞳。
在這篇文章中,我預(yù)設(shè)的場景是一個下載任務(wù)中會包括各種具體的子任務(wù)骚秦,舉個例子她倘,一個下載任務(wù)可能由三個視頻文件、兩個音頻文件作箍、三張圖片硬梁、兩個網(wǎng)絡(luò)請求的 JSON 格式結(jié)果組成。
因此胞得,我會把本文所說的“下載”歸入業(yè)務(wù)模塊荧止,它不追求做到任意場景下的可復(fù)用,但它能夠很好地完成這個較復(fù)雜場景下的下載任務(wù)阶剑。而這個業(yè)務(wù)模塊中所包含的文件下載跃巡、圖片緩存、文件操作等具體步驟牧愁,其實(shí)是無關(guān)業(yè)務(wù)的素邪,那么它們便可以歸為通用模塊。在其他進(jìn)行圖片緩存的場景下猪半,可以使用這里的圖片緩存模塊兔朦,而其他的文件操作場景,也可以使用這里的文件操作模塊磨确。它們的具體分析會在下文展開沽甥。
二、給出設(shè)計(jì)方案
結(jié)合文章第一部分的分析乏奥,著手進(jìn)行方案的設(shè)計(jì)摆舟。
“下載”不是單單一件事
通常意義上的下載,是指將云端的資源獲取到本地磁盤的過程邓了。對于 iOS 應(yīng)用盏檐,下載的目的多是進(jìn)行某些內(nèi)容的離線展示。一個完整的下載過程驶悟,應(yīng)該由以下的步驟組成:
-
文件操作
對于所下載的文件胡野,需要確定它在本地的存儲路徑;給定某個 key 值痕鳍,需要獲取對應(yīng)文件的存儲路徑硫豆;對于某個指定的路徑龙巨,會有檢查文件存在性、完整性等操作熊响;下載過程中不斷進(jìn)行文件寫入旨别,刪除已下載內(nèi)容時(shí)涉及文件刪除、目錄刪除汗茄;除此之外秸弛,還有獲取各個系統(tǒng)目錄、獲取磁盤空間數(shù)據(jù)等常規(guī)操作洪碳。若涉及安全性需求递览,還會有文件加密、解密操作瞳腌。因此绞铃,將文件操作封裝為一個單獨(dú)模塊是一個明智的選擇。文件操作不僅僅會在下載這個場景中出現(xiàn)嫂侍,因此儿捧,在這個模塊的實(shí)現(xiàn)過程中應(yīng)該盡量剝離業(yè)務(wù)相關(guān)的內(nèi)容,力求成為一個通用的工具模塊挑宠。
-
數(shù)據(jù)庫操作
基于文章第一部分中給出的場景菲盾,這里的下載任務(wù)應(yīng)該是結(jié)構(gòu)化的數(shù)據(jù)。無論網(wǎng)絡(luò)狀況是否正常各淀,已下載的內(nèi)容都能夠正常展示懒鉴,所以下載記錄應(yīng)該被持久化存儲【景ⅲ基于以上兩點(diǎn)疗我,數(shù)據(jù)庫的使用是自然的選擇咆畏。應(yīng)該明確的是南捂,數(shù)據(jù)庫存儲的是下載任務(wù)記錄,或叫做日志旧找,而非下載的文件溺健。考慮到 iOS 中數(shù)據(jù)庫框架的多樣性和業(yè)務(wù)方對數(shù)據(jù)庫性能的持續(xù)追求钮蛛,很容易預(yù)見到數(shù)據(jù)庫框架在未來的替換工作鞭缭。因此對于這個模塊,上文也進(jìn)行了分析魏颓,那就是依照依賴倒置原則岭辣,分成抽象的接口類和具體的實(shí)現(xiàn)類。
-
較大體積文件的下載
在下載的需求中甸饱,視頻沦童、音頻仑濒、zip 文件等體積較大的文件是很常見的。因此一個只針對較大體積文件的下載模塊模塊必不可少偷遗。它不涉及任何具體的業(yè)務(wù)細(xì)節(jié)墩瞳,它的任務(wù)僅僅是根據(jù)給定的文件 url 和本地存儲的路徑,完成該文件的下載氏豌。做到這個模塊的高內(nèi)聚是比較容易的喉酌,因此強(qiáng)烈建議將這部分封裝為一個通用模塊,以滿足任何場景下的文件下載需求泵喘。為減少通用模塊之間的橫向依賴泪电,一個思路是本地路徑由上層的業(yè)務(wù)模塊調(diào)用文件操作模塊獲得,然后傳遞給本模塊涣旨,而非本模塊直接調(diào)用文件操作模塊歪架;對于文件寫入操作,可直接使用系統(tǒng)的 NSFileManager
霹陡。同時(shí)也有另一種思路和蚪,大文件下載和文件操作之間的依賴是自然、可接受的烹棉,允許下載模塊依賴文件操作模塊攒霹。這些沒有標(biāo)準(zhǔn)答案,可以自行取舍浆洗。
-
圖片的下載
有時(shí)候下載任務(wù)中會包含圖片下載催束,按照體積來看,將圖片下載歸入文件類型也不為過伏社。但是圖片的緩存在iOS的開發(fā)中是一個積淀已深的話題抠刺,我們擁有 YYWebImage
、SDWebImage
等優(yōu)秀的圖片緩存框架摘昌,有什么理由再去重復(fù)造一個性能未必更優(yōu)的輪子呢速妖?除此之外,剛剛提到的兩個圖片框架基本應(yīng)用在了絕大多數(shù)的iOS網(wǎng)絡(luò)應(yīng)用中聪黎,所以很有可能出現(xiàn)的場景是:已經(jīng)下載過的圖片罕容,在項(xiàng)目中的某處不相關(guān)的地方用上述圖片框架進(jìn)行加載。如果圖片下載使用這些框架的緩存器來實(shí)現(xiàn)稿饰,那么在上述場景下锦秒,框架會從本地緩存中尋找到目標(biāo)圖片,避免重復(fù)的云端下載喉镰,達(dá)到了有效且明顯的優(yōu)化效果旅择∩驯瘢基于局部性原理秘案,這種情景的命中率還是不可忽略的蔽挠。因此师崎,建議將圖片的下載拆分為一個內(nèi)部實(shí)現(xiàn)使用上述框架的圖片緩存器。
-
網(wǎng)絡(luò)請求結(jié)果的緩存
有的下載場景中汇歹,需要對網(wǎng)絡(luò)請求進(jìn)行緩存屁擅。網(wǎng)絡(luò)請求的結(jié)果多為 JSON 格式的數(shù)據(jù),體積較小产弹,屬于輕量的下載內(nèi)容派歌。我的實(shí)現(xiàn)是網(wǎng)絡(luò)請求緩存和圖片緩存作為 cache 模塊的一部分,整體封裝一個 cache 模塊痰哨。也可以將這兩者分開模塊化胶果,視具體業(yè)務(wù)需求靈活決定。
-
特定場景下載的業(yè)務(wù)模塊
以上列出的模塊斤斧,基本都可以向可廣泛復(fù)用的通用模塊努力早抠。上文提到,模塊化中撬讽,也包括專注具體場景的業(yè)務(wù)模塊蕊连。在本文的業(yè)務(wù)場景下,我封裝了一個業(yè)務(wù)模塊游昼。它的職責(zé)是:持久化維護(hù)已下載和正在下載任務(wù)的list甘苍;根據(jù)按固定格式提交的下載任務(wù),解析出結(jié)構(gòu)化的任務(wù)結(jié)構(gòu)烘豌;對于不同類型的子任務(wù)载庭,使用上述對應(yīng)的通用模塊完成下載;同時(shí)負(fù)責(zé)協(xié)調(diào)各子任務(wù)之間的同步關(guān)系廊佩;在所有子任務(wù)完成下載后囚聚,檢查整個結(jié)構(gòu)的文件完整性;通過完整性校驗(yàn)后标锄,進(jìn)行數(shù)據(jù)庫存儲操作顽铸,存儲該次下載日志;在整個活動周期內(nèi)鸯绿,模塊還負(fù)責(zé)下載任務(wù)狀態(tài)的更新跋破。
模塊整體結(jié)構(gòu)
通過對整個下載過程的分析簸淀,我們拆分出了幾個模塊瓶蝴。依照單一職責(zé)原則,將每個模塊的職責(zé)劃分到了較為合適的粒度租幕,都能夠做到一定程度上的復(fù)用舷手。對于其中擴(kuò)展可能較高的模塊,依照依賴倒置原則劲绪,抽象出了一層接口類男窟,避免了未來底層修改時(shí)對上層業(yè)務(wù)代碼的影響盆赤。在模塊化的應(yīng)用上,也做到了目的明確歉眷、合理拆分牺六。
下圖即是整體的示意圖:
三、完成具體實(shí)現(xiàn)
其實(shí)寫完第二部分汗捡,本文的寫作目的已經(jīng)差不多達(dá)到淑际。大家從標(biāo)題可以感受到,本文側(cè)重點(diǎn)在于對”下載“這個場景運(yùn)用一些理論的指導(dǎo)進(jìn)行較為合理的代碼結(jié)構(gòu)設(shè)計(jì)扇住。不過為做到有始有終——“從理論分析開始春缕,用具體實(shí)現(xiàn)來結(jié)尾”,這部分對實(shí)現(xiàn)細(xì)節(jié)進(jìn)行一些討論艘蹋,提供一些“干貨”锄贼,這些方案面對不同場景會有不同的優(yōu)劣表現(xiàn),僅供參考女阀。
-
文件操作模塊
這部分我的實(shí)現(xiàn)是使用系統(tǒng)的 NSFileManager
進(jìn)行文件存在性判斷等基本操作宅荤。對于本地存儲的目標(biāo)路徑,生成規(guī)則為文件 URL 做 md5 操作浸策,再添加具體的文件類型后綴膘侮。在安全性較高的場景中,所下載的文件都來自自有的服務(wù)器的榛,那么文件正確性校驗(yàn)可以由后端提供部分支持琼了,如對于每個文件都返回特定的校驗(yàn)值,在本地下載完成后夫晌,使用由已下載文件生成的校驗(yàn)值和后端提供的進(jìn)行比對雕薪。
-
數(shù)據(jù)庫模塊
對于數(shù)據(jù)庫中需要存儲什么字段,我的意見是這樣的:對于某個具體的文件晓淀,存儲初始 url所袁、文件在本地存儲的路徑、文件大小凶掰、更新時(shí)間等基本信息燥爷。對于結(jié)構(gòu)化的整條下載記錄,則將還原初始下載任務(wù)的所需字段都進(jìn)行存儲懦窘。具體解釋下前翎,初始下載任務(wù)的提交時(shí)多是使用業(yè)務(wù)方的數(shù)據(jù)類型,比如一篇微博展示時(shí)的 model 畅涂,一篇文章展示時(shí)的 model港华。而下載任務(wù)提交到下載模塊后,我們會將初始的數(shù)據(jù)類型轉(zhuǎn)化為下載模塊的規(guī)定的數(shù)據(jù)格式午衰。若涉及到斷點(diǎn)續(xù)傳等場景立宜,便會存在 app 重啟后冒萄,由從數(shù)據(jù)庫中取得的下載模塊所用數(shù)據(jù)格式向初始業(yè)務(wù)方數(shù)據(jù)格式的逆轉(zhuǎn)化,這時(shí)就需要初始任務(wù)所有必要的狀態(tài)信息橙数,從而進(jìn)行現(xiàn)場恢復(fù)尊流,繼續(xù)進(jìn)行下載。
上文說到灯帮,下載管理業(yè)務(wù)模塊需要維護(hù)下載中奠旺、已下載任務(wù)的 list,用什么來區(qū)分狀態(tài)呢施流?我的實(shí)現(xiàn)是為下載記錄添加標(biāo)識是否完成的字段响疚,這樣當(dāng) app 重啟后,從數(shù)據(jù)庫中取得所有的下載記錄瞪醋,若某條記錄被標(biāo)識為未完成忿晕,那么它便是需要還原為初始下載任務(wù)的記錄,被歸入下載中 list银受。
-
大體積文件下載模塊
關(guān)于這部分的討論已經(jīng)有很多践盼,本文不再贅述。值得一提的是宾巍,這個通用組件依然會面臨底層實(shí)現(xiàn)更換或者版本升級的問題咕幻,所以依照依賴倒置抽象出接口層的思路在這里依然適用。
-
緩存模塊
關(guān)于圖片的緩存在上文已經(jīng)詳細(xì)討論顶霞。對于 JSON 格式的網(wǎng)絡(luò)請求結(jié)果肄程,iOS 中一般使用 NSDictionary
存儲,它支持 NSCoding
協(xié)議选浑,因此 YYCache
蓝厌、EGOCache
等緩存框架都是可以使用的。這部分的接口設(shè)計(jì)比較直白古徒,為指定 key 對應(yīng)的值進(jìn)行緩存拓提,根據(jù)給定 key 返回對應(yīng)的緩存值,以及移除給定 key 對應(yīng)的內(nèi)容隧膘。抽象接口層的思路代态,照例適用。
-
下載管理業(yè)務(wù)模塊
在項(xiàng)目的很多地方可能都需要獲知當(dāng)前下載模塊的狀態(tài)疹吃,所以這里使用單例實(shí)現(xiàn)是一個比較好的選擇蹦疑。在整個下載過程的最初,它根據(jù)提交的每一個初始任務(wù)數(shù)據(jù)互墓,解析出具體的子任務(wù)類型必尼,調(diào)用對應(yīng)的子模塊完成子任務(wù)的下載蒋搜。同一下載任務(wù)下的各子任務(wù)之間應(yīng)該是異步的篡撵,所以 dispatch group
是一個直觀的選擇判莉。順序提交的所有初始任務(wù)之間,則是同步的關(guān)系育谬,這里可以使用類似隊(duì)列的結(jié)構(gòu)來管理券盅。下面給出一個示意圖:
對于下載中、已下載這兩種狀態(tài)的區(qū)分膛檀,這里提供一個改進(jìn)思路:在某個初始任務(wù)真正開始下載之前锰镀,就向數(shù)據(jù)庫中插入一條新的下載記錄,設(shè)置狀態(tài)字段為未完成咖刃,當(dāng)所有子任務(wù)均完成且通過完整性校驗(yàn)后泳炉,更新狀態(tài)字段為完成。
最后嚎杨,為大家提供一個業(yè)務(wù)模塊的樣例偽代碼花鹅,用以展示整個下載流程。
//下載管理業(yè)務(wù)模塊的接口列表(大意展示)
//業(yè)務(wù)方的model
@class OriginModel;
@interface DownloadManager : NSObject
//獲取下載管理對象(單例)
+ (instancetype)sharedInstance;
//獲取下載中的任務(wù)
- (NSArray<OriginModel *> *)downloadingItems;
//獲取已下載的任務(wù)
- (NSArray<OriginModel*> *)downloadedItems;
//根據(jù)id獲取已下載的item
- (OriginModel *)downloadedItemForId:(id<NSCopying>)itemId;
//是否下載過指定id的item
- (BOOL)didDownloadedItem:(id<NSCopying>)itemId;
//批量下載
- (void)downloadItems:(NSArray<OriginModel*> *)items;
//暫停下載
- (void)pauseDownloadForItem:(id<NSCopying>)itemId;
//恢復(fù)下載
- (void)resumeDownloadForItem:(id<NSCopying>)itemId;
//取消下載
- (void)cancelDownloadForItem:(id<NSCopying>)itemId;
@end
//下載管理業(yè)務(wù)模塊的主要實(shí)現(xiàn)
@implementation DownloadManager
- (void)downloadItems:(NSArray<OriginModel *> *)items {
// 解析任務(wù)結(jié)構(gòu)枫浙,將所有任務(wù)push進(jìn)任務(wù)隊(duì)列
MissionStruct *oneStruct = [self analyzeMission];
for (MissionItem *item in oneStruct) {
[self.missionList pushItem:item];
}
...
// 若非空刨肃,從任務(wù)隊(duì)列中取出任務(wù)元素
if (![self.missionList isEmpty]) {
MissionItem *oneMission = [self.missionList pop];
[self handleMission:oneMission];
}
}
- (void)handleMission:(MissionItem *)mission {
// 調(diào)用數(shù)據(jù)庫模塊,插入一條新紀(jì)錄
[DatabaseManager insertMission:mission];
dispatch_group_t downloadGroup;
// 下載視頻
for (videoMission in mission.videos) {
dispatch_group_enter(downloadGroup);
// 調(diào)用文件管理模塊箩帚,獲取該url對應(yīng)的文件路徑
targetPath = [FileManager pathForURL:videoMission.url];
// 調(diào)用大文件下載模塊真友,下載該視頻
[FileDownloadManager downloadFile:videoMission.url
targetPath:targetPath
success:^(){
dispatch_group_leave(downloadGroup);
}];
}
// 下載音頻
for (audioMission in mission.audios) {
dispatch_group_enter(downloadGroup);
// 調(diào)用文件管理模塊,獲取該url對應(yīng)的文件路徑
targetPath = [FileManager pathForURL:audioMission.url];
// 調(diào)用大文件下載模塊紧帕,下載該音頻
[FileDownloadManager downloadFile:audioMission.url
targetPath:targetPath
success:^(){
dispatch_group_leave(downloadGroup);
}];
}
// 緩存圖片
for (imageMission in mission.images) {
dispatch_group_enter(downloadGroup);
// 調(diào)用圖片緩存模塊盔然,緩存該圖片
[ImageCacheManager cacheImage:imageMission.url
success:^(){
dispatch_group_leave(downloadGroup);
}];
}
// 緩存網(wǎng)絡(luò)請求
for (contentMission in mission.contents) {
dispatch_group_enter(downloadGroup);
// 調(diào)用網(wǎng)絡(luò)請求緩存模塊,緩存該網(wǎng)絡(luò)請求
[RequestCacheManager cacheRequest:contentMission.url
success:^(){
dispatch_group_leave(downloadGroup);
}];
}
...
// 所有子任務(wù)均完成
dispatch_group_notify(downloadGroup, dispatch_get_global_queue(0, 0), ^{
// 通過完整性校驗(yàn)
if ([self verifyAllSubMission:mission]) {
// 調(diào)用數(shù)據(jù)庫模塊是嗜,更新該下載紀(jì)錄
[DatabaseManager updateMission:mission];
} else {
// 未通過完整性校驗(yàn)轻纪,移除數(shù)據(jù)庫對應(yīng)記錄
[DatabaseManager removeMission:mission];
}
});
}
@end