iOS 10 by Tutorials 笔记(八)

Chapter 8: User Notifications

苹果在 iOS 3 上实现了远程推送通知,iOS 4 上实现了本地推送,这些年来用户通知一直没什么大变化,而在 iOS 10 苹果对通知做了大刀阔斧地改造。

  • Media attachments 现在可以在通知中添加多媒体附件了
  • Notification Content extensions 用来创建自定义的通知界面
  • Managing notifications 管理通知有了新接口
  • Notification Service app extensions 当远程通知到达时,让你有机会处理一番再推送给用户

本章我们来完成一个拥抱仙人掌的小应用,结构很简单。一个 TableView + 一个普通的 ConfigurationView 界面

具体的工程结构也很简单:

  • NotificationTableViewController.swift
  • ConfigurationViewController.swift
  • Main.storyboard
  • Utilities
  • Supporting Files

The User Notifications framework

iOS 10 推出了全新的 UserNotifications.framework 功能更加强大,这个新框架的核心要属 UNUserNotificationCenter 了。它作为单例来访问,管理着用户鉴权,定义通知以及相关操作,安排本地通知推送,以及为已有的通知提供管理接口。

第一步还是一如既往地先来鉴权,打开 TableView 的时候就向用户请求权限,在NotificationTableViewController.swift 的 viewDidLoad() 中添加鉴权代码

UNUserNotificationCenter.current()  
  .requestAuthorization(options: [.alert, .sound]) {
    (granted, error) in
    if granted {
      self.loadNotificationData()
    } else {
      print(error?.localizedDescription)
    }
}

Scheduling notifications

获得用户授权后,我们来安排定时推送,打开 ConfigurationViewController.swift

  • Cuddle me now! 按钮对应着 handleCuddleMeNow(_:) 方法,它最终调用 scheduleRandomNotification(in:completion:) 方法
  • Schedule 按钮对应着 scheduleRandomNotifications(_:completion:) 方法,它也会调用了 scheduleRandomNotification(in:completion:) 方法

我们来重点关注下二者都会调用的 scheduleRandomNotification(in:completion:) 方法,此刻它只是随机从 bundle 中找一张图,console 里打印出基本信息,并不会做与通知相关的事情。

创建一个通知内容(content),设置触发时间,创建通知请求(request),最后添加到通知中心上(单例)

// 1
let content = UNMutableNotificationContent()  
content.title = "New cuddlePix!"  
content.subtitle = "What a treat"  
content.body = "Cheer yourself up with a hug ) " //TODO: Add attachment  
// 2
let trigger = UNTimeIntervalNotificationTrigger(  
  timeInterval: seconds, repeats: false)
// 3
let request = UNNotificationRequest(  
  identifier: randomImageName, content: content, trigger: trigger)
// 4
UNUserNotificationCenter.current().add(request, withCompletionHandler:  
{ (error) in
  if let error = error {
    print(error)
    completion(false)
  } else {
    completion(true)
  }
})

运行一下,点击 Cuddle me now! 按钮,按 Home 键切到主屏幕

Adding attachments

我们现在可以在通知上添加多媒体附件了,先添加一张图片练练手,继续回到 scheduleRandomNotification(in:completion:) 方法中,为通知内容(content)添加附件(attachment)

let attachment = try! UNNotificationAttachment(identifier:  
  randomImageName, url: imageURL, options: .none)
content.attachments = [attachment]  

当通知被成功添加(add)到通知中心后,会创建一个针对附件(如图片)的安全链接,这样 Notification Content extensions 就能访问到这个文件

运行一下,点击 Cuddle me now! 按钮,切到主屏幕,它在调用 scheduleRandomNotification(in:completion:) 时,第一个参数传入 5 秒,这样推送通知就会延迟 5 秒触发。

Foreground notifications

UNUserNotificationCenterDelegate 协议定义了收到通知后的一些行为方法,这就包括 iOS 10 新加入的『在前台显示系统推送通知』的能力。

我们让 AppDelegate 遵守这个协议,在 application(_:didFinishLaunchingWithOptions:) 里添加

