1. 程式人生 > >iOS:應用沉睡之時 後臺傳輸服務

iOS:應用沉睡之時 後臺傳輸服務

轉自:https://realm.io/cn/news/gwendolyn-weston-ios-background-networking/

如有侵犯,請來信[email protected]

當用戶在下載檔案的時候,強制讓使用者一直保持應用的開啟就如同當水壺在燒水的時候,強迫人一直看著水有沒有燒開。在本次演講中,Gwendolyn Weston 將會給大家介紹如何使用 iOS 的後臺傳輸服務(Background Transfer Service) API 在後臺下載檔案,演講中還涉及到了實現過程中的常見問題以及使用案例。瞭解如何在您的應用中輕鬆地實現此功能,以減少使用者的時間,提高使用者的滿意度。�

大家好,我是 Gwendolyn Weston,是 PlanGrid 公司的一名開發者。今天在這裡我想跟大家談一談關於“後臺傳輸服務”的有關知識,我們就只圍繞後臺下載來進行介紹,教大家如何在應用中實現此項功能。首先,我想先討論一下如何在前臺實現下載機制,因為可能有人對 NSURLSession 不是很熟悉。然後,我將會談論如何讓這個下載功能請求與後臺相容。最後,我會演示如何避開該功能使用過程中常見的陷阱。

後臺傳輸服務 — 我們用水壺來比喻 (0:14)

後天傳輸服務是 iOS 7 引進的 API,它准許應用暫停或者中止之後,在後臺繼續執行網路服務(比如下載或者上傳)。舉個例子,這正是 Dropbox 為什麼能夠在後臺執行同步檔案到裝置功能的原因。

為了解釋這個功能為什麼很有用,請試想一下我們有一個水壺。我們將水倒入,按下燒水鍵,這時候我們本應該離開去做別的事,然而不行,我們必須站在水壺旁邊,否則的話水壺就不能燒水了。這是一個非常奇怪的規則,我們不得不遵循它。我們只好站在水壺旁邊,拿出手機開始刷微博。當我們看到好友們三三兩兩的出去遊玩,而我們卻沒有辦法,為什麼呢?“我們必須站在水壺旁邊,否則的話水壺就不能燒水”。於是,這個水壺就不會有人用了,就像前臺下載也不會有人喜歡一樣。

我所在的公司 PlanGrid 可以被形容為是一個“施工圖紙的 GitHub”,它為施工專案提供了版本控制以及專案管理的功能。

有一個很常見的用例:某個承包商登入了網站,標記了一個圖紙。這個標記會同步到其他承包商的裝置中,以節省人們的時間、精力和金錢,因為人們無需再重新列印這個圖紙,每一步變化都能夠即時展現。

這就意味著我們的使用者經常會上傳高清的圖紙到我們的倉庫當中。每個新加入此專案中的人,通常都需要花費數個小時將所有的圖紙同步到裝置當中。我們必須告訴使用者修改它們的裝置設定,取消螢幕自動鎖定,將應用一直開著直到下載全部完成。這是不是非常讓人惱火?同樣這個操作也是一個巨大的安全隱患,因為你不知道一個未鎖定的裝置在你離開期間會發生什麼事情,而這個裝置此時往往包含了重要的圖紙資訊。如果我們能夠告訴使用者:“點選下載後,你就不用管了,都交給我們了!”,那一切就大有不同了。

沒有後臺傳輸服務的下載就像一直盯著水壺燒水那樣無趣。而包含這項功能的話,你就可以不用盯著水壺燒水了,你只需設定好相關功能,然後做自己的事情就可以了。

下面是示例程式碼:

let urlstring = "https://remoteteakettle.com/boiledwater.pdf"
let filepath = "Documents/local_teakettle"
if let url = NSURL(string: urlstring) {
  let task = NSURLSession.sharedSession().downloadTaskWithURL(url, completionHandler: {
    (location:NSURL?, response:NSURLResponse?, error:NSError?) in
    if let loc = location, path = loc.path {
      try! NSFileManager.defaultManager().moveItemAtPath(path, toPath:filepath)
    }
  })
  task.resume()
}

