iOS 7 的多任務

在 iOS 7 之前,當程序置于后臺之后開發者們對他們程序所能做的事情非常有限。除了 VOIP 和基于地理位置特性以外,唯一能做的地方就是使用后臺任務(background tasks)讓代碼可以執行幾分鐘。如果你想下載比較大的視頻文件以便離線瀏覽,亦或者備份用戶的照片到你的服務器上,你都僅能完成一部分工作。

iOS 7 添加了兩個新的 API 以便你的程序可以在后臺更新界面以及內容。首先是后臺獲取(Background Fetch),它允許你定期地從網絡獲取新的內容。第二個 API 就是遠程通知(Remote Notifications),這是一個當事件發生時可以讓推送通知主動提醒應用的新特性,這兩者都為你的應用界面保持最新提供了極大的幫助。在新的后臺傳輸服務 (Background Transfer Service) 中執行定期的任務,也允許你在進程之外可以執行網絡傳輸(下載和上傳)工作。

后臺獲取 (Background Fetch) 和遠程通知 (Remote Notification) 基于簡單的 ApplicationDelegate 鉤子,在應用程序掛起之前的 30 秒時鐘時間執行工作。它們不是用于 CPU 頻繁工作或者長時間運行任務,而是用來處理長時間運行的網絡請求隊列,例如下載一部很大的電影,或者執行快速的內容更新。

對用戶來說,多任務處理有一點顯而易見的改變就是新的應用切換程序 (the new app switcher),它用來呈現應用到后臺時的界面快照。這些快照的存在是有一定理由的--現在你可以在后臺完成工作后更新程序快照,以用來呈現新的內容。社交網絡、新聞或者天氣等應用現在都可以直接呈現最新的內容而不需要用戶重新打開應用。我們稍后會介紹如何更新屏幕快照。

后臺獲取

后臺獲取是一種智能的輪詢機制,它很適合需要經常更新內容的程序,像社交網絡,新聞或天氣的程序。為了在用戶啟動程序前提前觸發后臺獲取,系統會根據用戶行為喚醒應用程序。舉個例子,如果用戶經常在下午 1 點使用某個應用程序,系統會學習,適應并在使用周期前執行后臺獲取。為了減少電池使用,使用設備無線通信的所有應用的后臺獲取會被合并,如果你向系統報告新數據無法獲取,iOS 會適應并使用此信息避免會繼續獲取。

開啟后臺獲取的第一步是在 info plist 文件中對 UIBackgroundModes 鍵指定特定的值。最簡單的途徑是在 Xcode 5 的 project editor 中新的 Capabilities 標簽頁中設置,這個標簽頁包含了后臺模式部分,可以方便配置多任務選項。

capabilities-on-bgfetch.jpg

或者,你可以手動編輯這個值

<key>UIBackgroundModes</key>
<array>
    <string>fetch</string>
</array>  

接下來,告訴 iOS 多久進行一次數據獲取

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    [application setMinimumBackgroundFetchInterval:UIApplicationBackgroundFetchIntervalMinimum];

    return YES;
}

iOS 默認不進行后臺獲取,所以你需要設置一個時間間隔,否則,你的應用程序永遠不能在后臺被喚醒。UIApplicationBackgroundFetchIntervalMinimum 這個值要求系統盡可能頻繁地去管理你的程序到底什么時候應該被喚醒,但如果你不需要這樣的話,你也應該指定一個你想要的的時間間隔。例如,一個天氣的應用程序,可能只需要幾個小時才更新一次,iOS 將會在后臺獲取之間至少等待你指定的時間間隔。

如果你的應用允許用戶退出登錄,那么就沒有獲取新數據的需要了,你應該把 minimumBackgroundFetchInterval 設置為 UIApplicationBackgroundFetchIntervalNever,這樣可以節省資源。

最后一步是在應用程序委托中實現下列方法:

