有始有終,設計一個結構合理的下載模組

halohily發表於2018-07-02

完成開發任務的同時,我們總希望自己能夠交付高質量的程式碼。程式碼質量的測度有很多方法,可擴充套件性、可複用性是其中的兩項指標。設計模式的理論能夠非常有效地指導程式碼設計,但是光談這些理論是非常抽象的,本文針對下載這個場景,結合設計模式的一些理論,談一談如何設計一個結構較為合理的下載模組。

一、明確需求

在著手編碼之前,先明確功能需求、技術需求,然後進行初步的思考。

從目標出發

從目標出發,能夠幫助明確設計過程中的側重點。對於下載這個場景,很直觀可以想到,它涉及到的檔案操作、持久化儲存等步驟是會頻繁出現在一個專案中的。所以我會希望為下載模組寫的大量程式碼能夠被良好複用。同時可以預見,下載這一場景是非常容易出現後續需求變更或者增加的,沒準今天只下載視訊,明天又需要新增對音訊、對 zip 檔案的支援;對於資料庫儲存框架,可能目前在使用 FMDB,後續又要更換為 WCDB。所以,也對這個模組的可擴充套件性、易修改性提出了要求。

結合一點點理論

設計模式中有幾大原則,剛開始接觸我們總感到難以把握。因為它們簡短得像幾字真言,而實際的場景卻有千千萬萬種。那麼,就從最易理解的**“單一職責原則”開始。簡單來說,一個單獨的模組應該只負責一個單獨的任務,任務的粒度越細,它和其他模組的耦合性越低,它也越容易被複用。而遵循“依賴倒置原則”,則會有效提高程式碼的易修改性。比如對於資料庫模組,在實際使用某一資料庫框架進行存取操作的實現類之上,再抽象出一層介面類。在下載過程中只使用介面類中提供的方法,而介面類中方法的具體實現,則由下層的實現類完成。這樣,當我們把資料庫框架由 FMDB 替換為 WCDB 時,只需對實現類的程式碼進行修改,修改的目標則是使用新框架再次實現介面類中宣告的方法,這也就是所謂的“針對介面程式設計”,而非”針對實現程式設計“。它帶來的好處是顯而易見的:在資料庫框架的替換過程中,最上層的業務程式碼完全無需改動**,只需對資料庫操作的實現類進行修改即可。

依賴倒置示意圖

模組化的目的

有一件事是需要明確的,我們常談的“模組化”,並非對所有模組都追求任意場景下的可複用。因為模組會分為業務模組和通用模組,通用模組力求做到任意場景下的可複用,而業務模組則專注於完成某一需求場景。雖然“下載”這個詞在很多專案中會出現,但不同的專案中對它的定義是不同的。有的“下載”僅僅意指下載單個的檔案,而有的下載則指的是某一場景下所有內容的本地快取。

在這篇文章中,我預設的場景是一個下載任務中會包括各種具體的子任務,舉個例子,一個下載任務可能由三個視訊檔案、兩個音訊檔案、三張圖片、兩個網路請求的 JSON 格式結果組成

因此,我會把本文所說的“下載”歸入業務模組,它不追求做到任意場景下的可複用,但它能夠很好地完成這個較複雜場景下的下載任務。而這個業務模組中所包含的檔案下載、圖片快取、檔案操作等具體步驟,其實是無關業務的,那麼它們便可以歸為通用模組。在其他進行圖片快取的場景下,可以使用這裡的圖片快取模組,而其他的檔案操作場景,也可以使用這裡的檔案操作模組。它們的具體分析會在下文展開。

二、給出設計方案

結合文章第一部分的分析,著手進行方案的設計。

“下載”不是單單一件事

通常意義上的下載,是指將雲端的資源獲取到本地磁碟的過程。對於 iOS 應用,下載的目的多是進行某些內容的離線展示。一個完整的下載過程,應該由以下的步驟組成:

  • 檔案操作