讓我們一行一行來看。第一行是我們遠端伺服器中存放的檔案地址。我們前往 remoteteakettle.com 嘗試下載一個名為 boiledwater.pdf 的檔案。第二行我們指定了我們想要儲存該檔案的路徑。至於第三行,你會看到從NSURLSession 框架中引用了許許多多的成員物件,讓我們對其仔細解讀。

NSURLSession 用於宣告和管理網路請求。sharedSession是基於某些預設設定的會話(session)單例。比如說,它使用預設的快取機制以及超時時間。我們可以自定義自己的會話,不過預設的會話對我們來說就足夠了。最後,我們執行基於我們網路請求的 NSURLSession 的下載任務。

NSURLSessionTask (6:00)

好的,我現在需要暫停一下,先不繼續介紹剩餘的程式碼,在此之前,我想先聊一聊 NSURLSession 下載任務。下載任務實際上是 NSURLSessionTask 的一個子類,它們實際上有三種類型:

  • NSURLSessionDownloadTask
  • NSURLSessionUploadTask
  • NSURLSessionDataTask (針對即時的網路請求,例如口令驗證)

我們通過會話物件來獲取我們的會話任務,而不是為了呼叫某些會話任務的便利構造器方法。也就是說,我們通過會話 URL 來進行反饋。

這意味著什麼呢?我喜歡將 NSURLSession 視為小狗狗,而我們的 URL 則是那些骨頭,因此每次我們給它骨頭的時候,它就會被歡樂所淹沒,然後去抓兔子。而這恰好是 我們想要的 NSURLSessionTask。我們可以從同一個NSURLSession 中多次獲取不同的會話任務,只需要為會話和會話任務建立一對多的關係即可。一個會話可以有多個會話任務,但是每個會話任務只能夠給一個會話反饋。在程式碼中,我們有許多在會話物件中建立任務的方法,比如downloadTaskWithURL(_:completionHandler:)

好的,我們回到之前的程式碼,下面是談論完成處理回撥(completion handler) 裡面內容的時候了。對於這個完成處理回撥來說,它當中有三個引數:

  • Location (檔案即將下載到的臨時路徑)
  • Response (可在此獲取網路請求的狀態碼)
  • Error (如果有問題出現,可在此捕獲)

你可能注意到了,我們有對錯誤進行任何的處理,也沒有捕獲任何異常。我假設我們的程式碼處於一個沒有錯誤和異常的世界。因此,我們所有做的就是將檔案從臨時目錄移到在此之前指定的下載路徑當中。

程式碼很成功,但是當用戶切到其他應用的時候怎麼辦?我們不想讓我們的這壺熱水變成徹頭徹尾的恥辱。

後臺下載 — NSURLSessionConfiguration(9:23)

要實現後臺下載,我們需要使用 NSURLSessionConfiguration 告知系統這些任務需要後臺服務相容。之前我說過我們可以初始化自定義的會話,這樣我們可以自定義某些屬性,比如說快取機制以及超時時間。其實並不是這樣的,我們並不會用這個方式來設定會話的某些屬性。我們所做的就是在某個“配置物件”中設定這些屬性,然後使用該配置來初始化會話。這是我們邁向後臺的第一步。

我們通過某種名為後臺配置的東西來建立自定義的會話。它只不過是在配置物件上的另一種設定罷了。下面的程式碼解釋得更清晰一些:

let config = NSURLSessionConfiguration.backgroundSessionConfigurationWithIdentifier("i am the batman")
let session = NSURLSession(configuration: config, delegate: self, delegateQueue: nil)