UNUserNotificationCenter.current().delegate = self  

下面的代理方法定义了在系统前台时收到系统通知该如何处理,我们在回调方法中传入了 .alert,让它以这种形式展示通知,你也可以传入空数组,什么也不做

extension AppDelegate: UNUserNotificationCenterDelegate {  
  func userNotificationCenter(_ center: UNUserNotificationCenter,
      willPresent notification: UNNotification,
      withCompletionHandler completionHandler:
      @escaping (UNNotificationPresentationOptions) -> Void) {
        completionHandler(.alert)
  }
}

运行一下,点击 Cuddle me now! 按钮,这次留在程序主界面,五秒后,通知准时到来

Managing notifications

如果你开启了很多推送通知,稍微长时间不碰手机,就会积攒很多推送,清理这些通知是件麻烦的事情。尤其针对一些实时性要求比较高的 App,比如体育球赛类的应用会推送实时比分,过时的比分显然毫无用处。

iOS 10 新推出的 UNUserNotificationCenter 现在提供了一些访问方法,允许以代码的方式动态移除那些待命状态(pending)和已经推送到达(delivered)的通知了,从而让用户的通知中心面板变得清爽一些。

除了删除无意义的通知,这些访问方法还能读取、设置通知的 categories

Querying Notification Center

首先通过新的 API 来读取应用的通知设置状态,然后将结果详情展示在我们的 TableView 上。打开 NotificationTableViewController.swift 找到 loadNotificationData(callback:) 方法,该方法在刷新 tableView 前调用来更新数据源,所以可以用来读取通知设置状态。

func loadNotificationData(callback: (() -> ())? = .none) {  
  let group = DispatchGroup()
  // group 内的任务完成后执行回调
  // This function schedules a notification block to be submitted to the specified queue when all blocks associated with the dispatch group have completed. If the group is empty (no block objects are associated with the dispatch group), the notification block object is submitted immediately. When the notification block is submitted, the group is empty.

  let notificationCenter = UNUserNotificationCenter.current()
  let dataSaveQueue = DispatchQueue(label: "com.raywenderlich.CuddlePix.dataSave")

  group.enter()
  notificationCenter.getNotificationSettings { settings in
    let settingsProvider = SettingTableSectionProvider(settings: settings, name: "Notification Settings")
    dataSaveQueue.async {
      self.tableSectionProviders[.settings] = settingsProvider
      group.leave()
    }
  }

  group.notify(queue: DispatchQueue.main) {
    if let callback = callback {
      callback()
    } else {
      self.tableView.reloadData()
    }
  }
}

获取『通知设置』核心的方法就是 getNotificationSettings(callback:) 具体的结果 UNNotificationSettings 通过回调传回。

注意:这个 callback 是异步执行的,所以调用此方法会立即返回,回调结果会在其他线程执行

运行程序,通过鉴权后,就能看到应用的通知设置以列表的形式展示在 TableView 上了

除了通过 UNUserNotificationCenter 读取通知设置状态,还能获取待推送的通知和已推送的通知

继续在上面的方法后面添加

group.enter()  
notificationCenter.getPendingNotificationRequests { (requests) in  
  let pendingRequestsProvider =
    PendingNotificationsTableSectionProvider(requests:
      requests, name: "Pending Notifications")
  dataSaveQueue.async(execute: {
    self.tableSectionProviders[.pending] = pendingRequestsProvider
    group.leave()
  }) 
}

group.enter()  
notificationCenter.getDeliveredNotifications { (notifications) in  
  let deliveredNotificationsProvider =
    DeliveredNotificationsTableSectionProvider(notifications:
      notifications, name: "Delivered Notifications")
  dataSaveQueue.async(execute: {
    self.tableSectionProviders[.delivered]
      = deliveredNotificationsProvider
    group.leave()
  })
}

group.notify(queue: DispatchQueue.main) {  
  if let callback = callback {
    callback()
  } else {
    self.tableView.reloadData()
  }
}