對於所下載的檔案,需要確定它在本地的儲存路徑;給定某個 key 值,需要獲取對應檔案的儲存路徑;對於某個指定的路徑,會有檢查檔案存在性、完整性等操作;下載過程中不斷進行檔案寫入,刪除已下載內容時涉及檔案刪除、目錄刪除;除此之外,還有獲取各個系統目錄、獲取磁碟空間資料等常規操作。若涉及安全性需求,還會有檔案加密、解密操作。因此,將檔案操作封裝為一個單獨模組是一個明智的選擇。檔案操作不僅僅會在下載這個場景中出現,因此,在這個模組的實現過程中應該儘量剝離業務相關的內容,力求成為一個通用的工具模組。

  • 資料庫操作

基於文章第一部分中給出的場景,這裡的下載任務應該是結構化的資料。無論網路狀況是否正常,已下載的內容都能夠正常展示,所以下載記錄應該被持久化儲存。基於以上兩點,資料庫的使用是自然的選擇。應該明確的是,資料庫儲存的是下載任務記錄,或叫做日誌,而非下載的檔案。考慮到 iOS 中資料庫框架的多樣性和業務方對資料庫效能的持續追求,很容易預見到資料庫框架在未來的替換工作。因此對於這個模組,上文也進行了分析,那就是依照依賴倒置原則,分成抽象的介面類和具體的實現類。

  • 較大體積檔案的下載

在下載的需求中,視訊、音訊、zip 檔案等體積較大的檔案是很常見的。因此一個只針對較大體積檔案的下載模組模組必不可少。它不涉及任何具體的業務細節,它的任務僅僅是根據給定的檔案 url 和本地儲存的路徑,完成該檔案的下載。做到這個模組的高內聚是比較容易的,因此強烈建議將這部分封裝為一個通用模組,以滿足任何場景下的檔案下載需求。為減少通用模組之間的橫向依賴,一個思路是本地路徑由上層的業務模組呼叫檔案操作模組獲得,然後傳遞給本模組,而非本模組直接呼叫檔案操作模組;對於檔案寫入操作,可直接使用系統的 NSFileManager。同時也有另一種思路,大檔案下載和檔案操作之間的依賴是自然、可接受的,允許下載模組依賴檔案操作模組。這些沒有標準答案,可以自行取捨。

  • 圖片的下載

有時候下載任務中會包含圖片下載,按照體積來看,將圖片下載歸入檔案型別也不為過。但是圖片的快取在iOS的開發中是一個積澱已深的話題,我們擁有 YYWebImageSDWebImage 等優秀的圖片快取框架,有什麼理由再去重複造一個效能未必更優的輪子呢?除此之外,剛剛提到的兩個圖片框架基本應用在了絕大多數的iOS網路應用中,所以很有可能出現的場景是:已經下載過的圖片,在專案中的某處不相關的地方用上述圖片框架進行載入。如果圖片下載使用這些框架的快取器來實現,那麼在上述場景下,**框架會從本地快取中尋找到目標圖片,避免重複的雲端下載,達到了有效且明顯的優化效果。基於區域性性原理,這種情景的命中率還是不可忽略的。**因此,建議將圖片的下載拆分為一個內部實現使用上述框架的圖片快取器。

  • 網路請求結果的快取

有的下載場景中,需要對網路請求進行快取。網路請求的結果多為 JSON 格式的資料,體積較小,屬於輕量的下載內容。我的實現是網路請求快取和圖片快取作為 cache 模組的一部分,整體封裝一個 cache 模組。也可以將這兩者分開模組化,視具體業務需求靈活決定。

  • 特定場景下載的業務模組

以上列出的模組,基本都可以向可廣泛複用的通用模組努力。上文提到,模組化中,也包括專注具體場景的業務模組。在本文的業務場景下,我封裝了一個業務模組。它的職責是:持久化維護已下載和正在下載任務的list;根據按固定格式提交的下載任務,解析出結構化的任務結構;對於不同型別的子任務,使用上述對應的通用模組完成下載;同時負責協調各子任務之間的同步關係;在所有子任務完成下載後,檢查整個結構的檔案完整性;通過完整性校驗後,進行資料庫儲存操作,儲存該次下載日誌;在整個活動週期內,模組還負責下載任務狀態的更新。

模組整體結構