- (void)                application:(UIApplication *)application 
  performFetchWithCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler
{
    NSURLSessionConfiguration *sessionConfiguration = [NSURLSessionConfiguration defaultSessionConfiguration];
    NSURLSession *session = [NSURLSession sessionWithConfiguration:sessionConfiguration];

    NSURL *url = [[NSURL alloc] initWithString:@"http://yourserver.com/data.json"];
    NSURLSessionDataTask *task = [session dataTaskWithURL:url 
                                        completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {

        if (error) {
            completionHandler(UIBackgroundFetchResultFailed);
            return;
        }

        // 解析響應/數據以決定新內容是否可用
        BOOL hasNewData = ...
        if (hasNewData) {
            completionHandler(UIBackgroundFetchResultNewData);
        } else {
            completionHandler(UIBackgroundFetchResultNoData);
        }
    }];

    // 開始任務
    [task resume];
}

系統喚醒應用程序后將會執行這個委托方法。需要注意的是,你只有 30 秒的時間來確定獲取的新內容是否可用,然后處理新內容并更新界面。30 秒時間應該足夠去從網絡獲取數據和獲取界面的縮略圖,但是最多只有 30 秒。當完成了網絡請求和更新界面后,你應該執行完成的回調。

完成回調的執行有兩個目的。首先,系統會估量你的進程消耗的電量,并根據你傳遞的 UIBackgroundFetchResult 參數記錄新數據是否可用。其次,當你調用完成的處理代碼時,應用的界面縮略圖會被采用,并更新應用程序切換器。當用戶在應用間切換時,用戶將會看到新內容。這種通過 completion handler 來報告并且生成截圖的方法,在新的多任務處理 API 中是很常見的。

在實際應用中,你應該將 completionHandler 傳遞到應用程序的子組件,然后在處理完數據和更新界面后調用。

在這里,你可能想知道 iOS 是如何在應用程序后臺運行時獲得界面截圖的,并且想知道應用程序的生命周期與后臺獲取之間有什么關系。如果應用程序處于掛起狀態,系統會先喚醒應用,然后再調用 application: performFetchWithCompletionHandler:。如果應用程序還沒有啟動,系統將會啟動它,然后調用常見的委托方法,包括 application: didFinishLaunchingWithOptions:。你可以把這種應用程序運行的方式想像為用戶從 Springboard 啟動這個程序,區別僅僅在于界面是看不見的,在屏幕外渲染的。

大多數情況下,無論應用在后臺啟動或者在前臺,你會執行相同的工作,但你可以通過查看 UIApplication 的 applicationState 屬性來判斷應用是不是從后臺啟動。

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    NSLog(@"Launched in background %d", UIApplicationStateBackground == application.applicationState);

    return YES;
}

測試后臺數據獲取
有兩種可以模擬后臺獲取的途徑。最簡單是從 Xcode 運行你的應用,當應用運行時,在 Xcode 的 Debug 菜單選擇 Simulate Background Fetch.

第二種方法,使用 scheme 更改 Xcode 運行程序的方式。在 Xcode 菜單的 Product 選項,選擇 Scheme 然后選擇 Manage Schemes。在這里,你可以編輯或者添加一個新的 scheme,然后選中 Launch due to a background fetch event 。如下圖:


edit-scheme-simulate-background-fetch.png

遠程通知

遠程通知允許你在重要事件發生時,告知你的應用。你可能需要發送新的即時信息,突發新聞的提醒,或者用戶喜愛電視的最新劇集已經可以下載以便離線觀看的消息。遠程通知很適合用于那些偶爾出現,但卻很重要的內容,如果使用后臺獲取模式中在兩次獲取間需要等待的時間是不可接受的話,遠程通知會是一個不錯的選擇。遠程通知會比后臺獲取更有效率,因為應用程序只有在需要的時候才會啟動。

一條遠程通知實際上只是一條普通的帶有 content-available 標志的推送通知。你可以發送一條帶有提醒信息的推送去告訴用戶有事請發生了,同時在后臺對界面進行更新。但遠程通知也可以做到在安靜地,沒有提醒消息或者任何聲音的情況下,只去更新應用界面或者觸發后臺工作。然后你可以在完成下載或者處理完新內容后,發送一條本地通知。

靜默的推送通知有速度限制,所以你可以大膽地根據應用程序的需要發送盡可能多的通知。iOS 和蘋果推送服務會控制推送通知多久被遞送,發送很多推送通知是沒有問題的。如果你的推送通知達到了限制,推送通知可能會被延遲,直到設備下次發送保持活動狀態的數據包,或者收到另外一個通知。