这里将所有的 get 操作都放到一个 group 中,是因为所有 get 操作的回调函数都是异步执行的。我们要等 group 中所有 get 操作的回调函数都处理完毕(会调用 group.leave 方法),才算数据源更新完毕,才能刷新 tableView。

而将所有针对字典 self.tableSectionProviders 的赋值操作都放进了 dataSaveQueue 队列中异步执行,为了避免产生并发问题(get 回调的异步线程不受我们控制)。

注意一定要将 group.notify 放到最后

运行一下,继续触发五秒后通知,在 TableView 上能看到通知的详细状态了

收到通知后,通知状态由 Pending 变成了 Delivered ,但必须手动刷新下列表,TableView 才能更新过来。

需要收到通知后触发刷新 TableView,我们又要用到 userNotificationCenter(_:willPresent:withCompletionHandler) 这个代理方法,在该方法内部加入下面代码:

NotificationCenter.default.post(name:  
  userNotificationReceivedNotificationName, object: .none)

我们在 TableViewController 中监听了名为 userNotificationReceivedNotificationName 的通知,一收到就刷新数据源和 TableView

Modifying notifications

想象一个体育直播 App 一场进行中的比赛,每次出现新比分都是直接在一个推送通知上进行修改,而不是每次推送一个新通知,这样体验会好很多。

更新通知也很直接,直接根据 identifier 在已有的通知上创建一个新的 UNNotificationRequest,然后传入你的更新内容,最后添加到 UNUserNotificationCenter 上,一旦满足触发条件,就会覆盖现有的通知。

相当于复用一个通知

推送通知是一件严肃的事情,太多的通知容易给用户带来困扰,我们可以删除掉那些还未推送待命状态(Pending)的通知。

为了演示这一点,我们通过删除 TableView 上对应的 Cell 来删除数据源中 Pending 的通知,找到 tableView(_:commit:forRowAt:) 方法加入下面代码:

// 1
guard let section =  
  NotificationTableSection(rawValue: indexPath.section),
  editingStyle == .delete && section == .pending else { return }
// 2
guard let provider = tableSectionProviders[.pending]  
  as? PendingNotificationsTableSectionProvider else { return }
let request = provider.requests[indexPath.row]  
// 3
UNUserNotificationCenter.current()  
  .removePendingNotificationRequests(withIdentifiers:
    [request.identifier])
loadNotificationData(callback: {  
  self.tableView.deleteRows(at: [indexPath], with: .automatic)
})

具体步骤就是找出对应的 request,然后根据 request.identifier 在通知中心中删除 Pending 状态的通知,最后在回调方法中更新 TableView 界面

运行一下,现在可以删除正在待命的通知了

Notification content extensions

iOS 10 还有一大变化就是推出了全新的 Notification Content extensions,你可以为通知提供自定义的界面了,虽然交互有限,自定义通知的界面并不像 View 支持那么丰富的手势操作,但 extension 可以根据具体的 actions 来更新 View

actions 其实和 Alert 提供的 actions 类似,提供几个按钮供用户选择

创建的 Notification Content extensions 必须遵守 UNNotificationContentExtension 协议,便于监听通知和相关操作

此外,extension 一般是独立的二进制包,没有直接访问主程序资源的权限,因此如果需要通过 UNNotificationAttachment 类型来传递附件资源

Creating an extension with an attachment

要创建一个 Notification Content extensions 打开 File\New\Target,选择 iOS\Application Extension\Notification Content 模板

我们创建了一个名为 ContentExtension 的扩展,包含三个文件

打开 MainInterface.storyboard 一探究竟,整个故事板上就一个孤零零的 NotificationViewController 我们来定制它的界面(View)

拖一个 UIImageView 以及一个 emoji 表情上去

然后在 VC 中设置好对应的 @IBOutlet var imageView: UIImageView!

收到通知时会调用 didReceive(_:) 方法(UNNotificationContentExtension 协议提供),我们可以从该方法提供的 notification 参数中获取到需要的数据,来设置 imageView

