iOS下载功能的封装

之前写了 iOS下载功能的实现,但仅仅是把功能实现而已,后来仔细想了想,感觉这个功能跟业务耦合度太高了,还是可以把下载这个功能剥离出来,方便复用。
抽离出来的代码在这里:https://github.com/Phelthas/LXMDownloader
这里记录一下思路

俗话说的好:没有什么解耦是一个中间层搞不定的,如果真的有,那就再加一层!

抽离封装的主要工作,也就是把能够复用的代码变成一个中间层而已。
所以首先要确定哪些功能是可以复用的,哪些仅仅是业务代码。
简单来说,下载这个功能(包括开始,暂停,删除等)是可以复用的,具体下载的是什么,下载完成了要干什么等就是业务范围了。

1, Model的定义
我在GitHub上看了好几个star比较多的download库,大部分都是自定义了下载的model,但这个model一旦定了,就已经跟业务挂钩了,因为model肯定跟业务相关,所以如果要封装,就不能自定义具体的model,只能定义下载的对象应该有什么属性之类——这就是协议的应用场景了。
所以首先就要要定义 下载对象所需要遵守的协议,有了这个协议,就可以说,只要满足这个协议的对象,下载器都可以下载,这就算是个跟业务无关的功能了。

那model应该有哪些属性呢?

比较重要的有,url,completedUnitCount,totalUnitCount,downloadStatus,和uniqueId等。
我看好多别人的库都是直接那url来做唯一标识符,然后将url md5一下作为文件名,但我感觉用url不太科学呀,比如说下载一个视频的时候,视频的videoId是固定的,但视频的url可能会变(为了防盗链,很多视频都有url失效时间),或者一个视频可能有标清,高清等码率,分别对应不同的url,但一般来说下载任何一个码率的视频都算已经下载过该视频了。
总之我感觉用一个uniqueId来作为下载对象的唯一标识符是很有必要的。
completedUnitCount和totalUnitCount用来做进度条,一般用kvo或者通知来监听;
下载速度如果需要的话可以加入一个时间戳,让每次返回的data大小除以时间间隔得出;
downloadStatus用来表示下载的状态

public enum LXMDownloaderStatus: Int {
    case none = 0
    case downloading
    case paused
    case waiting
    case finished
    case failed
}

一个协议定义的属性太多也不方便操作,可以将所有这些属性封装成一个对象,将这个对象作为协议要求的属性。

@objcMembers
open class LXMDownloaderItem: NSObject, NSCoding {

    //注意:swift的类必须继承NSObject并且明确声明为dynamic才可以使用KVO

    open dynamic var downloadStatus: LXMDownloaderStatus = .none
    open dynamic var totalUnitCount: Int64 = 0
    open dynamic var completedUnitCount: Int64 = 0
    open weak var downloadTask: URLSessionDownloadTask? //这里要用weak,让task完成后能正确的结束
    open dynamic var progress: Float {
        if totalUnitCount == 0 {
            return 0
        } else {
            return Float(completedUnitCount) / Float(totalUnitCount)
        }
    }
    open var itemId: String //itemId是唯一标示符,内部使用itemId是否相等来判断是否是同一个对象的
    open var urlString: String
    public init(itemId: String, urlString: String) {
        self.itemId = itemId
        self.urlString = urlString
        super.init()
    }

@objc public protocol LXMDownloaderModelProtocol {
    @objc var lxm_downloadItem: LXMDownloaderItem { set get }
}

2,Model的持久化
因为Model肯定是与业务相关的,所以model的持久化也应该由业务方来做,然后在下载器初始化的时候将model赋值给管理器。
这里因为我这儿定义了对象,所以要为对象加入NSCoding支持。

   /// 注意,encode过程中downloadTask会被忽略,因为URLSessionDownloadTask不能序列化,progress是只读属性,不用序列化

    open func encode(with aCoder: NSCoder) {
        aCoder.encode(downloadStatus.rawValue, forKey: "downloadStatus")
        aCoder.encode(totalUnitCount, forKey: "totalUnitCount")
        aCoder.encode(completedUnitCount, forKey: "completedUnitCount")
        aCoder.encode(urlString, forKey: "urlString")
        aCoder.encode(itemId, forKey: "itemId")
    }

    public required init?(coder aDecoder: NSCoder) {
        downloadStatus = LXMDownloaderStatus(rawValue: aDecoder.decodeInteger(forKey: "downloadStatus")) ?? .none
        totalUnitCount = aDecoder.decodeInt64(forKey: "totalUnitCount")
        completedUnitCount = aDecoder.decodeInt64(forKey: "completedUnitCount")
        urlString = aDecoder.decodeObject(forKey: "urlString") as? String ?? ""
        itemId = aDecoder.decodeObject(forKey: "itemId") as? String ?? ""
    }
}

3,下载器

