PushNotification发展历史
iOS 10 中,把以前杂乱的和通知相关的 API 都统一了,现在开发者可以使用独立的UserNotifications.framework
来集中管理和使用 iOS 系统中通知的功能。在此基础上,Apple 还增加了撤回单条通知,更新已展示通知,中途修改通知内容,在通知中展示图片视频,自定义通知 UI 等一系列新功能,非常强大。
对于开发者来说,相较于之前版本,iOS 10 提供了一套非常易用的通知处理接口,是 SDK 的一次重大重构。而之前的绝大部分通知相关 API 都已经被标为弃用 (deprecated)。下面我们来回顾一下PushNotification的发展历程。
iOS 3 - 引入推送通知:Application
的registerForRemoteNotificationTypes
与UIApplicationDelegate
的application(_:didRegisterForRemoteNotificationsWithDeviceToken:),application(_:didReceiveRemoteNotification:)
iOS 4 - 引入本地通知scheduleLocalNotification
,presentLocalNotificationNow:
,application(_:didReceive:)
iOS 5 - 加入通知中心页面
iOS 6 - 通知中心页面与 iCloud 同步
iOS 7 - 后台静默推送application(_:didReceiveRemoteNotification:fetchCompletionHandle:)
iOS 8 - 重新设计 Notification
权限请求,Actionable 通知registerUserNotificationSettings(_:)
,UIUserNotificationAction
与UIUserNotificationCategory
,application(_:handleActionWithIdentifier:forRemoteNotification:completionHandler:)
等
iOS 9 - Text Input action,基于 HTTP/2 的推送请求UIUserNotificationActionBehavior
,全新的 Provider API 等
现状分析
有点晕,不是么?一个开发者很难在不借助于文档的帮助下区分 application(_:didReceiveRemoteNotification:)
和application(_:didReceiveRemoteNotification:fetchCompletionHandle:)
,新入行的开发者也不可能明白 registerForRemoteNotificationTypes
和 registerUserNotificationSettings(_:)
之间是不是有什么关系,Remote 和 Local Notification 除了在初始化方式之外那些细微的区别也让人抓狂,而很多 API 都被随意地放在了 UIApplication 或者 UIApplicationDelegate 中。除此之外,应用已经在前台时,远程推送是无法直接显示的,要先捕获到远程来的通知,然后再发起一个本地通知才能完成显示。更让人郁闷的是,应用在运行时和非运行时捕获通知的路径还不一致。虽然这些种种问题都是由一定历史原因造成的,但不可否认,正是混乱的组织方式和之前版本的考虑不周,使得 iOS 通知方面的开发一直称不上“让人愉悦”,甚至有不少“坏代码”的味道。
另一方面,现在的通知功能相对还是简单,我们能做的只是本地或者远程发起通知,然后显示给用户。虽然 iOS 8 和 9 中添加了按钮和文本来进行交互,但是已发出的通知不能更新,通知的内容也只是在发起时唯一确定,而这些内容也只能是简单的文本。 想要在现有基础上扩展通知的功能,势必会让原本就盘根错节的 API 更加难以理解。
在 iOS 10 中新加入 UserNotifications 框架,可以说是 iOS SDK 发展到现在的最大规模的一次重构。新版本里通知的相关功能被提取到了单独的框架,通知也不再区分类型,而有了更统一的行为。我们接下来就将由浅入深地解析这个重构后的框架的使用方式。
UserNotifications 框架解析
基本流程
iOS 10 中通知相关的操作遵循下面的流程:
首先你需要向用户请求推送权限,然后发送通知。对于发送出的通知,如果你的应用位于后台或者没有运行的话,系统将通过用户允许的方式 (弹窗,横幅,或者是在通知中心) 进行显示。如果你的应用已经位于前台正在运行,你可以自行决定要不要显示这个通知。最后,如果你希望用户点击通知能有打开应用以外的额外功能的话,你也需要进行处理。
权限申请
iOS 8 之前,本地推送 UILocalNotification
和远程推送 Remote Notification
是区分对待的,应用只需要在进行远程推送时获取用户同意。iOS 8 对这一行为进行了规范,因为无论是本地推送还是远程推送,其实在用户看来表现是一致的,都是打断用户的行为。因此从 iOS 8 开始,这两种通知都需要申请权限。iOS 10 里进一步消除了本地通知和推送通知的区别。向用户申请通知权限非常简单:
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) {
granted, error in
if granted {
// 用户允许进行通知
}
}
当然,在使用 UN 开头的 API 的时候,不要忘记导入 UserNotifications 框架:
import UserNotifications
第一次调用这个方法时,会弹出一个系统弹窗。
要注意的是,一旦用户拒绝了这个请求,再次调用该方法也不会再进行弹窗,想要应用有机会接收到通知的话,用户必须自行前往系统的设置中为你的应用打开通知,如果不是杀手级应用,想让用户主动去在茫茫多 app 中找到你的那个并专门为你开启通知,往往是不可能的。因此,在合适的时候弹出请求窗,在请求权限前预先进行说明,以此增加通过的概率应该是开发者和策划人员的必修课。相比与直接简单粗暴地在启动的时候就进行弹窗,耐心诱导会是更明智的选择。
一旦用户同意后,你就可以在应用中发送本地通知了。不过如果你通过服务器发送远程通知的话,还需要多一个获取用户 token 的操作。你的服务器可以使用这个 token 将用向 Apple Push Notification 的服务器提交请求,然后 APNs 通过 token 识别设备和应用,将通知推给用户。
提交 token 请求和获得 token 的回调是现在“唯二”不在新框架中的 API。我们使用 UIApplication
的registerForRemoteNotifications
来注册远程通知,在 AppDelegate
的 application(_:didRegisterForRemoteNotificationsWithDeviceToken)
中获取用户 token:
// 向 APNS 请求 token:
UIApplication.shared.registerForRemoteNotifications()
// AppDelegate.swift
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let tokenString = deviceToken.hexString
print("Get Push token: \(tokenString)")
}
获取得到的deviceToken
是一个 Data
类型,为了方便使用和传递,我们一般会选择将它转换为一个字符串。Swift 3
中可以使用下面的 Data 扩展来构造出适合传递给 Apple 的字符串:
extension Data {
var hexString: String {
return withUnsafeBytes {(bytes: UnsafePointer<UInt8>) -> String in
let buffer = UnsafeBufferPointer(start: bytes, count: count)
return buffer.map {String(format: "%02hhx", $0)}.reduce("", { $0 + $1 })
}
}
}
权限设置
用户可以在系统设置中修改你的应用的通知权限,除了打开和关闭全部通知权限外,用户也可以限制你的应用只能进行某种形式的通知显示,比如只允许横幅而不允许弹窗及通知中心显示等。一般来说你不应该对用户的选择进行干涉,但是如果你的应用确实需要某种特定场景的推送的话,你可以对当前用户进行的设置进行检查:
UNUserNotificationCenter.current().getNotificationSettings {
settings in
print(settings.authorizationStatus) // .authorized | .denied | .notDetermined
print(settings.badgeSetting) // .enabled | .disabled | .notSupported
// etc...
}
发送通知
UserNotifications
中对通知进行了统一。我们通过通知的内容 (UNNotificationContent)
,发送的时机(UNNotificationTrigger)
以及一个发送通知的 String 类型的标识符,来生成一个 UNNotificationRequest
类型的发送请求。最后,我们将这个请求添加到 UNUserNotificationCenter.current()
中,就可以等待通知到达了:
// 1. 创建通知内容
let content = UNMutableNotificationContent()
content.title = "Time Interval Notification"
content.body = "My first notification"
// 2. 创建发送触发
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)
// 3. 发送请求标识符
let requestIdentifier = "com.onevcat.usernotification.myFirstNotification"
// 4. 创建一个发送请求
let request = UNNotificationRequest(identifier: requestIdentifier, content: content, trigger: trigger)
// 将请求添加到发送中心
UNUserNotificationCenter.current().add(request) { error in
if error == nil {
print("Time Interval Notification scheduled: \(requestIdentifier)")
}
}
iOS 10 中通知不仅支持简单的一行文字,你还可以添加 title
和 subtitle
,来用粗体字的形式强调通知的目的。对于远程推送,iOS 10 之前一般只含有消息的推送 payload 是这样的:
{
"aps":{
"alert":"Test",
"sound":"default",
"badge":1
}
}
如果我们想要加入title
和 subtitle
的话,则需要将 alert
从字符串换为字典,新的 payload 是:
{
"aps":{
"alert":{
"title":"I am title",
"subtitle":"I am subtitle",
"body":"I am body"
},
"sound":"default",
"badge":1
}
}
好消息是,后一种字典的方法其实在 iOS 8.2 的时候就已经存在了。虽然当时title
只是用在 Apple Watch
上的,但是设置好body
的话在 iOS 上还是可以显示的,所以针对 iOS 10 添加标题时是可以保证前向兼容的。
另外,如果要进行本地化对应,在设置这些内容文本时,本地可以使用String.localizedUserNotificationString(forKey: "your_key", arguments: [])
的方式来从 Localizable.strings
文件中取出本地化字符串,而远程推送的话,也可以在payload
的 alert
中使用loc-key
或者title-loc-key
来进行指定。关于 payload 中的 key,可以参考这篇文档。
2.触发器是只对本地通知而言的,远程推送的通知的话默认会在收到后立即显示。现在UserNotifications
框架中提供了三种触发器,分别是:在一定时间后触发 UNTimeIntervalNotificationTrigger
,在某月某日某时触发UNCalendarNotificationTrigger
以及在用户进入或是离开某个区域时触发UNLocationNotificationTrigger
。
3.请求标识符可以用来区分不同的通知请求,在将一个通知请求提交后,通过特定 API 我们能够使用这个标识符来取消或者更新这个通知。我们将在稍后再提到具体用法。
4.在新版本的通知框架中,Apple 借用了一部分网络请求的概念。我们组织并发送一个通知请求,然后将这个请求提交给 UNUserNotificationCenter
进行处理。我们会在 delegate 中接收到这个通知请求对应的 response
,另外我们也有机会在应用的 extension
中对request
进行处理。我们在接下来的章节会看到更多这方面的内容。
在提交通知请求后,我们锁屏或者将应用切到后台,并等待设定的时间后,就能看到我们的通知出现在通知中心或者屏幕横幅了:
关于最基础的通知发送,可以参考
Demo
中TimeIntervalViewController的内容。
取消和更新
在创建通知请求时,我们已经指定了标识符。这个标识符可以用来管理通知。在 iOS 10 之前,我们很难取消掉某一个特定的通知,也不能主动移除或者更新已经展示的通知。想象一下你需要推送用户账户内的余额变化情况,多次的余额增减或者变化很容易让用户十分困惑 - 到底哪条通知才是最正确的?又或者在推送一场比赛的比分时,频繁的通知必然导致用户通知中心数量爆炸,而大部分中途的比分对于用户来说只是噪音。
iOS 10 中,UserNotifications 框架提供了一系列管理通知的 API,你可以做到:
- 取消还未展示的通知
- 更新还未展示的通知
- 移除已经展示过的通知
- 更新已经展示过的通知
其中关键就在于在创建请求时使用同样的标识符。
比如,从通知中心中移除一个展示过的通知:
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 3, repeats: false)
let identifier = "com.onevcat.usernotification.notificationWillBeRemovedAfterDisplayed"
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request) { error in
if error != nil {
print("Notification request added: \(identifier)")
}
}
delay(4) {
print("Notification request removed: \(identifier)")
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [identifier])
}
类似地,我们可以使用 removePendingNotificationRequests
,来取消还未展示的通知请求。对于更新通知,不论是否已经展示,都和一开始添加请求时一样,再次将请求提交给 UNUserNotificationCenter
即可:
// let request: UNNotificationRequest = ...
UNUserNotificationCenter.current().add(request) { error in
if error != nil {
print("Notification request added: \(identifier)")
}
}
delay(2) {
let newTrigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
// Add new request with the same identifier to update a notification.
let newRequest = UNNotificationRequest(identifier: identifier, content:newContent, trigger: newTrigger)
UNUserNotificationCenter.current().add(newRequest) { error in
if error != nil {
print("Notification request updated: \(identifier)")
}
}
}
远程推送可以进行通知的更新,在使用 Provider API 向 APNs 提交请求时,在 HTTP/2 的 header 中apns-collapse-id
key 的内容将被作为该推送的标识符进行使用。多次推送同一标识符的通知即可进行更新。
对应本地的removeDeliveredNotifications
,现在还不能通过类似的方式,向 APNs 发送一个包含 collapse id 的 DELETE 请求来删除已经展示的推送,APNs 服务器并不接受一个 DELETE 请求。不过从技术上来说 Apple 方面应该不存在什么问题,我们可以拭目以待。现在如果想要消除一个远程推送,可以选择使用后台静默推送的方式来从本地发起一个删除通知的调用。关于后台推送的部分,可以参考王巍之前的一篇关于 iOS7 中的多任务的文章。
关于通知管理,可以参考 Demo 中 ManagementViewController
的内容。为了能够简单地测试远程推送,一般我们都会用一些方便发送通知的工具,Knuff 就是其中之一。我也为 Knuff 添加了apns-collapse-id
的支持,你可以在这个 fork 的 repo 或者是原 repo 的 pull request 中找到相关信息。