iOS10中PushNotification大改革(未完待续...)

PushNotification发展历史

iOS 10 中,把以前杂乱的和通知相关的 API 都统一了,现在开发者可以使用独立的UserNotifications.framework来集中管理和使用 iOS 系统中通知的功能。在此基础上,Apple 还增加了撤回单条通知,更新已展示通知,中途修改通知内容,在通知中展示图片视频,自定义通知 UI 等一系列新功能,非常强大。

对于开发者来说,相较于之前版本,iOS 10 提供了一套非常易用的通知处理接口,是 SDK 的一次重大重构。而之前的绝大部分通知相关 API 都已经被标为弃用 (deprecated)。下面我们来回顾一下PushNotification的发展历程。

iOS 3 - 引入推送通知:ApplicationregisterForRemoteNotificationTypesUIApplicationDelegateapplication(_:didRegisterForRemoteNotificationsWithDeviceToken:),application(_:didReceiveRemoteNotification:)

iOS 4 - 引入本地通知scheduleLocalNotificationpresentLocalNotificationNow:application(_:didReceive:)

iOS 5 - 加入通知中心页面
iOS 6 - 通知中心页面与 iCloud 同步

iOS 7 - 后台静默推送application(_:didReceiveRemoteNotification:fetchCompletionHandle:)

iOS 8 - 重新设计 Notification权限请求,Actionable 通知registerUserNotificationSettings(_:)UIUserNotificationActionUIUserNotificationCategoryapplication(_:handleActionWithIdentifier:forRemoteNotification:completionHandler:)

iOS 9 - Text Input action,基于 HTTP/2 的推送请求UIUserNotificationActionBehavior,全新的 Provider API 等

现状分析

有点晕,不是么?一个开发者很难在不借助于文档的帮助下区分 application(_:didReceiveRemoteNotification:)application(_:didReceiveRemoteNotification:fetchCompletionHandle:),新入行的开发者也不可能明白 registerForRemoteNotificationTypesregisterUserNotificationSettings(_:)之间是不是有什么关系,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。我们使用 UIApplicationregisterForRemoteNotifications 来注册远程通知,在 AppDelegateapplication(_: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 中通知不仅支持简单的一行文字,你还可以添加 titlesubtitle,来用粗体字的形式强调通知的目的。对于远程推送,iOS 10 之前一般只含有消息的推送 payload 是这样的:

 {
   "aps":{
     "alert":"Test",
     "sound":"default",
     "badge":1
   }
 }

如果我们想要加入titlesubtitle 的话,则需要将 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 文件中取出本地化字符串,而远程推送的话,也可以在payloadalert中使用loc-key
或者title-loc-key
来进行指定。关于 payload 中的 key,可以参考这篇文档

2.触发器是只对本地通知而言的,远程推送的通知的话默认会在收到后立即显示。现在UserNotifications 框架中提供了三种触发器,分别是:在一定时间后触发 UNTimeIntervalNotificationTrigger,在某月某日某时触发UNCalendarNotificationTrigger以及在用户进入或是离开某个区域时触发UNLocationNotificationTrigger

3.请求标识符可以用来区分不同的通知请求,在将一个通知请求提交后,通过特定 API 我们能够使用这个标识符来取消或者更新这个通知。我们将在稍后再提到具体用法。

4.在新版本的通知框架中,Apple 借用了一部分网络请求的概念。我们组织并发送一个通知请求,然后将这个请求提交给 UNUserNotificationCenter 进行处理。我们会在 delegate 中接收到这个通知请求对应的 response,另外我们也有机会在应用的 extension 中对request进行处理。我们在接下来的章节会看到更多这方面的内容。

在提交通知请求后,我们锁屏或者将应用切到后台,并等待设定的时间后,就能看到我们的通知出现在通知中心或者屏幕横幅了:


关于最基础的通知发送,可以参考DemoTimeIntervalViewController
的内容。

取消和更新

在创建通知请求时,我们已经指定了标识符。这个标识符可以用来管理通知。在 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 中找到相关信息。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,716评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,558评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,431评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,127评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,511评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,692评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,915评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,664评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,412评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,616评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,105评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,424评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,098评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,096评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,869评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,748评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,641评论 2 271

推荐阅读更多精彩内容

  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 11,617评论 4 59
  • 极光推送: 1.JPush当前版本是1.8.2,其SDK的开发除了正常的功能完善和扩展外也紧随苹果官方的步伐,SD...
    Isspace阅读 6,600评论 10 16
  • 努力吧吹鼓手 我們圍繞你的身邊 世界就在你的手間 Photo by Dnyaneshwar Vaidya
    憨憨爹阅读 170评论 0 0
  • 一觉睡醒,不知身处何方,倍感孤独; 回顾过往历程 好像从未真正踏入社会,不明人情世故 从刚来珠海便结识了韦家人开始...
    Lisa姚阅读 183评论 0 0
  • 今天是第二天了!时差没倒过来!加上飞机上的疲惫,估计得几天恢复了!早上10点多和儿子起床,煎了个鸡蛋,炒了培根,然...
    妮儿_ebf9阅读 139评论 0 1