首先,既然是要抽离封装,那下载器就不应该是一个单例,因为一个APP中可能有不止一个下载器,比如有专门下载视频的videoDownloader,有专门下载文件的fileDownloader,如果是单例就没办法区分了,单例还是应该由业务方来实现。
这也是参考AFNetworking的思路,AFURLSessionManager和AFHTTPSessionManager虽然都叫Manager,但都不是单例,具体业务在使用的时候,是创建自己的Client作为单例。

然后下载器的基本功能,包括初始化,开始下载,暂停,取消,删除本地文件等,
还有工具类的方法,包括返回指定model的本地存放路径,下载文件夹路径,是否存在本地文件的判断等,
然后下载器的回调,包括下载成功的回调,下载失败的回调,model状态变更需要保存的回调等,
然后下载器的配置属性,包括最大并发数,是否允许使用蜂窝网络下载。
这些方法都是针对之前定义好的遵守协议的对象的。

需要注意的细节:

1,断点续传的问题
iOS10开始到 iOS10.2之前的版本resumeData的保存和获取可能会有问题,这个网上也有解决方案,我也就没仔细研究,参考https://benscheirman.com/2016/09/resume-data-broken-in-ios-10/
https://stackoverflow.com/questions/39346231/resume-nsurlsession-on-ios10/39347461#39347461
作者给出的代码没有判断iOS10.2之后的版本,我自己测试,这iOS11以后也直接用这段代码的会报错,直接用系统方法就行了。

2,后台下载的支持

func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void)

这个AppDelegate的方法一定要实现!!!
这个AppDelegate的方法一定要实现!!!
这个AppDelegate的方法一定要实现!!!

我看了好几个别人的库,居然没有强调这一点的,这个方法是APP支持后台下载的关键。
要知道,如果下载的过程中APP进入了后台,那URLSession的所有delegate就都不会在调用了,包括进度的回调,下载完成的回调,下载失败的回调等等,
如果没有实现上面的方法,而且当下载完成时APP又刚好在后台的话,那下载好的文件是没办法移动到你指定的下载路径的。除非APP又进入了前台,系统才会重新调用URLSession的delegate方法,否则的话下载好的文件就一直在临时文件夹呆着,对用户来说相当于丢失了。
上面这个代理方法的作用,可以理解为:相当于让APP在用户不知道的情况下进入了前台一次。
即,只要实现了上面的代理方法,即使APP在后台时,URLSession的- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didFinishDownloadingToURL:(NSURL *)location- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error方法依然会正常调用。

3,APP被kill时resumeData的保存问题

在我看的所有下载库的代码中,就没看到有去处理这个问题的。。。虽然这个问题并不复杂,不知道是大佬们觉得这就根本不是个问题?还是懒的去处理?还是这种情况可以忽略?

具体场景是:当APP有任务正在下载时被kill掉了(不管是在后台被系统kill掉了还是用户手动kill掉了),那这时候下载到一半的任务其实是有resumeData的。
但这时候并不会调用到URLSession的didCompleteWithError方法(估计是怕delegate方法还没执行完APP就已经结束了,反而导致未知的问题),这时候系统会发送UIApplication.willTerminateNotification通知,但监听这个通知在回调里执行open func cancel(byProducingResumeData completionHandler: @escaping (Data?) -> Void)也是不行的,因为这个取消方法是异步的,也需要执行时间的,也存在回调还没完成APP就已经结束的问题;

系统给出的解决方案是:当APP重新启动,同一个identifier的URLSession被创建的时候,这时候执行URLSession的delegate方法didCompleteWithError,其中的error中会带有resumeData,error的类型是 NSURLErrorCancelled,跟正常情况下任务被取消的流程是一致的。

所以如果想保存APP被kill时的resumeData,应该在APP重新启动的时候在URLSession的delegate方法中保存。
那么问题来了:APP重新启动回调URLSession的delegate方法时,传入的session和task,跟任务被启动的时候的session和task,不是同一个对象,但他们具有相同的属性值。问题就是如何找到task对应的下载Model,把这个resumeData和对应下载model关联起来?

上面的model中虽然定义了downloadTask,但downloadTask是不支持序列化的,所以重启以后获取到的downloadTask肯定是nil。回调传入的downloadTask中可以获取的属性有originalRequest,currentRequest,taskDescription,taskIdentifier等,可见,匹配的工作还是需要从这几个属性着手。

taskDescription应该是最合适的,可以在任务开始的时候给taskDescription赋值,同时将这个taskDescription写入对应下载的model进行持久化,然后回调的时候根据taskDescription来找到对应model。
(这里还有个问题需要注意一下,AFURLSessionManager已经把taskDescription这个属性给占用了,而且APP重启的时候会给未执行完的task的taskDescription重新赋值)。
也可以用url来匹配,及利用downloadTask的originalRequest或currentRequest里面的url与下载model里面的url匹配。

参考:苹果官方教程

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容