func didReceive(_ notification: UNNotification) {  
  guard let attachment = notification.request.content.attachments.first
    else { return }
  if attachment.url.startAccessingSecurityScopedResource() {
    let imageData = try? Data.init(contentsOf: attachment.url)
    if let imageData = imageData {
      imageView.image = UIImage(data: imageData)
    }
    attachment.url.stopAccessingSecurityScopedResource()
  }
}

Attachments 位于应用的沙盒之中,并不在扩展中,所以必须显式调用 startAccessingSecurityScopedResource() 得到一个安全的 url,进而访问数据,结束后也要记得关闭这条安全通道 stopAccessingSecurityScopedResource()

extension 设置完毕了,但是当推送通知到达时,系统是如何知道可以发送给哪些 extension。答案是通知中心依赖 extension 中的 plist 来找出该 extension 能处理的通知类型

打开 Info.plistContentExtension 下的 NSExtensionAttributes,设置 UNNotificationExtensionCategorynewCuddlePix,标记了能够处理通知的 id

另外一个 UNNotificationExtensionInitialContentSizeRatio 表示通知展开后的比例(宽和高)

现在系统知道了 categorynewCuddlePix 的通知交给 extension 来处理

我们将要推送新通知的 category 都设为 "newCuddlePix"(在 ConfigurationViewController.swift 里的 scheduleRandomNotification(in:completion:) 方法中设置)

content.categoryIdentifier = newCuddlePixCategoryName  

现在系统在即将推送通知前,会先检查通知的 identifier,然后尝试找出是否有注册的 extension 来处理它。

对于远程推送通知,也是一样的道理

先在确保在主程序 CuddlePix scheme 上运行一次,然后切换到 ContentExtension scheme 运行,弹出窗口选择 CuddlePix 运行

接着点击 Cuddle me now!按钮,当通知在顶端出现后,重压屏幕或下拉展开,你会看到 extension 所展示的通知界面

如果你想移除系统所提供的标题、内容文本,可以在 extension plist 里添加 UNNotificationExtensionDefaultContentHidden,设置为 true

Handling notification actions

自定义视图的通知另一大利器就是提供了可交互性,Notification Content extensions 并不能直接处理触摸操作,但是可以配合系统提供的自定义按钮和用户交互,然后根据用户的操作直接更新界面。比如当你收到一条邀请,你可以直接在通知上更新邀请的日期。

在背后驱动这一切的是 UNNotificationCategory,它定义了独一无二的通知类型以及关于该通知的操作。具体的操作封装为 UNNotificationAction 对象。

当你配置完毕然后添加到 UNUserNotificationCenter 中,这些对象可以帮助你找到在 App 或 extensions 中可操作的通知进行处理。

Defining the action

我们的目标是传播快乐,所以要加一层蒙版动画,让星星闪烁起来。第一步先在 App 中注册 notification category 和 action,打开 AppDelegate.swift 添加

func configureUserNotifications() {  
// 1
  let starAction = UNNotificationAction(identifier:
    "star", title: "🌟 star my cuddle 🌟", options: [])
// 2
  let category =
    UNNotificationCategory(identifier: newCuddlePixCategoryName,
      actions: [starAction],
      intentIdentifiers: [],
      options: [])
// 3
  UNUserNotificationCenter.current()
    .setNotificationCategories([category])
}
  1. UNNotificationAction 有两个任务:一是展示执行的内容,二是对要执行的动作进行了唯一标识,这样 controller 就能操作他们了。这里需要一个标题和一个标识符。
  2. 定义了一个 UNNotificationCategory,使用了之前提到的 newCuddlePixCategoryName 常量(即 "newCuddlePix",会由 extension 处理),然后封装了 action 进数组作为参数。
  3. 最后通过 setNotificationCategories() 方法传递这个配置好的 category

然后在 App 启动时设置 application(_:didFinishLaunchingWithOptions:)

configureUserNotifications()  

运行一下,发现这个 Action 按钮添加成功了 试着点击下 star my cuddle 这个按钮,毫无反应,下面我们来实现。

Handling and forwarding extension responses

Notification extensions 在收到 action 按钮被触发的请求后,会触发 UNNotificationContentExtension 协议的另一个 receive 方法,我们在该方法内部做决定:是自己处理,还是转发给 App 处理

