前言
在 iOS 開發中,有這樣一個場景:某件重要的事情必須立刻讓用戶知道,甚至不惜以打斷用戶當前操作為代價來強調這份重要性。這就是通知(Notifiations)。目前常用的框架為 UserNotifications,它主要用來在鎖屏和應用界面通過彈窗來顯示通知。另一個框架是 Notification Center ,以它實現的跨 object 通知以及原生的 KVO(Key-Value-Observing) 是 iOS 中觀察者模式的主要實現手段。
本文內容:
- UserNotifications 介紹
- 本地通知(Local Notifications)
- 遠程通知(Remote Notifications)
- 觀察者模式(Observer Pattern)
UserNotifications 介紹
UserNotifications 是 iOS 10 剛剛引入的全新框架。與以往版本的本地通知和遠程通知分別處理不同,這次蘋果把兩者的 API 統一。從此以后,無論處理本地通知還是遠程通知,都是用 UserNotifications 框架。
UserNotifications 的流程也十分簡單,主要分以下 4 步:
- 注冊
通過調用 requestAuthorization
這個方法,通知中心會向用戶發送通知許可請求。在彈出的 Alert 中點擊同意,即可完成注冊。
- 創建
如果是本地推送,則在 AppDelegate 中設置推送參數;如果是遠程推送,則無需設置參數,推送的內容和觸發時間都在遠程服務器端配置。
- 推送
這一步就是系統或者遠程服務器推送通知。伴隨著一聲清脆的響聲(或自定義的聲音),通知對應的UI顯示到手機界面的過程。
- 響應
當用戶看到通知后,點擊進去會有相應的響應選項。如下圖:
例如 Instagram 這個 App ,用戶看到它的通知后有3個選項:一是 Like , 點擊之后就是給你朋友的照片點贊;另一個是 Quick Reply,點擊之后可以評論照片;最后是 View Post,點擊之后是進入 Instagram 主 App 進行照片瀏覽。用戶不同的選擇決定了之后的操作,筆者稱這個過程是對 Notification 的響應。
本地通知
因為通知是針對整個 App 級別的功能,所以一般在 AppDelegate 中完成注冊和創建的過程。代碼如下:
// 注冊
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { accepted, error in
if !accepted {
print("Notification access denied.")
}
}
// 創建
func scheduleNotification(at date: Date) {
// 觸發機制
let calendar = Calendar(identifier: .gregorian)
let components = calendar.dateComponents(in: .current, from: date)
let newComponents = DateComponents(calendar: calendar, timeZone: .current, month: components.month, day: components.day, hour: components.hour, minute: components.minute)
let trigger = UNCalendarNotificationTrigger(dateMatching: newComponents, repeats: false)
// 通知內容
let content = UNMutableNotificationContent()
content.title = "Tutorial Reminder"
content.body = "Just a reminder to read your tutorial over at Soapyigu's Swift30Projects!"
content.sound = UNNotificationSound.default()
// 傳入參數
let request = UNNotificationRequest(identifier: "textNotification", content: content, trigger: trigger)
// 將創建好的通知傳入通知中心
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
UNUserNotificationCenter.current().add(request) { error in
if let error = error {
print("Uh oh! We had an error: \(error)")
}
}
}
在創建過程中,有以下幾點值得注意:
- 觸發機制。如果是時間觸發,就用 UNCalendarNotificationTrigger;如果是地點觸發,就用 UNLocation?Notification?Trigger。
- 通知內容。除了標題(title)、內容(body)、聲音(sound)外,還可以添加副標題(subTitle)甚至是圖片。添加圖片的示例代碼如下:
// 將圖片添加到通知中
if let path = Bundle.main.path(forResource: "Swift", ofType: "png") {
// 通過本地圖片 Swift.png 的路徑創建 URL
let url = URL(fileURLWithPath: path)
do {
let attachment = try UNNotificationAttachment(identifier: "Swift", url: url, options: nil)
// 設置內容的附件,將圖片傳入
// 你可以傳多個圖片進入,但只會顯示第一個圖片
// 當然你也可以根據不同情況顯示不同圖片
content.attachments = [attachment]
} catch {
print("The attachment was not loaded.")
}
}
- Identifier。一個 App 可能有多種本地通知,它們之間是通過 Identifier 進行區分的。
- 將創建好的通知傳入通知中心。多個 Notifications 之間有先后順序,它們排成隊列在通知中心中。這里我們為了方便演示,刪除了以前所有的通知。
完成了注冊和創建,我們只要在合適的時間讓系統推送通知即可。代碼中表現為在某個時間點調用scheduleNotification(date)
。之后我們就可以看到相應的通知彈出:
一般情況下用戶會點擊通知直接進入 App 查看。假如要實現在通知出現時快速操作,比如過10分鐘再提醒我這樣的選項,我們又該怎么做呢?這時候我們引入UNNotificationAction
和UNNotificationCategory
。
- UNNotificationAction: 響應通知的單個具體操作。例如直接給相關推送信息點贊。
- UNNotificationCategory: 響應操作對應的類別。相當于是多個 UNNotificationAction 構成的群組,表明一類響應操作。
下面一段代碼就是創立了一個 "Remind me later" 的 UNNotificationAction 響應操作,并將其加入到 "normal" 的
UNNotificationCategory 類別之中。
let action = UNNotificationAction(identifier: "remindLater", title: "Remind me later", options: [])
let category = UNNotificationCategory(identifier: "normal", actions: [action], intentIdentifiers: [], options: [])
UNUserNotificationCenter.current().setNotificationCategories([category])
有了上面代碼,當用戶點擊通知,我們就能看到相應的快捷操作。那么用戶點擊 “Remind me later” ,我們該如何在 App 中設置對應的操作,讓系統在10分鐘后再次推送響應通知呢?
很簡單,我們只要在UNUserNotificationCenterDelegate
協議中實現userNotificationCenter(_:didReceive:withCompletionHandler:)
。當用戶點擊通知選項時,這個方法自動被調用。這里我們通過 identifier 來判斷具體是哪一個選項被點擊,再調用對應響應方法即可。
extension AppDelegate: UNUserNotificationCenterDelegate {
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
if response.actionIdentifier == "remindLater" {
let newDate = Date(timeInterval: 600, since: Date())
scheduleNotification(at: newDate)
}
}
}
遠程通知
再接觸遠程代碼的具體實現之前,我們先來看看遠程通知的原理:
- App 向 iOS 系統申請推送權限
- iOS 系統向 APNs(Apple Push Notification Service) 請求手機 device token,并告訴 App,能接受推送的通知。
- App 將手機的 device token 傳給后端
- 后端向 APNs 推送通知
- APNs 將響應通知推送給響應手機
從以上流程我們可以看出,APNs 在這里啟動了監管者和托管者的作用,無論是請求還是推送都要經過 APNs。也就是說,所有的推送都必須按照 APNs 的游戲規則來。
有人到這里要問了,所有推送都指望 APNs,那流量那么大,APNs 崩了怎么辦?
這確實是這個系統的一個弊端,就是耦合度太高,過于指望 APNs 很容易造成單點故障。所以,蘋果在 iOS 10 以前,對于遠程通知的內容,做了以下限制:
In iOS 8 and later, the maximum size allowed for a notification payload is 2 kilobytes; Apple Push Notification service refuses any notification that exceeds this limit. (Prior to iOS 8 and in OS X, the maximum payload size is 256 bytes.)
就是說,最多傳 2 KB 通知。這樣即使 1 秒鐘內有 100 萬個遠程推送同時發生,也就 2 GB。這對于一個大公司來說毫無壓力。
后來在 iOS 10 中,蘋果引入了Notification Content Extension 和 Notification Service Extension,這時候就可以修改原來的 notification 內容了,比如添加多媒體文件之類。講這兩個 extension 的文章太多,筆者這里不作贅述,只提供以下原理圖一張。
下面我們來看下具體怎么實現。遠程推送與本地推送不同在于,在注冊通知前,先要設置 App 使其允許遠程通知。具體做法就是去 App Settings -> Capabilities -> Push Notifications,打開 Push Notificaitons。
接著就是老步驟注冊。注意不同的是這次要說明是遠程通知。代碼如下:
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { accepted, error in
if !accepted {
print("Notification access denied.")
}
}
/// 注冊遠程通知,此處與本地通知不同
application.registerForRemoteNotifications()
遠程通知的內容由遠程服務器決定,本地無需創建。服務器端需要以下幾個關鍵數據來確認對指定的手機進行推送:
- Device Token: APNs 用來確認究竟是哪臺機器,哪個 App的參數。它可以通過以下代碼獲取。
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
/// 將 device token 轉化為字符串
let deviceTokenString = deviceToken.reduce("", {$0 + String(format: "%02X", $1)})
/// 將 device token 打印到 console 里面
print("APNs device token: \(deviceTokenString)
}
開發 App 的正確做法是把 Device Token 發送到服務器端,這里為了演示方便,就直接打印出來了。Device Token 大概長下面這樣:
5311839E985FA01B56E7AD74334C0137F7D6AF71A22745D0FB50DED665E0E882
- Key ID: 后臺服務器發送通知時, APNs 對其的認證號碼。它需要你去開發者中心注冊 APNs Auth Key。它會產生一個 .p8 文件,Key ID 就在其中。
- Team ID: 你 Apple ID 對應的號碼。可以在 App Settings -> Bundle Identifier 里找到。
這樣服務器就可以向你的手機發送通知了。加入響應操作,同樣是借助UNNotificationAction
和UNNotificationCategory
,并調用userNotificationCenter(_:didReceive:withCompletionHandler:)
,與本地推送的響應處理是一模一樣的。
觀察者模式
觀察者模式是設計模式中的一種,就是說一個對象當自身某些狀態發生變化的時候,自身發生相應操作或通知給另一個對象。對象之間無需有直接或間接的關系。這種設計模式的最大的好處是在于解耦。因為兩個對象可以分別單獨設計,只需在特定情況下通知對方即可。
下面請看一道面試題:請自行設計 Swift 的 Notification API,使其能夠實現 iOS 中的觀察者模式。
拿到這道題目,我們首先要分析 Notification API 對于觀察者模型的使用場景,無非就是兩種:跨 object 通知,以及 KVO(Key-Value-Observing)。
跨 object 通知以及 NotificationCenter 設計
首先我們來看跨 object 通知。一個最簡單的應用場景,當一個 ViewController 初始化時,它要通知 Network 部分去下載相應的圖片以填充對應的 UIImageView。所以流程如下:
- Network 注冊觀察 ViewController 初始化行為
- ViewController 發生初始化行為,并發出相應通知
- Network 得到通知,觀察到 ViewController 行為的發生
- Network 根據通知,調用 downloadImage 方法
根據以上流程,我們發現這種邏輯是 objects 之間的信號傳遞和接收過程。比較好的設計方法是單獨設計一個 Notification 類別,它相當于是一個通知調度中心,處理任意 objects 之間的通知,而不影響 objects 本身的其他操作。所以我們設計出了 NotificationCenter 這個類別,它有這兩個操作:
class NotificationCenter {
/* 注冊觀察
* observer:說明誰是觀察者,此例中是 Network
* selector:通知發生后觀察者調用方法,此例中為 func downloadImage(url)
* notificationName:通知名稱,用來識別具體通知
* object:信息發送者,如果為 nil 則表示任何發送者信息都接受,此例中為 ViewController
*/
func add(observer: Any, selector: Selector, notificationName: String, object: Any?)
/* 發送通知
* notificationName:通知名稱,用來識別具體通知,與上面的注冊觀察對應
* object:信息發送者,此例中為 ViewController
* userInfo:提供給觀察者的信息,此例中為需要下載圖片的 URL,以及對應的ImageView
*/
func post(notificationName: String, object: Any? , userInfo:[AnyHashable : Any]? = nil)
}
由于是跨 object 之間的通知,所以可知此類通知具有一般性,故而 NotificationCenter 設計為單例比較好:
class var default: NotificationCenter { get }
最后還要注意一個問題,就是當觀察者被回收的時候,我們一定要撤銷觀察,否則會發生通知發向一個 nil 類的情況,導致 App 崩潰。于是我們這樣設計:
func remove(observer: Any)
然后將它添加在類 deinit 中:
deinit {
remove(observer: self)
}
貌似我們已經設計好了針對跨 object 的最簡單 API。對照一下 Apple 官方的 NotificationCenter API,發現確實也是這個思路。不過他們設計的更全面可靠,這里大家可以自行比較。
KVO
我們來看第二個情況,就是 KVO -- 鍵值觀察。
顧名思義,鍵值觀察就是說當某個屬性發生變化,其對應的值也發生變化。它一般用于單個 object 內部的情況。舉個具體的例子,ViewController 一開始 UIImageView 沒有圖片的時候,我們用 activityIndicator 顯示加載狀態,當 Network 下載好圖片并給 UIImageView 賦值之后,我們停止 activityIndicator 的加載狀態。也就是說我們觀察 image 這個屬性,當它由 nil 變成非 nil 時,程序作出關閉 activityIndicator 動畫的相應操作
所以基本流程如下:
- ViewController 給 UIImageView 添加 activityIndicator,啟動動畫效果
- ViewController 觀察 UIImageView 的 image 屬性
- ViewController 通過上面提到的跨 object 通知,從 Network 里下載 image,并給 UIImageView 賦值
- ViewController 觀察到 UIImageView 的 image 屬性已經被賦值,所以啟動相應方法,關閉 activityIndicator 的動畫
這里我們可以看出來,這是針對單個 object 的某個屬性變化而設計出來的通知框架。所以我們不妨用 extension 的形式對 NSObject 添加通知方法。
extension NSObject {
/* 注冊觀察
* observer:說明誰是觀察者,此例中是 UIImageView
* property: 指出被觀察的屬性,此例中是 UIImageView 中的 image
* options:通知中應該傳遞的信息,比如 UIImageView 中新的 image 信息
*/
func add?Observer(observer: NSObject, property: String, ?options: ObservingOptions)
/* 響應觀察
* property: 指出被觀察的屬性,此例中是 UIImageView 中的 image
* object: 觀察屬性對應的 object,此例中是 UIImageView
* change: 表明屬性的相應變化,如果表示任何變化都可以接受,可以傳入 nil
*/
func observeValue(forProperty property: String,
ofObject object: Any,
change: [NSKeyValueChangeKey : Any]?)
}
同是不要忘記 deinit 的時候 removeObserver,防止 App 崩潰。對比 Apple 官方的 addObserver API 和 observeValue API,我們發現蘋果還引入了一個參數context
來更加靈活的處理通知觀察機制。你可以定義不同的 context 并根據這些 context 來對屬性變化做出處理。比如下面這樣:
let myContext = UnsafePointer<()>()
observee.addObserver(observer, forKeyPath: …, options: nil, context: myContext)
override func observeValueForKeyPath(keyPath: String!, ofObject object: AnyObject!, change: [NSObject : AnyObject]!, context: UnsafePointer<()>) {
if context == myContext {
…
} else {
super.observeValueForKeyPath(keyPath, ofObject: object, change: change, context: context)
}
}
總結
iOS 10中蘋果的本地推送和遠程推送 API 達到了高度統一,都使用 UserNotifications 這個框架來實現,學習曲線大幅下降。功能也得到了大幅度擴展,多媒體文件添加、擴展包、分類別響應、3D Touch 都使得推送功能更加靈活。
至于蘋果自己設計的 KVO 和 NotificationCenter 機制,筆者認為有很大的局限性。因為對應的通知和相應代碼段之間有一定距離,代碼量很大的時候非常容易找不到對應的相應。同時這種觀察者模式又難以測試,代碼維護和質量很難得到保證。正是因為這些原因,響應式編程才日漸興起,大家不妨去看看 RxSwift 和 ReactCocoa,其對應的 MVVM 架構也在系統解耦上要優于原生的 MVC。
參考
Introduction to User Notifications Framework in iOS 10
Push Notifications Tutorial: Getting Started
Send Push Notifications to iOS Devices using Xcode 8 and Swift 3