我們繼續一行一行地看,第一行程式碼用名為backgroundSessionConfigurationWithIdentifier(_:) 的方法建立了一個會話配置,這個標誌符可以任意取名,比如說你最喜歡的顏色,Moby Dick 的首字母,或者我們這裡寫的“I am the batman”。第二行我們用這個配置初始化了一個 NSURLSession。我們將這個類本身設為委託,因此它將可以獲取該會話接收到的所有委託方法了。最後,我們設定委託佇列(delegate queue)。

委託佇列可以設定為任意一個您打算用以呼叫委託方法的佇列。不過,如果設定為 nil 的話,它會使用預設的委託佇列。在我們將這些程式碼加回我們的初始示例之前,我想要強調一下這些識別符號必須是唯一的。至於為什麼,我們首先需要了解一下後臺請求的生命週期。

我們在 Dropbox 應用中執行這個前臺網路請求。我們將檔案同步到裝置中,接著殺死這個應用。這個時候,會話中的所有網路請求仍在繼續,這是因為這些請求都在後臺進行。當系統回告應用,說“該會話中的所有請求都已結束”的時候,將會呼叫所有的 UIAppDelegate 方法,從而讓我們知道接下來該做什麼操作。

唯一區別是,除了呼叫 application:finishLoadingWithOptions 之外,它還會觸發一個新的方法,名為application:handleEventsForBackgroundURLSession。這個方法中會傳回你所定義的會話標誌符。比如說我們之前所定義的“I am the Batman”。整個過程就像這樣:名為“I am the Batman” 的會話結束了!之後您打算怎麼做呢?無論我們之後是在發生錯誤時處理錯誤,還是會話成功完成,我們都必須要在這個方法中重新建立這個會話。至於程式碼是什麼樣子的,它實際上意味著使用同一個識別符號建立另一個後臺配置,然後用這個後臺配置建立另一個會話。

func application(application: UIApplication, handleEventsForBackgroundURLSession identifier: String,
  completionHandler:() -> Void) {
    let config = NSURLSessionConfiguration.backgroundSessionConfigurationWithIdentifier(identifier)
    let session = NSURLSession(configuration: config, delegate: self, delegateQueue: NSOperationQueue.mainQueue())
    session.getTasksWithCompletionHandler { (dataTasks, uploadTasks, downloadTasks) -> Void in
      // 執行您自己的任務請求!
    }
  }

這就是您需要執行的迴圈鏈,當我們重新獲取了新的會話任務後,系統會知道“會話重新啟用,現有的任務都是與該會話建立關聯的任務”。

然而,由於系統只知道是哪一個任務是被會話識別符號單獨重建的,但是如果有兩個會話擁有同一個識別符號的話,它就力不從心了。這樣一來,系統該如何知曉哪一個任務是您想要重新建立的呢?事實上文件中有一個很不好的訊息是,多個共享相同標誌符的會話行為是不確定的。因此我們不要這樣做。現在我們將剛才建立的後臺配置的程式碼放到之前的程式碼當中去。絕大部分程式碼都差不多一樣,除了這兩行新程式碼:

let urlstring = "https://remoteteakettle.com/boiledwater.pdf"
let filepath = "Documents/local_teakettle"
if let url = NSURL(string: urlstring) {

  let config = NSURLSessionConfiguration.backgroundSessionConfigurationWithIdentifier((NSUUID().UUIDString))
  let session = NSURLSession(configuration: config, delegate: self, delegateQueue: nil)

  let task = NSURLSession.sharedSession().downloadTaskWithURL(url, completionHandler: {
    (location:NSURL?, response:NSURLResponse?, error:NSError?) in
    if let loc = location, path = loc.path {
      try! NSFileManager.defaultManager().moveItemAtPath(path, toPath:filepath)
    }
  })
  task.resume()
}

陷阱 #1: 沒有完成處理回撥 (13:03)

很遺憾的是,我們並不能讓後臺請求結束時立即做些什麼。當您試圖為後臺任務建立一個擁有完成處理回撥的任務時,控制檯會警報說這項功能不被支援。因此我們需要使用委託方法。當應用重新啟動以及在handleEventsForBackgroundURLSession() 方法中重建會話之後,應用將呼叫該會話中的所有委託方法。通常情況下,我們一般關注的是URLSession(_:downloadTask:didFinishDownloadingToURL:) 這個方法。它將會給予回撥:

  • 會話物件
  • 完成的任務
  • 檔案被下載到的臨時路徑

