有始有终,设计一个结构合理的下载模块

3,167 阅读15分钟

完成开发任务的同时,我们总希望自己能够交付高质量的代码。代码质量的测度有很多方法,可扩展性、可复用性是其中的两项指标。设计模式的理论能够非常有效地指导代码设计,但是光谈这些理论是非常抽象的,本文针对下载这个场景,结合设计模式的一些理论,谈一谈如何设计一个结构较为合理的下载模块。

一、明确需求

在着手编码之前,先明确功能需求、技术需求,然后进行初步的思考。

从目标出发

从目标出发,能够帮助明确设计过程中的侧重点。对于下载这个场景,很直观可以想到,它涉及到的文件操作、持久化存储等步骤是会频繁出现在一个项目中的。所以我会希望为下载模块写的大量代码能够被良好复用。同时可以预见,下载这一场景是非常容易出现后续需求变更或者增加的,没准今天只下载视频,明天又需要添加对音频、对 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