internal func didReceive(_ response: UNNotificationResponse,  
                  completionHandler completion:
    @escaping (UNNotificationContentExtensionResponseOption) -> Void) {
    // 1 先判断 action 的标识,如果确定就显示星星动画
  if response.actionIdentifier == "star" {
    imageView.showStars()
    let time = DispatchTime.now() +
      DispatchTimeInterval.milliseconds(2000)
    DispatchQueue.main.asyncAfter(deadline: time) {
    // 2 动画完成后让通知消失
      completion(.dismissAndForwardAction)
    }
  } 
}

整个动画会持续两秒钟

作者专门写了一个类库来实现星星动画 imageView.showStars() 这里就不详述了

至此,Notification extensions 的任务已经完成了,那么最后那个闭包 completion(.dismissAndForwardAction) 将消息转发给谁呢?答案是主程序 App,显然它也需要一个接收消息的地方,UNUserNotificationCenterDelegate 也为我们提供了现成的协议方法

打开 AppDelegate.swift 在 UNUserNotificationCenterDelegate extension 下添加

func userNotificationCenter(_ center: UNUserNotificationCenter,  
                            didReceive response: UNNotificationResponse,
                            withCompletionHandler
  completionHandler: @escaping () -> Void) {
  print("Response received for \(response.actionIdentifier)")
  completionHandler()
}

在这个协议方法里我们接收了来自 Notification extensions 转发的 action 消息,做了输出确认,最后的 completionHandler() 方法告诉 user notification center 你已经对 action 处理完毕了

再次运行点击 Response received for star 按钮后,这次不仅能欣赏到动画,还能看到终端输出

Response received for star  

最后再总结一下『从前台接受到通知到处理完毕』整个流程

  1. UNUserNotificationCenterDelegate 的 userNotificationCenter(_:willPresent:withCompletionHandler:) 方法被调用(仅在前台),然后决定是否展示这条通知
  2. UNNotificationContentExtension 的 didReceive(_:) 方法被调用,你可以再此方法中改造通知的界面
  3. 如果在通知中加入了可交互的 action 按钮,当用户点击后,会调用 UNNotificationContentExtension 的 didReceive(_:completionHandler:) 方法
  4. UNNotificationContentExtension 通过最后带 dismissAndForwardAction 参数的回调,将 action 转发给主程序,在 UNUserNotificationCenterDelegate 协议的 userNotificationCenter(_:didReceive:withCompletionHandler:) 方法中可以捕获到

Notification Service app extensions

除了修改本地通知,iOS 10 还允许拦截远程推送通知,然后动态修改后再展示出来,比如动态添加一个媒体附件或一段解密内容。

我们这里主要演示添加一个张图片附件,首先你需要配置下环境:接收远程通知只能在真机上调试,需要开发者帐号(付费),最后测试推送用到 Pusher 这个工具工具

设置好开发者帐号就能开启推送了

Pusher 需要一个 PCKS #12 文件和设备的 push token,前者按照它提供的 ReadMe 操作导入即可,要拿到 device push token 我们需要去程序里手动取

打开 AppDelegate.swiftapplication(_:didFinishLaunchingWithOptions) 方法中先注册远程推送,然后添加两个方法,分别对应了注册成功和失败的情形:

extension AppDelegate {  
  // 1 注册失败
  func application(_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error) {  
      print("Registration for remote notifications failed")
      print(error.localizedDescription)
  }
  // 2 注册成功,输出 device push token 
  func application(_ application: UIApplication,
    didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
      print("Registered with device token: \(deviceToken.hexString)")
  }
}

运行程序,现在我们可以从 debug console 里拿到 device token 了。万事俱备,打开 Pusher 推送一发,内容填入:

{
"aps":{
      "alert":{
         "title":"New cuddlePix!",
         "subtitle":"From your friend",
"body":"Cheer yourself up with this remote hug ) " },
      "category":"newCuddlePix"
   }
}

点击 Push 推送,收到通知~

Creating and configuring a Notification Service extension