這就是我們要做的第二個步驟。我們將完成處理回撥中的程式碼移到這個委託方法中。下面就是程式碼應該有的樣子:

let urlstring = "https://remoteteakettle.com/boiledwater.pdf"
let filepath = "Documents/local_teakettle"
if let url = NSURL(string: urlstring) {

  let config = NSURLSessionConfiguration.backgroundSessionConfigurationWithIdentifier((NSUUID().UUIDString))
  let session = NSURLSession(configuration: config, delegate: self, delegateQueue: nil)

  let task = session.downloadTaskWithURL(url)
  task.resume()
}

func URLSession(session: NSURLSession, downloadTask: NSURLSessionDownloadTask, didFinishDownloadingToURL location:
  NSURL) {
    if let path = location.path {
      try! NSFileManager.defaultManager().moveItemAtPath(path, toPath:filepath)
    }
  }

我們使用此委託方法而不是初始化一個帶有完成處理回撥的下載任務。不幸的是,這個辦法也不是很成功,因為 filepath 在委託方法的識別範圍之外。

陷阱 #2: 沒有附屬請求資訊 (14:49)

由於我們必須獲取不同方法中關於網路請求的相關資訊,比如說最終的檔案路徑,因此我們必須用某種方式將這些資訊儲存下來。我們可以使用taskDescription 來進行處理,但是文件說明“這個屬性需要包含使用者可讀的字串,也就是說它可以展示在應用介面當中”。由於我們可能會想要儲存許多請求資訊,比如說檔案路徑、模型 UUID 或者檔名字等等內容,因此這個方法不是最佳選擇。NSURLSessionDownloadTask 的子類同樣也不是一個好選擇,因為我們的 NSURLSession 只會返回預定義的類,並不能返回我們自定義的子類。

解決方法: 儲存請求資訊 (16:43)

如果系統不給我們提供這個功能,那麼我們就自己實現它!我們將要自己儲存這些資料:

public class HalfBoiledWater: NSObject {
  public let sessionId: String
  public let taskId: Int
  public let filepath: String

  init(sessionId:String, taskId:Int, filepath:String) {
    self.sessionId = sessionId
    self.taskId = taskId
    self.filepath = filepath
  }

  func save() {
    // 儲存到資料儲存區域
  }
}

public func fetchModel(sessionId:String, taskId:Int) -> HalfBoiledWater {
  // 從資料儲存區域中檢索
}

好的,現在我們可以有多種方法來對其進行儲存了。我們每個人都有自己最喜歡的資料儲存方式。它可能是 FMDB、SQLite,甚至是 Core Data。我打算讓大家自行決定,在這兩個方法 save() 和 fetchModel(_:taskId:) 中自行實現。注意到我們在這個打算放入資料庫並執行檢索的序列化模型中添加了檔案路徑。我們同樣也使用 sessionId 和 taskId 作為主鍵以便能夠儲存這個模型。下面是我們新增的示例程式碼:

let urlstring = "https://remoteteakettle.com/boiledwater.pdf"
let filepath = "Documents/local_teakettle"
if let url = NSURL(string: urlstring) {

  let config = NSURLSessionConfiguration.backgroundSessionConfigurationWithIdentifier((NSUUID().UUIDString))
  let session = NSURLSession(configuration: config, delegate: self, delegateQueue: nil)

  let task = session.downloadTaskWithURL(url)
  if let sessionId = session.configuration.identifier {
    let persistedModel = HalfBoiledWater(sessionId:sessionId, taskId:task.taskIdentifier, filepath:filepath)
    persistedModel.save()
  }
  task.resume()
}

func URLSession(session: NSURLSession, downloadTask