從iOS7以來,蘋果推出NSURLSession后,iOS現(xiàn)在可以實(shí)現(xiàn)真正的后臺(tái)下載,這對(duì)我們iOSer來說是一個(gè)福音。
一個(gè) NSURLSession
對(duì)象可以協(xié)調(diào)一個(gè)或多個(gè) NSURLSessionTask
對(duì)象,并根據(jù)NSURLSessionTask
創(chuàng)建的 NSURLSessionConfiguration
實(shí)現(xiàn)不同的功能。使用相同的配置,你也可以創(chuàng)建多組具有相關(guān)任務(wù)的 NSURLSession
對(duì)象。要利用后臺(tái)傳輸服務(wù),你將會(huì)使用 [NSURLSessionConfiguration backgroundSessionConfiguration]
來創(chuàng)建一個(gè)會(huì)話配置。添加到后臺(tái)會(huì)話的任務(wù)在外部進(jìn)程運(yùn)行,即使應(yīng)用程序被掛起,崩潰,或者被殺死,它依然會(huì)運(yùn)行。
下面我們來看看如何使用NSURLSession
下載用到的委托方法
- AppDelegate委托方法
//在應(yīng)用處于后臺(tái),且后臺(tái)任務(wù)下載完成時(shí)回調(diào)
- (void)application:(UIApplication *)application
handleEventsForBackgroundURLSession:(NSString *)identifier
completionHandler:(void (^)())completionHandler;
- NSURLSession委托方法
/* 在任務(wù)下載完成、下載失敗
* 或者是應(yīng)用被殺掉后,重新啟動(dòng)應(yīng)用并創(chuàng)建相關(guān)identifier的Session時(shí)調(diào)用
*/
- (void)URLSession:(NSURLSession *)session
task:(NSURLSessionTask *)task
didCompleteWithError:(NSError *)error;
/* 應(yīng)用在后臺(tái),而且后臺(tái)所有下載任務(wù)完成后,
* 在所有其他NSURLSession和NSURLSessionDownloadTask委托方法執(zhí)行完后回調(diào),
* 可以在該方法中做下載數(shù)據(jù)管理和UI刷新
*/
- (void)URLSessionDidFinishEventsForBackgroundURLSession:(NSURLSession *)session;
注:最好將handleEventsForBackgroundURLSession
中completionHandler
保存,在該方法中待所有載數(shù)據(jù)管理和UI刷新做完后,再調(diào)用completionHandler()
- NSURLSessionDownloadTask委托方法
/* 下載過程中調(diào)用,用于跟蹤下載進(jìn)度
* bytesWritten為單次下載大小
* totalBytesWritten為當(dāng)當(dāng)前一共下載大小
* totalBytesExpectedToWrite為文件大小
*/
- (void)URLSession:(NSURLSession *)session
downloadTask:(NSURLSessionDownloadTask *)downloadTask
didWriteData:(int64_t)bytesWritten
totalBytesWritten:(int64_t)totalBytesWritten
totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite;
/* 下載恢復(fù)時(shí)調(diào)用
* 在使用downloadTaskWithResumeData:方法獲取到對(duì)應(yīng)NSURLSessionDownloadTask,
* 并該task調(diào)用resume的時(shí)候調(diào)用
*/
- (void)URLSession:(NSURLSession *)session
downloadTask:(NSURLSessionDownloadTask *)downloadTask
didResumeAtOffset:(int64_t)fileOffset
expectedTotalBytes:(int64_t)expectedTotalBytes;
//下載完成時(shí)調(diào)用
- (void)URLSession:(NSURLSession *)session
downloadTask:(NSURLSessionDownloadTask *)downloadTask
didFinishDownloadingToURL:(NSURL *)location;
注:在URLSession:downloadTask:didFinishDownloadingToURL
方法中,location只是一個(gè)磁盤上該文件的臨時(shí) URL,只是一個(gè)臨時(shí)文件,需要自己使用NSFileManager將文件寫到應(yīng)用的目錄下(一般來說這種可以重復(fù)獲得的內(nèi)容應(yīng)該放到cache目錄下),因?yàn)楫?dāng)你從這個(gè)委托方法返回時(shí),該文件將從臨時(shí)存儲(chǔ)中刪除。
創(chuàng)建后臺(tái)下載的操作步驟
后臺(tái)傳輸?shù)牡膶?shí)現(xiàn)也十分簡(jiǎn)單,簡(jiǎn)單說分為三個(gè)步驟:
- 創(chuàng)建后臺(tái)下載用的NSURLSession對(duì)象,設(shè)置為后臺(tái)下載類型;
- 向這個(gè)對(duì)象中加入對(duì)應(yīng)的傳輸?shù)腘SURLSessionTask,并開始下載;
- 在AppDelegate里實(shí)現(xiàn)
handleEventsForBackgroundURLSession
,以刷新UI及通知系統(tǒng)傳輸結(jié)束。 - 實(shí)現(xiàn)NSURLSessionDownloadDelegate中必要的代理
下面用代碼來說明描述后臺(tái)下載的流程
首先,我們看下后臺(tái)下載的時(shí)序圖
具體代碼實(shí)現(xiàn)
- 創(chuàng)建一個(gè)后臺(tái)下載對(duì)象
用dispatch_once創(chuàng)建一個(gè)用于后臺(tái)下載對(duì)象,目的是為了保證identifier的唯一,文檔不建議對(duì)于相同的標(biāo)識(shí)符 (identifier) 創(chuàng)建多個(gè)會(huì)話對(duì)象。這里創(chuàng)建并配置了NSURLSession,將通過backgroundSessionConfiguration其指定為后臺(tái)session并設(shè)定delegate。
- (NSURLSession *)backgroundURLSession {
static NSURLSession *session = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
NSString *identifier = @"com.yourcompany.appId.BackgroundSession";
NSURLSessionConfiguration* sessionConfig = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:identifier];
session = [NSURLSession sessionWithConfiguration:sessionConfig
delegate:self
delegateQueue:[NSOperationQueue mainQueue]];
});
return session;
}
- 向其中加入對(duì)應(yīng)的傳輸用的NSURLSessionTask,并調(diào)用resume啟動(dòng)下載。
- (void)beginDownloadWithUrl:(NSString *)downloadURLString {
NSURL *downloadURL = [NSURL URLWithString:downloadURLString];
NSURLRequest *request = [NSURLRequest requestWithURL:downloadURL];
NSURLSession *session = [self backgroundURLSession];
NSURLSessionDownloadTask *downloadTask = [session downloadTaskWithRequest:request];
[downloadTask resume];
}
- 在appDelegate中實(shí)現(xiàn)
handleEventsForBackgroundURLSession
,要注意的是,需要在handleEventsForBackgroundURLSession
中必須重新建立一個(gè)后臺(tái) session 的參照(可以用之前dispatch_once
創(chuàng)建的對(duì)象),否則NSURLSessionDownloadDelegate
和NSURLSessionDelegate
方法會(huì)因?yàn)闆]有 對(duì) session 的 delegate 設(shè)置而不會(huì)被調(diào)用。
然后保存completionHandler()。
- (void)application:(UIApplication *)application
handleEventsForBackgroundURLSession:(NSString *)identifier
completionHandler:(void (^)())completionHandler {
NSURLSession *backgroundSession = [self backgroundURLSession];
NSLog(@"Rejoining session with identifier %@ %@", identifier, backgroundSession);
// 保存 completion handler 以在處理 session 事件后更新 UI
[self addCompletionHandler:completionHandler forSession:identifier];
}
- (void)addCompletionHandler:(CompletionHandlerType)handler
forSession:(NSString *)identifier {
if ([self.completionHandlerDictionary objectForKey:identifier]) {
NSLog(@"Error: Got multiple handlers for a single session identifier. This should not happen.\n");
}
[self.completionHandlerDictionary setObject:handler forKey:identifier];
}
注:handleEventsForBackgroundURLSession方法是在后臺(tái)下載的所有任務(wù)完成后才會(huì)調(diào)用。如果當(dāng)后臺(tái)傳輸完成時(shí),如果應(yīng)用程序已經(jīng)被殺掉,iOS將會(huì)在后臺(tái)啟動(dòng)該應(yīng)用程序,下載相關(guān)的委托方法會(huì)在 application:didFinishLaunchingWithOptions:
方法被調(diào)用之后被調(diào)用。
- 實(shí)現(xiàn)
URLSessionDidFinishEventsForBackgroundURLSession
,待所有數(shù)據(jù)處理完成,UI刷新之后在改方法中在調(diào)用之前保存的completionHandler()。
//NSURLSessionDelegate委托方法,會(huì)在NSURLSessionDownloadDelegate委托方法后執(zhí)行
- (void)URLSessionDidFinishEventsForBackgroundURLSession:(NSURLSession *)session {
NSLog(@"Background URL session %@ finished events.\n", session);
if (session.configuration.identifier) {
// 調(diào)用在 -application:handleEventsForBackgroundURLSession: 中保存的 handler
[self callCompletionHandlerForSession:session.configuration.identifier];
}
}
- (void)callCompletionHandlerForSession:(NSString *)identifier {
CompletionHandlerType handler = [self.completionHandlerDictionary objectForKey: identifier];
if (handler) {
[self.completionHandlerDictionary removeObjectForKey: identifier];
NSLog(@"Calling completion handler for session %@", identifier);
handler();
}
}
- 在此,后臺(tái)下載的基本功能已經(jīng)具備了,如果還需要監(jiān)聽下載進(jìn)度和對(duì)下載完成數(shù)據(jù)進(jìn)行處理,則需要實(shí)現(xiàn)上面提到的委托方法
URLSession:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite:
和URLSession:downloadTask:didFinishDownloadingToURL:
關(guān)于斷點(diǎn)下載
對(duì)于斷點(diǎn)下載需要考慮幾個(gè)問題:
- 如何暫停下載,暫停后,如何繼續(xù)下載?
- 下載失敗后,如何恢復(fù)下載?
- 應(yīng)用被用戶殺掉后,如何恢復(fù)之前的下載?
針對(duì)這幾個(gè)問題,我們一個(gè)來分析
- 如何暫停下載,暫停后,如何繼續(xù)下載?
有兩種方法 - 第一種,使用cancelByProducingResumeData
/* 對(duì)某一個(gè)NSURLSessionDownloadTask取消下載,取消后會(huì)回調(diào)給我們 resumeData,
* resumeData包含了下載任務(wù)的一些狀態(tài),之后可以用戶恢復(fù)下載
*/
- (void)cancelByProducingResumeData:(void (^)(NSData * resumeData))completionHandler;
調(diào)用該方法會(huì)觸發(fā)以下方法,會(huì)附帶resumeData,用于恢復(fù)。
- (void)URLSession:(NSURLSession *)session
task:(NSURLSessionTask *)task
didCompleteWithError:(NSError *)error
對(duì)應(yīng)恢復(fù)方法
//通過之前保存的resumeData,獲取斷點(diǎn)的NSURLSessionTask,調(diào)用resume恢復(fù)下載
NSURLSessionDownloadTask *task = [[self backgroundURLSession] downloadTaskWithResumeData:resumeData];
[task resume];
- 第二種,使用NSURLSessionDownloadTask的suspend方法
//暫停
[self.downloadTask suspend];
//恢復(fù)
[self.downloadTask resume];
通過以上的兩個(gè)方法,就可以實(shí)現(xiàn)下載的暫停與恢復(fù)下載了
- 下載失敗后,如何恢復(fù)下載?
下載失敗后,可以通過以下代碼來恢復(fù)下載
/* 該方法下載成功和失敗都會(huì)回調(diào),只是失敗的是error是有值的,
* 在下載失敗時(shí),error的userinfo屬性可以通過NSURLSessionDownloadTaskResumeData
* 這個(gè)key來取到resumeData(和上面的resumeData是一樣的),再通過resumeData恢復(fù)下載
*/
- (void)URLSession:(NSURLSession *)sessiona
task:(NSURLSessionTask *)task
didCompleteWithError:(NSError *)error {
if (error) {
// check if resume data are available
if ([error.userInfo objectForKey:NSURLSessionDownloadTaskResumeData]) {
NSData *resumeData = [error.userInfo objectForKey:NSURLSessionDownloadTaskResumeData];
NSURLSessionTask *task = [[self backgroundURLSession] downloadTaskWithResumeData:resumeData];
[task resume];
}
}
}
- 應(yīng)用被用戶殺掉后,如何恢復(fù)之前的下載?
在應(yīng)用被殺掉前,iOS系統(tǒng)保存應(yīng)用下載sesson的信息,在重新啟動(dòng)應(yīng)用,并且創(chuàng)建和之前相同identifier的session時(shí)(蘋果通過identifier找到對(duì)應(yīng)的session數(shù)據(jù)),iOS系統(tǒng)會(huì)對(duì)之前下載中的任務(wù)進(jìn)行依次回調(diào)URLSession:task:didCompleteWithError:
方法,之后可以使用上面提到的下載失敗時(shí)的處理方法進(jìn)行恢復(fù)下載
知道這些后,看下前臺(tái)下載的時(shí)序圖對(duì)整個(gè)下載流程就了解了。
關(guān)于Session的生命周期,可以閱讀 Apple 的 Life Cycle of a URL Session with Custom Delegates 文檔,它講解了所有類型的會(huì)話任務(wù)的完整生命周期。
后臺(tái)下載的配置和限制
NSURLSessionConfiguration 允許你設(shè)置默認(rèn)的HTTP頭,配置緩存策略,限制使用蜂窩數(shù)據(jù)等等。其中一個(gè)選項(xiàng)是discretionary標(biāo)志,這個(gè)標(biāo)志允許系統(tǒng)為分配任務(wù)進(jìn)行性能優(yōu)化。這意味著只有當(dāng)設(shè)備有足夠電量時(shí),設(shè)備才通過 Wifi 進(jìn)行數(shù)據(jù)傳輸。如果電量低,或者只僅有一個(gè)蜂窩連接,傳輸任務(wù)是不會(huì)運(yùn)行的。后臺(tái)傳輸總是在 discretionary模式下運(yùn)行。timeoutIntervalForResource屬性,支持資源超時(shí)特性。你可以使用這個(gè)特性指定你允許完成一個(gè)傳輸所需的最長(zhǎng)時(shí)間。內(nèi)容只在有限的時(shí)間可用,或者在用戶只有有限Wifi帶寬的時(shí)間內(nèi)無法下載或上傳資源的情況下,你也可以使用這個(gè)特性。
最后,我們來說一說使用后臺(tái)會(huì)話的幾個(gè)限制。作為一個(gè)必須實(shí)現(xiàn)的委托,您不能對(duì)NSURLSession使用簡(jiǎn)單的基于 block的回調(diào)方法。后臺(tái)啟動(dòng)應(yīng)用程序,是相對(duì)耗費(fèi)較多資源的,所以總是采用HTTP重定向。后臺(tái)傳輸服務(wù)只支持HTTP和HTTPS,你不能使用自定義的協(xié)議。系統(tǒng)會(huì)根據(jù)可用的資源進(jìn)行優(yōu)化,在任何時(shí)候你都不能強(qiáng)制傳輸任務(wù)在后臺(tái)進(jìn)行。
另外,要注意的是在后臺(tái)會(huì)話中,NSURLSessionDataTasks 是完全不支持的,你應(yīng)該只出于短期的,小請(qǐng)求為目的使用這些任務(wù),而不是用來下載或上傳。其中發(fā)現(xiàn)一些需要注意的點(diǎn),記錄下來。
NSURLSession在iOS10上的Bug
在iOS10上,resumeData不能直接使用,會(huì)提示以下錯(cuò)誤
*** -[NSKeyedUnarchiver initForReadingWithData:]: data is NULL
*** -[NSKeyedUnarchiver initForReadingWithData:]: data is NULL
Invalid resume data for background download. Background downloads must use http or https and must download to an accessible file.
解決方法有點(diǎn)小麻煩,具體解決方法可以參照以下Demo
關(guān)于在后臺(tái)啟動(dòng)新的下載任務(wù),蘋果對(duì)這方面有限制
大致說明如下:
1.蘋果的NSURLSession這個(gè)類會(huì)維護(hù)一個(gè)Delay值(即延時(shí)執(zhí)行時(shí)間),用于后臺(tái)啟動(dòng)任務(wù)延時(shí)執(zhí)行時(shí)使用;
2.當(dāng)在后臺(tái)啟動(dòng)一個(gè)新任務(wù)時(shí),蘋果會(huì)對(duì)這個(gè)任務(wù)進(jìn)行延時(shí)執(zhí)行,延時(shí)時(shí)間蘋果那邊是有一個(gè)默認(rèn)的延時(shí)時(shí)間,當(dāng)后臺(tái)啟動(dòng)的任務(wù)數(shù)越多,這個(gè)值就會(huì)成2的N-1
冪倍增長(zhǎng);
3.比如:假設(shè)蘋果設(shè)定的延時(shí)時(shí)間為Delay
。當(dāng)在后臺(tái)啟動(dòng)了第一個(gè)任務(wù)時(shí),這個(gè)任務(wù)的延時(shí)時(shí)間為Delay
,這個(gè)任務(wù)會(huì)在Delay
時(shí)間后開始執(zhí)行;當(dāng)啟動(dòng)在后臺(tái)啟動(dòng)第二個(gè)任務(wù)時(shí),這個(gè)任務(wù)的延時(shí)時(shí)間為2*Delay
,當(dāng)啟動(dòng)第三個(gè)任務(wù)是,該任務(wù)的延時(shí)執(zhí)行時(shí)間即為2*2*Delay
以此類推,在后臺(tái)啟動(dòng)第N個(gè)任務(wù)是,該任務(wù)的延時(shí)執(zhí)行時(shí)間為2(N-1)次方*Delay
;
4.但是在應(yīng)用從后臺(tái)切到前臺(tái)或者重新啟動(dòng)時(shí),這個(gè)延時(shí)時(shí)間會(huì)重置。
所以蘋果對(duì)在后臺(tái)啟動(dòng)新的下載或者上傳任務(wù)時(shí),是有限制的,蘋果也是不建議這么處理的
以下為蘋果官方說明的鏈接地址:
https://forums.developer.apple.com/thread/14854