Slug gwendolyn background networking

应用沉睡之时:后台传输服务

当用户在下载文件的时候,强制让用户一直保持应用的开启就如同当水壶在烧水的时候,强迫人一直看着水有没有烧开。在本次演讲中,Gwendolyn Weston 将会给大家介绍如何使用 iOS 的后台传输服务(Background Transfer Service) API 在后台下载文件,演讲中还涉及到了实现过程中的常见问题以及使用案例。了解如何在您的应用中轻松地实现此功能,以减少用户的时间,提高用户的满意度。🍵


概述 (0:00)

大家好,我是 Gwendolyn Weston,是 PlanGrid 公司的一名开发者。今天在这里我想跟大家谈一谈关于“后台传输服务”的有关知识,我们就只围绕后台下载来进行介绍,教大家如何在应用中实现此项功能。首先,我想先讨论一下如何在前台实现下载机制,因为可能有人对 NSURLSession 不是很熟悉。然后,我将会谈论如何让这个下载功能请求与后台兼容。最后,我会演示如何避开该功能使用过程中常见的陷阱。

后台传输服务 — 我们用水壶来比喻 (0:14)

后天传输服务是 iOS 7 引进的 API,它准许应用暂停或者中止之后,在后台继续执行网络服务(比如下载或者上传)。举个例子,这正是 Dropbox 为什么能够在后台执行同步文件到设备功能的原因。

为了解释这个功能为什么很有用,请试想一下我们有一个水壶。我们将水倒入,按下烧水键,这时候我们本应该离开去做别的事,然而不行,我们必须站在水壶旁边,否则的话水壶就不能烧水了。这是一个非常奇怪的规则,我们不得不遵循它。我们只好站在水壶旁边,拿出手机开始刷微博。当我们看到好友们三三两两的出去游玩,而我们却没有办法,为什么呢?“我们必须站在水壶旁边,否则的话水壶就不能烧水”。于是,这个水壶就不会有人用了,就像前台下载也不会有人喜欢一样。

用例 (2:02)

我所在的公司 PlanGrid 可以被形容为是一个“施工图纸的 GitHub”,它为施工项目提供了版本控制以及项目管理的功能。

有一个很常见的用例:某个承包商登入了网站,标记了一个图纸。这个标记会同步到其他承包商的设备中,以节省人们的时间、精力和金钱,因为人们无需再重新打印这个图纸,每一步变化都能够即时展现。

这就意味着我们的用户经常会上传高清的图纸到我们的仓库当中。每个新加入此项目中的人,通常都需要花费数个小时将所有的图纸同步到设备当中。我们必须告诉用户修改它们的设备设置,取消屏幕自动锁定,将应用一直开着直到下载全部完成。这是不是非常让人恼火?同样这个操作也是一个巨大的安全隐患,因为你不知道一个未锁定的设备在你离开期间会发生什么事情,而这个设备此时往往包含了重要的图纸信息。如果我们能够告诉用户:“点击下载后,你就不用管了,都交给我们了!”,那一切就大有不同了。

没有后台传输服务的下载就像一直盯着水壶烧水那样无趣。而包含这项功能的话,你就可以不用盯着水壶烧水了,你只需设定好相关功能,然后做自己的事情就可以了。

前台下载 (4:41)

下面是示例代码:

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()
}

Receive news and updates from Realm straight to your inbox

让我们一行一行来看。第一行是我们远程服务器中存放的文件地址。我们前往 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 的话,它会使用默认的委托队列。在我们将这些代码加回我们的初始示例之前,我想要强调一下这些标识符必须是唯一的。至于为什么,我们首先需要了解一下后台请求的生命周期。

后台请求的生命周期 (11:02)

我们在 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:) 中自行实现。注意到我们在这个打算放入数据库并执行检索的序列化模型中添加了文件路径。我们同样也使用 sessionIdtaskId 作为主键以便能够存储这个模型。下面是我们添加的示例代码:

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: NSURLSessionDownloadTask, didFinishDownloadingToURL location:
  NSURL) {
  if let sessionId = session.configuration.identifier {
    let persistedModel = fetchModel(sessionId, taskId:downloadTask.taskIdentifier)
    if let path = location.path {
      try! NSFileManager.defaultManager().moveItemAtPath(path, toPath:persistedModel.filepath)
    }
}

我们创建了下载任务,还同样创建了所有我们所需要的模型信息,并将其存储在数据库当中。接下来,在我们的委托方法中,当我们需要返回文件路径的时候,我们只需要用会话 ID 以及任务标识符作为键来检索这个模型即可。

结论 (18:35)

一般情况下,作为最基本的后台下载来说,这些代码就已经足够了。总的来说,我们首先创建了后台配置用以告知系统我们想让该会话中所有的任务都能够在后台运行。接着,我们将代码从完成处理回调中移到了委托方法里面。然后,我们用某种方法存储了我们在委托方法中所需的请求信息,这样我们就可以在需要的时候检索调用。

这并没有结束,借助这个 API 我们还有许多事情可以做。我们可以显示进度,取消下载,或者在错误发生的时候重置下载等等。此外,还有很多潜在的问题等着你去发现,去解决。

我希望本次讲座能够帮助大家将后台下载功能添加到应用当中,这样用户就不必一直开着前台等待下载了。谢谢大家!

About the content

This content has been published here with the express permission of the author.

Gwendolyn Weston

Gwendolyn Weston is a developer at PlanGrid where she works on version control for construction blueprints. Outside of that, she likes to rock climb and obsess over the color purple.

4 design patterns for a RESTless mobile integration »

close