通過對整個下載過程的分析,我們拆分出了幾個模組。依照單一職責原則,將每個模組的職責劃分到了較為合適的粒度,都能夠做到一定程度上的複用。對於其中擴充套件可能較高的模組,依照依賴倒置原則,抽象出了一層介面類,避免了未來底層修改時對上層業務程式碼的影響。在模組化的應用上,也做到了目的明確、合理拆分。

下圖即是整體的示意圖:

整體依賴關係示意圖

三、完成具體實現

其實寫完第二部分,本文的寫作目的已經差不多達到。大家從標題可以感受到,本文側重點在於對”下載“這個場景運用一些理論的指導進行較為合理的程式碼結構設計。不過為做到有始有終——“從理論分析開始,用具體實現來結尾”,這部分對實現細節進行一些討論,提供一些“乾貨”,這些方案面對不同場景會有不同的優劣表現,僅供參考。

  • 檔案操作模組

這部分我的實現是使用系統的 NSFileManager 進行檔案存在性判斷等基本操作。對於本地儲存的目標路徑,生成規則為檔案 URL 做 md5 操作,再新增具體的檔案型別字尾。在安全性較高的場景中,所下載的檔案都來自自有的伺服器,那麼檔案正確性校驗可以由後端提供部分支援,如對於每個檔案都返回特定的校驗值,在本地下載完成後,使用由已下載檔案生成的校驗值和後端提供的進行比對。

  • 資料庫模組

對於資料庫中需要儲存什麼欄位,我的意見是這樣的:對於某個具體的檔案,儲存初始 url、檔案在本地儲存的路徑、檔案大小、更新時間等基本資訊。對於結構化的整條下載記錄,則將還原初始下載任務的所需欄位都進行儲存。具體解釋下,初始下載任務的提交時多是使用業務方的資料型別,比如一篇微博展示時的 model ,一篇文章展示時的 model。而下載任務提交到下載模組後,我們會將初始的資料型別轉化為下載模組的規定的資料格式。若涉及到斷點續傳等場景,便會存在 app 重啟後,由從資料庫中取得的下載模組所用資料格式向初始業務方資料格式的逆轉化,這時就需要初始任務所有必要的狀態資訊,從而進行現場恢復,繼續進行下載。

上文說到,下載管理業務模組需要維護下載中、已下載任務的 list,用什麼來區分狀態呢?我的實現是為下載記錄新增標識是否完成的欄位,這樣當 app 重啟後,從資料庫中取得所有的下載記錄,若某條記錄被標識為未完成,那麼它便是需要還原為初始下載任務的記錄,被歸入下載中 list。

  • 大體積檔案下載模組

關於這部分的討論已經有很多,本文不再贅述。值得一提的是,這個通用元件依然會面臨底層實現更換或者版本升級的問題,所以依照依賴倒置抽象出介面層的思路在這裡依然適用。

  • 快取模組

關於圖片的快取在上文已經詳細討論。對於 JSON 格式的網路請求結果,iOS 中一般使用 NSDictionary 儲存,它支援 NSCoding 協議,因此 YYCacheEGOCache等快取框架都是可以使用的。這部分的介面設計比較直白,為指定 key 對應的值進行快取,根據給定 key 返回對應的快取值,以及移除給定 key 對應的內容。抽象介面層的思路,照例適用。

  • 下載管理業務模組

在專案的很多地方可能都需要獲知當前下載模組的狀態,所以這裡使用單例實現是一個比較好的選擇。在整個下載過程的最初,它根據提交的每一個初始任務資料,解析出具體的子任務型別,呼叫對應的子模組完成子任務的下載。同一下載任務下的各子任務之間應該是非同步的,所以 dispatch group 是一個直觀的選擇。順序提交的所有初始任務之間,則是同步的關係,這裡可以使用類似佇列的結構來管理。下面給出一個示意圖:

下載任務結構示意圖

對於下載中、已下載這兩種狀態的區分,這裡提供一個改進思路:在某個初始任務真正開始下載之前,就向資料庫中插入一條新的下載記錄,設定狀態欄位為未完成,當所有子任務均完成且通過完整性校驗後,更新狀態欄位為完成。

