1. 需求背景
公司的app需要需要支持訂閱更新的自動下載功能缺狠。當(dāng)訂閱更新的靜默推送將app啟動到后臺時吧恃,在后臺開始下載更新的內(nèi)容。
本文主要記錄以下我開發(fā)過程中對于方案的選擇牲剃,開發(fā)需要注意的細(xì)節(jié)和踩過的坑鳖轰。如果需要先了解系統(tǒng)生命周期和NSURLSession后臺下載的相關(guān)機制清酥,那么可以移步本文最后相關(guān)資料的部分,找到相關(guān)資料的鏈接自行了解蕴侣。
2. 整體方案
2.1 NSURLSession對后臺下載的支持
使用如下backgroundSessionConfiguration
創(chuàng)建的NSURLSession
能夠提供以下幾個特別重要的能力焰轻,對于iOS上的后臺下載任務(wù)background session是不二選擇。(后面就叫backgroundSession
了)
當(dāng)你通過backgroundSession
創(chuàng)建的NSURLSessionDownloadTask
開始之后:
- app切換到后臺進(jìn)入suspended狀態(tài)昆雀,這個
downloadTask
仍然可以在一個獨立的進(jìn)程中繼續(xù)進(jìn)行辱志,并在下載完成后系統(tǒng)會將你的app從suspended狀態(tài)切換到UIApplicationStateBackground
, 讓你的app繼續(xù)處理下載任務(wù)蝠筑。 - app在后臺被系統(tǒng)殺死之后,
downloadTask
同樣會繼續(xù)進(jìn)行(注意如果用戶force quit你的app揩懒,那么下載任務(wù)會被取消什乙,并且在下一次啟動恢復(fù)backgroundSession
后會有下載失敗的回調(diào))。任務(wù)完成時旭从,系統(tǒng)同樣會重新啟動你的app到UIApplicationStateBackground
稳强,app可以通過相同的identifier重新創(chuàng)建backgroundSession
并獲取到完成的downloadTask
场仲,然后通過代理事件處理下載任務(wù)和悦。 -
backgroundSession
只支持HTTP and HTTPS協(xié)議,如果需要通過其他協(xié)議來進(jìn)行下載任務(wù)(比如ftp)渠缕,那么這部分的下載任務(wù)就需要考慮另行處理了鸽素。
2.2 AFNetworking or URLSession
AFNetworking對于大部分iOS開發(fā)者AFN已經(jīng)是相當(dāng)于網(wǎng)絡(luò)基礎(chǔ)庫一樣的存在了。AFNetworking封裝的API亦鳞,通過傳遞block的方式能夠非常方便的應(yīng)對復(fù)雜多樣的數(shù)據(jù)接口請求馍忽。但是下載任務(wù)一般就是一個GET請求,下載完成之后將文件存儲到對應(yīng)的沙盒路徑燕差。通過NSURLSession
本身的代理回調(diào)到XXXDownloadManager之類的管理類中集中處理也不會增加很多工作量遭笋。
但是,這一次對開發(fā)我還是選擇了在AFN的基礎(chǔ)上進(jìn)行封裝:因為項目中原本的下載庫已經(jīng)使用了AFN來創(chuàng)建下載請求徒探,本著盡量減少改動的想法瓦呼,就沿用了之前的方案。
ps:后續(xù)的開發(fā)中發(fā)現(xiàn)测暗,AFN的封裝方式對于后臺下載的支持并不太友好央串,相比直接使用URLSession進(jìn)行開發(fā)也并沒有節(jié)省太多工作量。
3. 一些技術(shù)細(xì)節(jié)實現(xiàn)
3.1 暫停下載 - suspend
vs cancelByProducingResumeData:
方案:很糾結(jié)的選擇了用suspend
來暫停下載任務(wù)碗啄,原因如下:
實際測試這兩個方法都可以達(dá)到暫停下載的目的(至少用戶看起來是暫停)质和,但是蘋果開發(fā)論壇上找到如下官方回復(fù)
Background Transfer Service: suspend DownloadTa… |Apple Developer Forums:
Tasks suspension is rarely used and, when it is, it’s mostly used to temporarily disable callbacks as part of some sort of concurrency control system. That’s because a suspended task can still be active on the wire; all that the suspend does is prevent it making progress internally, issuing callbacks, and so on.
OTOH, if you’re implementing a long-term pause (for example, the user wants to pause a download), you’d be better off calling -cancelByProducingResumeData
來自官方的說法是很明確的,cancelByProducingResumeData
應(yīng)該是最合理的選擇稚字。但是所有下載任務(wù)在cancel時都能生成resumeData嗎饲宿?文檔是這么說的:
結(jié)合以上兩點我是這么判斷的:
- 下載源是可控并滿足生成resumeData,那么毫無疑問通過
cancelByProducingResumeData
來暫停下載是最佳選擇胆描。 - 如果下載源不可控褒傅,那么可以選擇
suspend
來暫停下載。但是需要做好下載任務(wù)最終超時失敗的處理袄友,不然用戶就會奇怪的發(fā)現(xiàn)殿托,我已經(jīng)暫停了所有下載問題怎么一直提醒我下載失敗剧蚣?支竹。
綜上旋廷,由于app面對的下載源不可控最終還是選擇了suspend來暫停任務(wù)。
ps:iOS10.2之前的?生成的resumeData
有bug礼搁,可能會導(dǎo)致resumeData
不可用饶碘,可以用下面鏈接中的方式對判斷系統(tǒng)版本resumeData進(jìn)行處理。
ios - Resume NSUrlSession on iOS10 - Stack Overflow馒吴;
3.2 關(guān)于移動網(wǎng)絡(luò)訪問的限制
方案:使用兩個NSURLSession
搭配不同的allowCellularAccess
屬性進(jìn)行移動網(wǎng)絡(luò)訪問限制扎运。
3.2.1 為什么不監(jiān)聽Reachability變化的通知來做網(wǎng)絡(luò)限制?
之前項目中下載的網(wǎng)絡(luò)環(huán)境限制是通過AFReachabilityManager
的通知來做的饮戳。這種方式無論app在前臺或者后臺豪治,只要代碼正在運行的時是沒有問題的。在使用backgroundSession
之后扯罐,app沒有運行的時候下載任務(wù)仍然在繼續(xù)负拟,這種方式就有點力不從心了。 在這種情況下想要限制網(wǎng)絡(luò)的訪問歹河,就必須通過NSURLSession的本身機制來實現(xiàn)網(wǎng)絡(luò)限制掩浙。
NSURLSession
中有兩個地方可以限制移動網(wǎng)絡(luò)訪問:
-
NSURLSession
的allowsCellularAccess
屬性,這是一個readonly
的屬性秸歧,在session初始化的時候會根據(jù)傳入的configuration對象的對應(yīng)屬性決定厨姚。 -
NSURLRequest
的allowCellularAccess
屬性,在創(chuàng)建request對象的時候可以設(shè)置键菱。
需要注意的是以上兩個地方都只能在實例初始化的時候設(shè)置一次谬墙,不能隨時改動。并且在NSURLSession
和NSURLRequest
同時設(shè)置該屬性的時候纱耻,更嚴(yán)格的設(shè)置會生效芭梯,也就是說只要有一個地方限制了移動網(wǎng)絡(luò)那么最終生成的downloadTask
。
ps:雖然下載任務(wù)的流量限制已經(jīng)完全交給session管理弄喘。但是AFReachabilityManager
的網(wǎng)絡(luò)監(jiān)聽仍然保留玖喘,因為網(wǎng)絡(luò)變化時session對請求的暫停或開始事件并沒有回調(diào)蘑志,仍然需要這個監(jiān)聽在網(wǎng)絡(luò)環(huán)境變化的更新UI用累奈。
3.2.2 下載任務(wù)網(wǎng)絡(luò)限制狀態(tài)的切換
方案:使用兩個NSURLSession
,在session的層面做控制急但。
從產(chǎn)品的角度需要可以單獨控制每個下載請求的移動網(wǎng)絡(luò)訪問限制澎媒。(比如用戶在移動網(wǎng)絡(luò)環(huán)境下手動繼續(xù)了某個下載,那么就需要解除這個下載的移動網(wǎng)絡(luò)訪問限制)波桩。
考慮到以單個請求作為控制的粒度戒努,放在NSURLRequest
上設(shè)置看起來是合理的,為了重新設(shè)置allowsCellularAccess
屬性镐躲,需要先取消當(dāng)前的downloadTask
然后重新發(fā)起請求储玫。但在cancelByProducingResumeData:
和downloadTaskWithResumeData:
之間卻發(fā)現(xiàn)并沒有API可以切換下載任務(wù)的allowsCellularAccess
屬性侍筛。
這下尷尬了,難道要舍棄已經(jīng)下載了一大半的數(shù)據(jù)重新創(chuàng)建一個NSURLRequest
嗎撒穷? 這顯然不劃算匣椰。
所以我采用的方案是:
使用兩個NSURLSession
在session的層面做控制。
一個允許移動網(wǎng)絡(luò)訪問的commonSession
端礼,
一個禁止移動網(wǎng)絡(luò)訪問的nonCellularAccessSession
禽笑。
需要切換網(wǎng)絡(luò)權(quán)限時,先通過cancelByProducingResumeData:
拿到resumeData
蛤奥,然后在對應(yīng)的session上downloadTaskWithResumeData:
繼續(xù)下載任務(wù)佳镜。
完美解決后臺下載網(wǎng)絡(luò)限制的需求, 這部分的代碼長這樣
- (void)p_changeCellularAccessForDownload:(XXXDownloadModel *)download newStrategy:(BOOL)allowed {
// 1. nonCellularSessionManager 中的任務(wù)
BOOL foo = download.dataTask && [self.nonCellularSessionManager.downloadTasks containsObject:(NSURLSessionDownloadTask *)download.dataTask];
if (foo) {
if (allowed == NO) {
download.allowCellularAccess = allowed;
return;
} else {
NSURLSessionDownloadTask *downloadTask = (NSURLSessionDownloadTask *)download.dataTask;
BOOL suspended = (downloadTask.state != NSURLSessionTaskStateRunning);
[downloadTask cancelByProducingResumeData:^(NSData * _Nullable resumeData) {
xxx_dispatch_queue_async_safe(self.syncQueue, ^{
download.resumeData = resumeData;
download.allowCellularAccess = allowed;
[self p_addDownloadItem:download suspended:suspended];
});
}];
return;
}
}
// 2. commonSessionManager 中的任務(wù)
BOOL bar = download.dataTask && [self.commonSessionManager.downloadTasks containsObject:(NSURLSessionDownloadTask *)download.dataTask];
if (bar) {
if (YES == allowed) {
download.allowCellularAccess = allowed;
return;
} else {
NSURLSessionDownloadTask *downloadTask = (NSURLSessionDownloadTask *)download.dataTask;
BOOL suspended = (downloadTask.state == NSURLSessionTaskStateSuspended);
download.preferToIgnoreErrorToast = YES;
[downloadTask cancelByProducingResumeData:^(NSData * _Nullable resumeData) {
xxx_dispatch_queue_async_safe(self.syncQueue, ^{
download.resumeData = resumeData;
download.allowCellularAccess = allowed;
[self p_addDownloadItem:download suspended:suspended];
});
}];
return;
}
}
// 3. 設(shè)置未開始的任務(wù)
if ([self.waitingModels containsObject:download]) {
download.allowCellularAccess = allowed;
return;
}
// 4. addSuspendDownloadWithURL添加的任務(wù) (通過[task suspend]的任務(wù)應(yīng)該也會在2.3.步驟中處理)
download.allowCellularAccess = allowed;
}
3.3 啟動時從Session中恢復(fù)下載任務(wù)
方案:每次app啟動后都要使用相同的identifier創(chuàng)建以上兩個session,然后通過session getTasksWithCompletionHandler:
獲得所有downloadTask
喻括,然后使用downloadTask.originalRequest.URL
作為唯一標(biāo)識邀杏,從持久化的數(shù)據(jù)中恢復(fù)下載任務(wù)贫奠。
為什么使用URL而不是downloadTask.description
或者downloadTask.identifier
作為恢復(fù)任務(wù)時的唯一標(biāo)識唬血?
首先需要注意的是,請求可能被重定向唤崭,所以要使用originalRequest.URL
而不是downloadTask.currentReqeust.URL
拷恨。
task.description
這個屬性在app被系統(tǒng)殺死后會丟失,顯然不適合谢肾。
downloadTask.identifier
實測可以在app兩次啟動之間保持一致腕侄。但是因為下載任務(wù)分布在兩個session,使用該屬性判斷需要先判斷session芦疏,增加了額外的復(fù)雜度冕杠。而一般情況下,每個下載任務(wù)的URL都是不同的酸茴。使用URL作為唯一標(biāo)識已經(jīng)能夠滿足需要分预。
重建session的線程同步問題
從session中恢復(fù)下載任務(wù)的過程可以分為以下兩種情況:
-
手動啟動app過程中發(fā)現(xiàn)進(jìn)行中的下載任務(wù)。
-
下載任務(wù)完成通過
background session
的回調(diào)啟動app
可以看到這種情況任務(wù)的恢復(fù)陷入的僵局薪捍。實際上任務(wù)已經(jīng)下載完成笼痹,但是不得不重新下載任務(wù),而且可能會陷入不斷重新下載任務(wù)的死循環(huán)酪穿。(雖然iOS會限制background session
在后臺啟動的次數(shù)但是仍然很不好 )
一開始調(diào)試的時候app啟動頻繁凳干,基本上遇到的是情況1. 雖然會遇到一些下載狀態(tài)異常,但是因為考慮到background session
的機制比較tricky被济,而且又是開發(fā)中救赐,出現(xiàn)也沒有那么頻繁沒有介意(其實是因為任務(wù)丟失之后,我會自動重新開始下載只磷。经磅。)少欺。但是在版本發(fā)布之后因為一些統(tǒng)計數(shù)據(jù)的異常發(fā)現(xiàn)了情況2的問題,我的解決方案如下:
目前來看應(yīng)該可以滿足業(yè)務(wù)需求馋贤。
至于為什么不直接在NSURLDownloadTask回調(diào)時再從持久化數(shù)據(jù)中尋找和綁定到對應(yīng)的任務(wù)上赞别。
- 考慮到機制改動的風(fēng)險和開發(fā)成本。配乓。
- 懶仿滔。。
3.4 基于AFN完成下載時需要注意的地方
3.4.1 通過AFURLSessionManager
的回調(diào)
必須通過
AFURLSessionManager
中的setXXXBlock:
等方法設(shè)置回調(diào)的block犹芹,在這些block中處理下載請求的回調(diào)
先看AFURLSession的初始化中的部分代碼
- (instancetype)initWithSessionConfiguration:(NSURLSessionConfiguration *)configuration {
// 根據(jù)傳入的configuration進(jìn)行初始化...
// 在初始化方法的最后從session中獲取tasks并且設(shè)置delegate
[self.session getTasksWithCompletionHandler:^(NSArray *dataTasks, NSArray *uploadTasks, NSArray *downloadTasks) {
// 獲取downloadTask并且為downloadTask設(shè)置AFURLSessionManagerTaskDelegate, 然而設(shè)置的progressBlock & destinationBlock & completionBlock都是nil
for (NSURLSessionDownloadTask *downloadTask in downloadTasks) {
[self addDelegateForDownloadTask:downloadTask progress:nil destination:nil completionHandler:nil];
}
}];
// 設(shè)置dataTasks & uploadTasks的代理
// ...
return self;
}
可以看到調(diào)用addDelegateForDownloadTask: progress:destination:completionHandler:
方法的時候崎页,幾個block參數(shù)都傳的nil。按照默認(rèn)的處理邏輯腰埂,這些在AFURLSessionManager
初始化時恢復(fù)的task最終會默默的無感知的以失敗結(jié)束飒焦。如果你想要自己設(shè)置AFURLSessionManagerTaskDelegate
的參數(shù),AFN并沒有對外暴露這個類屿笼。幸運的是AFN還提供了setBlock…系列API牺荠。AFNURLSessionManager
在收到session的代理事件回調(diào)時,在把事件傳遞給會AFURLSessionManagerTaskDelegate
的同時會執(zhí)行這些設(shè)置好的block驴一。
problem solved休雌!
3.4.2 關(guān)于AFURLSessionManager
的completionQueue
completionQueue
只針對AFURLSessionManagerTaskDelegate
,setXXXBlock:
系列方法設(shè)置的block并不會在completionQueue
上執(zhí)行肝断,而是在session初始化的時候制定的operationQueue
上執(zhí)行杈曲,所以你提供的block需要自己處理線程同步
類似下面這樣:
- (void)setTaskDidCompleteBlockForSessionManager:(AFHTTPSessionManager *)manager {
__weak typeof(self) weakSelf = self;
[manager setTaskDidCompleteBlock:^(NSURLSession * _Nonnull session, NSURLSessionTask * _Nonnull task, NSError * _Nullable error) {
__strong typeof(weakSelf) self = weakSelf;
__block NSError *blockError = error;
p_dispatch_queue_async_safe(self.syncQueue, ^{
// 處理下載任務(wù)完成
};
}];
}
3.5 關(guān)于下載并發(fā)數(shù)量的限制
如果你所有下載源都來自同一個host,那么你大可以通過NSURLSession
的HTTPMaximumConnectionsPerHost
屬性來進(jìn)行限制胸懈。
如果你的下載源來自很多不同的host(比如我面對的情況)担扑,那么并發(fā)數(shù)量只能自己進(jìn)行限制了,具體限制的并發(fā)數(shù)量是多少就見仁見智了趣钱。
還有一點需要考慮的是涌献,backgroundSession
喚起你app的間隔是會隨著喚起次數(shù)指數(shù)遞增的。
如果你有30個下載任務(wù)羔挡,策略1:每三個任務(wù)完成之后再開始三個任務(wù)洁奈。策略2: 同時開啟30個下載任務(wù),30個下載任務(wù)都完成之后session喚起你的app绞灼。蘋果開發(fā)人員的建議是使用策略2的利术,具體可以看下面這個鏈接NSURLSession’s Resume Rate Limiter |Apple Developer Forums。
4. 測試調(diào)試
一開始低矮,調(diào)試backgroundSession
相關(guān)的代碼會詭異到讓我懷疑人生印叁。建議所有要調(diào)試backgroundSession
相關(guān)的業(yè)務(wù),尤其是和suspention/relaunch機制相關(guān)業(yè)務(wù)的同學(xué)仔細(xì)看一下這個官方論壇上的帖子。
這里只簡要的說明一下幾個注意點:
盡量使用真機進(jìn)行調(diào)試
一開始用XCode配合模擬器調(diào)試的時候經(jīng)常會遇到NSPosixErrorDomain code 2 file not fuond..
這個錯誤轮蜕。這是因為XCode每次運行時會改變app的路徑昨悼,導(dǎo)致downloadTaskByResumeData:
時傳入的resumeData
指向的路徑失效造成的)。帖子中提到了這個問題跃洛,并且建議使用真機進(jìn)行調(diào)試可以規(guī)避這個問題率触。然而我在使用真機進(jìn)行調(diào)試時也會遇到這個問題。
使用exit()
方法調(diào)試app重新啟動相關(guān)的邏輯
使用Xcode debugger進(jìn)行調(diào)試的時候汇竭,debugger會防止app進(jìn)入suspended狀態(tài)葱蝗。并且app進(jìn)入suspended狀態(tài)或者進(jìn)入suspended狀態(tài)然后被系統(tǒng)kill是不會有任何通知或回調(diào)的。所以不要傻傻的鎖上屏幕等著app進(jìn)入suspended狀態(tài)或者被系統(tǒng)skill了细燎。正確的做法是使用exit()
方法來退出app两曼。這是app會等同于被系統(tǒng)殺死的狀態(tài)。然后就可以根據(jù)你的需要玻驻,用各種方式啟動app進(jìn)行調(diào)試悼凑,包括但不限于:
- 直接用Xcode再run一遍。
- 手動啟動app璧瞬。
- 使用靜默推送的通知啟動app到后臺户辫。
- 設(shè)置scheme中的Background Fetch調(diào)試選項,可以通過xcode把app啟動到后臺彪蓬。(相當(dāng)于從suspended狀態(tài)喚起到后臺)
另外多提一句寸莫。只要在scheme中把launch選項設(shè)置成 wait for executable to be launched捺萌。那么通過方式2/3/4啟動app也可以通過debugger調(diào)試档冬。
使用[session invalidateAndCancel]
來讓session恢復(fù)初始狀態(tài)
從任務(wù)管理頁面force quit你的app并不會讓backgroundSession
恢復(fù)初始狀態(tài)。如果需要讓backgroundSession
回到初始狀態(tài)來進(jìn)行調(diào)試桃纯,那么[backgroundSession invalidateAndCancel]
或者刪除app重新安裝才能讓你的session回到初始狀態(tài)酷誓。
ps:[backgroundSession invalidateAndCancel]
之后這個session實例對象就不可用了,需要重新創(chuàng)建實例或者直接重新啟動app态坦。
更多調(diào)試相關(guān)的注意事項請看下面的帖子:
- Testing Background Session Code |Apple Developer Forums
- Got a serious NSURLSession background session bug |Apple Developer Forums
總結(jié)
暫時沒想到什么可總結(jié)的盐数,有空的時候準(zhǔn)備補一個demo,就醬伞梯。
PS:整體還是一個比較蛋疼的方案玫氢,如果讓我重新開始寫一個下載框架,我應(yīng)該會拋棄AFNetworking谜诫,在NSURLSession/ NSURLRequest的基礎(chǔ)上去寫漾峡。
5. 參考資料
官方文檔
- URL Loading System | Apple Developer Documentation
- NSURLSession: New Features and Best Practices - WWDC 2016 - Videos - Apple Developer
- Downloading Files in the Background | Apple Developer Documentation
NSURLSession相關(guān)教程
- URLSession Tutorial: Getting Started | Ray Wenderlich
- Background Modes Tutorial: Getting Started | Ray Wenderlich
第三方庫:
- GitHub - mzeeshanid/MZDownloadManager: This download manager uses NSURLSession api to download files. It can download multiple files at a time. It can download large files if app is in background. It can resume downloads if app was quit.
- GitHub - HustHank/BackgroundDownloadDemo: 一個簡單的使用NSURLSession的下載Demo,包括后臺下載和斷點下載