如果现在你展开这个远程推送来的通知,发现通知内容之前显示图像的部分是空的,这是因为我们之前的本地通知是由 Cuddle me now! 来触发的,我们在其中定制了一条本地通知,并添加了本地图片资源。现在我们远程推送的通知无法直接附加资源文件,但可以通过附带资源链接来达到同样的目的。

在将远程通知推送给用户前,我们可以利用 Notification Service app extensions 提供的新特性先拦截通知,将图片资源链接下载到本地变成图片,放到通知内容合适的位置,再推送给用户

据此来改造下推送内容

{
"aps":{
      "alert":{
         "title":"New cuddlePix!",
         "subtitle":"From your friend",
"body":"Cheer yourself up with this remote hug ) " },
      "category":"newCuddlePix",
     "mutable-content": 1
},
   "attachment-url": "https://wolverine.raywenderlich.com/books/i10t/
notifications/i10t-feature.png"  
}

增加了两项:

  1. mutable-content 表示 Notification Service extension 是否要修改通知内容,1 表示 true,0 表示 flase(默认)
  2. 加了一条 attachment-url 即图片外链,本例是这样,当然你也可以根据自己需求设置其他文件类型

现在 Notification Service extension 可以根据 attachment-url 载入资源文件了,下面有请主角出场,打开 File\New\Target,选择 iOS\Application Extension\Notification Service Extension,创建一个名为 ServiceExtension 的扩展。它的结构也很简单,包含两个文件:

NotificationService 继承自类 UNNotificationServiceExtension,提供了两个方法:

  1. didReceive(_:withContentHandler) 当 extension 收到一个通知时被调用,系统给它有限的时间去修改通知内容。它可以从第一个参数中将原通知内容拷贝出来,然后做完修改再传递给它的第二个参数(回调函数)里去更新
  2. serviceExtensionTimeWillExpire() 当第一个方法没有及时返回时,系统会调用这个方法。其实就是多一层保险,你可以在里面想办法将修改完的通知内容提交到第一个方法的 contentHandler 中去,如果什么都不做,系统就会返回默认的通知内容。

来看代码:

class NotificationService: UNNotificationServiceExtension {

  var contentHandler: ((UNNotificationContent) -> Void)?
  var bestAttemptContent: UNMutableNotificationContent?

  override func didReceive(_ request: UNNotificationRequest,
                           withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
    self.contentHandler = contentHandler
    bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)

    if let bestAttemptContent = bestAttemptContent {
      // Modify the notification content here...
    guard let attachmentString = bestAttemptContent.userInfo["attachment-url"] as? String,
      let attachmentUrl = URL(string: attachmentString) else {return}
    let session = URLSession(configuration: URLSessionConfiguration.default)
    let attachmentDownloadTask = session.downloadTask(with: attachmentUrl, completionHandler: {
      (url, res, error) in
      if let error = error {
        print("Error downloading: \(error.localizedDescription)")
      } else if let url = url {
        let attachment = try! UNNotificationAttachment(identifier: attachmentString,
                                                       url: url,
                                                       options: [UNNotificationAttachmentOptionsTypeHintKey:kUTTypePNG])
        bestAttemptContent.attachments = [attachment]
      }
        contentHandler(bestAttemptContent)
      })

      attachmentDownloadTask.resume()
    }
  }

  override func serviceExtensionTimeWillExpire() {
    // Called just before the extension will be terminated by the system.
    // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
    if let contentHandler = contentHandler, let bestAttemptContent =  bestAttemptContent {
      contentHandler(bestAttemptContent)
    }
  }
}

简单来说就是拦截到通知后,解析出资源附件的地址,然后下载到本地一个临时位置,根据该文件创建一个 UNNotificationAttachment 对象,最后再放到 UNNotificationContent 里通过回调传给通知中心

再次运行,Pusher 推送一下,现在远程推送通知也能显示图片啦


-EOF-

chengway

认清生活真相之后依然热爱它!

Subscribe to Talk is cheap, Show me the world!

Get the latest posts delivered right to your inbox.

or subscribe via RSS with Feedly!