最後,為大家提供一個業務模組的樣例虛擬碼,用以展示整個下載流程。

//下載管理業務模組的介面列表(大意展示)

//業務方的model
@class OriginModel;

@interface DownloadManager : NSObject
//獲取下載管理物件(單例)
+ (instancetype)sharedInstance;
//獲取下載中的任務
- (NSArray<OriginModel *> *)downloadingItems;
//獲取已下載的任務
- (NSArray<OriginModel*> *)downloadedItems;
//根據id獲取已下載的item
- (OriginModel *)downloadedItemForId:(id<NSCopying>)itemId;
//是否下載過指定id的item
- (BOOL)didDownloadedItem:(id<NSCopying>)itemId;
//批量下載
- (void)downloadItems:(NSArray<OriginModel*> *)items;
//暫停下載
- (void)pauseDownloadForItem:(id<NSCopying>)itemId;
//恢復下載
- (void)resumeDownloadForItem:(id<NSCopying>)itemId;
//取消下載
- (void)cancelDownloadForItem:(id<NSCopying>)itemId;
@end
複製程式碼
//下載管理業務模組的主要實現

@implementation DownloadManager

- (void)downloadItems:(NSArray<OriginModel *> *)items {
    
//    解析任務結構,將所有任務push進任務佇列
    MissionStruct *oneStruct = [self analyzeMission];
    for (MissionItem *item in oneStruct) {
        [self.missionList pushItem:item];
    }
    ...
//    若非空,從任務佇列中取出任務元素
    if (![self.missionList isEmpty]) {
        MissionItem *oneMission = [self.missionList pop];
        [self handleMission:oneMission];
    }
}

- (void)handleMission:(MissionItem *)mission {
    
    //    呼叫資料庫模組,插入一條新紀錄
    [DatabaseManager insertMission:mission];
    dispatch_group_t downloadGroup;
    
    //    下載視訊
    for (videoMission in mission.videos) {
        dispatch_group_enter(downloadGroup);
        //        呼叫檔案管理模組,獲取該url對應的檔案路徑
        targetPath = [FileManager pathForURL:videoMission.url];
        //        呼叫大檔案下載模組,下載該視訊
        [FileDownloadManager downloadFile:videoMission.url
                               targetPath:targetPath
                                  success:^(){
                                      dispatch_group_leave(downloadGroup);
                                  }];
    }
    
    //    下載音訊
    for (audioMission in mission.audios) {
        dispatch_group_enter(downloadGroup);
        //        呼叫檔案管理模組,獲取該url對應的檔案路徑
        targetPath = [FileManager pathForURL:audioMission.url];
        //        呼叫大檔案下載模組,下載該音訊
        [FileDownloadManager downloadFile:audioMission.url
                               targetPath:targetPath
                                  success:^(){
                                      dispatch_group_leave(downloadGroup);
                                  }];
    }
    
    //    快取圖片
    for (imageMission in mission.images) {
        dispatch_group_enter(downloadGroup);
        //        呼叫圖片快取模組,快取該圖片
        [ImageCacheManager cacheImage:imageMission.url
                                  success:^(){
                                      dispatch_group_leave(downloadGroup);
                                  }];
    }
    
    //    快取網路請求
    for (contentMission in mission.contents) {
        dispatch_group_enter(downloadGroup);
        //        呼叫網路請求快取模組,快取該網路請求
        [RequestCacheManager cacheRequest:contentMission.url
                              success:^(){
                                  dispatch_group_leave(downloadGroup);
                              }];
    }
    
    ...
    
    //    所有子任務均完成
    dispatch_group_notify(downloadGroup, dispatch_get_global_queue(0, 0), ^{
    //    通過完整性校驗
        if ([self verifyAllSubMission:mission]) {
            //    呼叫資料庫模組,更新該下載紀錄
            [DatabaseManager updateMission:mission];
        } else {
            //    未通過完整性校驗,移除資料庫對應記錄
            [DatabaseManager removeMission:mission];
        }
    });
}

@end
複製程式碼

相關文章