發送遠程通知
要發送一條遠程通知,需要在推送通知的有效負載(payload)設置 content-available 標志。content-available 標志和用來通知 報刊應用(Newsstand)的健值是一樣的,因此,大多數推送腳本和庫都已經支持遠程通知。當你發送一條遠程通知時,你可能還想要包含一些通知有效負載中的數據,讓你應用程序可以引用事件。這可以為你節省一些網絡請求,并提高應用程序的響應度。

我建議在開發的時候,使用 Nomad CLI’s Houston 工具發送推送消息,當然你也可以使用你喜歡的庫或腳本。

你可以通過 nomad-cli ruby gem 來安裝 Houston

gem install nomad-cli

然后通過包含在 Nomad 的 apn 實用工具發送一條通知:

# Send a Push Notification to your Device
apn push <device token> -c /path/to/key-cert.pem -n -d content-id=42

在這里,-n 標志指定應該包含 content-available 健值,-d 標志允許添加我們自定義的數據健值到有效負荷。

通知的有效負荷(payload)結果和下面類似:

{
    "aps" : {
        "content-available" : 1
    },
    "content-id" : 42
}

iOS 7 添加了一個新的應用程序委托方法,當接收到一條帶有 content-available 的推送通知時,下面的方法會被調用:

- (void)application:(UIApplication *)application 
  didReceiveRemoteNotification:(NSDictionary *)userInfo 
        fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler
{
    NSLog(@"Remote Notification userInfo is %@", userInfo);

    NSNumber *contentID = userInfo[@"content-id"];
    // 根據 content ID 進行操作
    completionHandler(UIBackgroundFetchResultNewData);
}

和后臺抓取一樣,應用程序進入后臺啟動,也有 30 秒的時間去獲取新內容并更新界面,最后調用完成的處理代碼。我們可以像后臺獲取那樣,執行快速的網絡請求,但我們可以使用新的強大的后臺傳輸服務,處理任務隊列,下面看看我們如何在任務完成后更新界面。

NSURLSession 和 后臺傳輸服務(Background Transfer Service)

NSURLSession 是 iOS 7 添加的一個新類,它也是 Foundation networking 中的新技術。作為 NSURLConnection 的替代品,一些熟悉的概念和類都保留下來了,例如 NSURL,NSURLRequest 和 NSURLResponse。所以,你可以使用 NSURLSessionTask 這一 NSURLConnection 的替代品,來處理網絡請求及響應。一共有 3 種會話任務:數據,下載和上傳。每一種都向 NSURLSessionTask 添加了語法糖,根據你的需要,適當選擇一種。

一個 NSURLSession 對象協調一個或多個 NSURLSessionTask 對象,并根據 NSURLSessionTask 創建的 NSURLSessionConfiguration 實現不同的功能。使用相同的配置,你也可以創建多組具有相關任務的 NSURLSession 對象。要利用后臺傳輸服務,你將會使用 [NSURLSessionConfiguration backgroundSessionConfiguration] 來創建一個會話配置。添加到后臺會話的任務在外部進程運行,即使應用程序被掛起,崩潰,或者被殺死,它依然會運行。

NSURLSessionConfiguration 允許你設置默認的 HTTP 頭,配置緩存策略,限制使用蜂窩數據等等。其中一個選項是 discretionary 標志,這個標志允許系統為分配任務進行性能優化。這意味著只有當設備有足夠電量時,設備才通過 Wifi 進行數據傳輸。如果電量低,或者只僅有一個蜂窩連接,傳輸任務是不會運行的。后臺傳輸總是在 discretionary 模式下運行。

目前為止,我們大概了解了 NSURLSession,以及一個后臺會話如何進行,接下來,讓我們回到遠程通知的例子,添加一些代碼來處理后臺傳輸服務的下載隊列。當下載完成后,我們會通知用戶該文件已經可以使用了。

NSURLSessionDownloadTask
首先,我們先處理一條遠程通知,并把一個 NSURLSessionDownloadTask 添加到后臺傳輸服務的隊列。在 backgroundURLSession 方法中,我們根據后臺會話配置,創建一個 NSURLSession 對象,并把 application delegate 作為會話的委托對象。文檔不建議對于相同的標識符 (identifier) 創建多個會話對象,所以我們使用 dispatch_once 來避免潛在的問題:

- (NSURLSession *)backgroundURLSession
{
    static NSURLSession *session = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        NSString *identifier = @"io.objc.backgroundTransferExample";
        NSURLSessionConfiguration* sessionConfig = [NSURLSessionConfiguration backgroundSessionConfiguration:identifier];
        session = [NSURLSession sessionWithConfiguration:sessionConfig 
                                                delegate:self 
                                           delegateQueue:[NSOperationQueue mainQueue]];
    });

    return session;
}

- (void)           application:(UIApplication *)application 
  didReceiveRemoteNotification:(NSDictionary *)userInfo 
        fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler
{
    NSLog(@"Received remote notification with userInfo %@", userInfo);

    NSNumber *contentID = userInfo[@"content-id"];
    NSString *downloadURLString = [NSString stringWithFormat:@"http://yourserver.com/downloads/%d.mp3", [contentID intValue]];
    NSURL* downloadURL = [NSURL URLWithString:downloadURLString];

    NSURLRequest *request = [NSURLRequest requestWithURL:downloadURL];
    NSURLSessionDownloadTask *task = [[self backgroundURLSession] downloadTaskWithRequest:request];
    task.taskDescription = [NSString stringWithFormat:@"Podcast Episode %d", [contentID intValue]];
    [task resume];

    completionHandler(UIBackgroundFetchResultNewData);
}

我們使用 NSURLSession 類方法創建一個下載任務,配置請求,并提供說明供以后使用。因為所有會話任務一開始處于掛起狀態,你必須謹記要調用 [task resume] 保證開始了任務。

現在,我們需要實現 NSURLSessionDownloadDelegate 的委托方法,當下載完成時,調用回調函數。如果你需要處理認證或會話生命周期的其他事件,你可能還需要實現 NSURLSessionDelegate 或 NSURLSessionTaskDelegate 的方法。你應該閱讀 Apple 的 Life Cycle of a URL Session with Custom Delegates 文檔,它講解了所有類型的會話任務的完整生命周期。

NSURLSessionDownloadDelegate 中的委托方法全部是必須實現的,盡管在這個例子中我們只需要用到 [NSURLSession downloadTask:didFinishDownloadingToURL:]。任務完成下載時,你會得到一個磁盤上該文件的臨時 URL。你必須把這個文件移動或復制你的應用程序空間,因為當你從這個委托方法返回時,該文件將從臨時存儲中刪除。

#Pragma Mark - NSURLSessionDownloadDelegate

- (void)         URLSession:(NSURLSession *)session 
               downloadTask:(NSURLSessionDownloadTask *)downloadTask
  didFinishDownloadingToURL:(NSURL *)location
{
    NSLog(@"downloadTask:%@ didFinishDownloadingToURL:%@", downloadTask.taskDescription, location);

    // 用 NSFileManager 將文件復制到應用的存儲中
    // ...

    // 通知 UI 刷新
}

- (void)  URLSession:(NSURLSession *)session 
        downloadTask:(NSURLSessionDownloadTask *)downloadTask 
   didResumeAtOffset:(int64_t)fileOffset 
  expectedTotalBytes:(int64_t)expectedTotalBytes
{
}

- (void)         URLSession:(NSURLSession *)session 
               downloadTask:(NSURLSessionDownloadTask *)downloadTask 
               didWriteData:(int64_t)bytesWritten totalBytesWritten:(int64_t)totalBytesWritten 
  totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite
{
}

當后臺會話任務完成時,如果你的應用程序仍然在前臺運行,上面的代碼已經足夠了。然而,在大多數情況下,你的應用程序可能是沒有運行的,或者在后臺被掛起。在這些情況下,你必須實現應用程序委托的兩個方法,這樣系統就可以喚醒你的應用程序。不同于以往的委托回調,該應用程序委托會被調用兩次,因為您的會話和任務委托可能會收到一系列消息。app delegate 的:handleEventsForBackgroundURLSession: 方法會在這些 NSURLSession 委托的消息發送前被調用,然后,URLSessionDidFinishEventsForBackgroundURLSession 在隨后被調用。在前面的方法中,包含了一個后臺完成的回調(completionHandler),并在后面的方法中執行回調以便更新界面:

- (void)                  application:(UIApplication *)application 
  handleEventsForBackgroundURLSession:(NSString *)identifier completionHandler:(void (^)())completionHandler
{
    // 你必須重新建立一個后臺 seesiong 的參照
    // 否則 NSURLSessionDownloadDelegate 和 NSURLSessionDelegate 方法會因為
    // 沒有 對 session 的 delegate 設定而不會被調用。參見上面的 backgroundURLSession
    NSURLSession *backgroundSession = [self backgroundURLSession];

    NSLog(@"Rejoining session with identifier %@ %@", identifier, backgroundSession);

    // 保存 completion handler 以在處理 session 事件后更新 UI
    [self addCompletionHandler:completionHandler forSession:identifier];
}

- (void)URLSessionDidFinishEventsForBackgroundURLSession:(NSURLSession *)session
{
    NSLog(@"Background URL session %@ finished events.\n", session);

    if (session.configuration.identifier) {
        // 調用在 -application:handleEventsForBackgroundURLSession: 中保存的 handler
        [self callCompletionHandlerForSession:session.configuration.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];
}

- (void)callCompletionHandlerForSession: (NSString *)identifier
{
    CompletionHandlerType handler = [self.completionHandlerDictionary objectForKey: identifier];

    if (handler) {
        [self.completionHandlerDictionary removeObjectForKey: identifier];
        NSLog(@"Calling completion handler for session %@", identifier);

        handler();
    }
}

如果當后臺傳輸完成時,應用程序不再停留在前臺,那么,對于更新程序界面來說,這個兩步處理過程是必要的。此外,如果當后臺傳輸完成時,應用程序根本沒有在運行,iOS 將會在后臺啟動該應用程序,然后前面的應用程序和會話的委托方法會在 application:didFinishLaunchingWithOptions: 方法被調用之后被調用。

配置和限制
我們簡單地體驗了后臺傳輸的強大之處,但你應該深入文檔,閱讀 NSURLSessionConfiguration 部分,以便最好地滿足你的使用場景。例如,NSURLSessionTasks 通過 NSURLSessionConfiguration 的 timeoutIntervalForResource 屬性,支持資源超時特性。你可以使用這個特性指定你允許完成一個傳輸所需的最長時間。內容只在有限的時間可用,或者在用戶只有有限 Wifi 帶寬的時間內無法下載或上傳資源的情況下,你也可以使用這個特性。

除了下載任務,NSURLSession 也全面支持上傳任務,因此,你可能會在后臺將視頻上傳到服務器,這保證用戶不需要再像 iOS 6 那樣保持應用程序前臺運行。如果當傳輸完成時你的應用程序不需要在后臺運行,一個比較好的做法是,把 NSURLSessionConfiguration 的 sessionSendsLaunchEvents 屬性設置為 NO。高效利用系統資源,是一件讓 iOS 和用戶都高興的事。

最后,我們來說一說使用后臺會話的幾個限制。作為一個必須實現的委托,您不能對 NSURLSession 使用簡單的基于 block 的回調方法。后臺啟動應用程序,是相對耗費較多資源的,所以總是采用 HTTP 重定向。后臺傳輸服務只支持 HTTP 和 HTTPS,你不能使用自定義的協議。系統會根據可用的資源進行優化,在任何時候你都不能強制傳輸任務在后臺進行。

另外,要注意的是在后臺會話中,NSURLSessionDataTasks 是完全不支持的,你應該只出于短期的,小請求為目的使用這些任務,而不是用來下載或上傳。

總結

iOS 7 中強大的多任務和網絡 API 為現有應用和新應用開啟了一系列全新的可能性。如果你的應用程序可以從進程外的網絡傳輸和數據中獲益,那么盡情地使用這些美妙的 API。一般情況下,你可以就像你的應用正在前臺運行那樣去實現后臺傳輸,并進行適當的界面更新,而這里絕大多數的工作都已經為你完成了。

使用適當的新 API 來為你的應用程序提供內容。
盡可能早地調用 completion handler 以提高效率。
讓 completion handler 為應用程序更新界面快照。
擴展閱讀

[WWDC 2013 session “What’s New with Multitasking”]
(https://developer.apple.com/videos/)
[WWDC 2013 session “What’s New in Foundation Networking”]
(https://developer.apple.com/videos/)
[URL Loading System Programming Guide]
(https://developer.apple.com/library/content/documentation/Cocoa/Conceptual/URLLoadingSystem/URLLoadingSystem.html#//apple_ref/doc/uid/10000165i)

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容