前言
在 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 中完成註冊和建立的過程。程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
/// 註冊 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;如果是地點觸發,就用 UNLocationNotificationTrigger。
- 通知內容。除了標題(title)、內容(body)、聲音(sound)外,還可以新增副標題(subTitle)甚至是圖片。新增圖片的示例程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
/// 將圖片新增到通知中 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 類別之中。
1 2 3 |
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 來判斷具體是哪一個選項被點選,再呼叫對應響應方法即可。
1 2 3 4 5 6 7 8 |
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。
接著就是老步驟註冊。注意不同的是這次要說明是遠端通知。程式碼如下:
1 2 3 4 5 6 7 |
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { accepted, error in if !accepted { print("Notification access denied.") } } /// 註冊遠端通知,此處與本地通知不同 application.registerForRemoteNotifications() |
遠端通知的內容由遠端伺服器決定,本地無需建立。伺服器端需要以下幾個關鍵資料來確認對指定的手機進行推送:
- Device Token: APNs 用來確認究竟是哪臺機器,哪個 App的引數。它可以通過以下程式碼獲取。
1 2 3 4 5 6 |
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 這個類別,它有這兩個操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
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 設計為單例比較好:
1 |
class var default: NotificationCenter { get } |
最後還要注意一個問題,就是當觀察者被回收的時候,我們一定要撤銷觀察,否則會發生通知發向一個 nil 類的情況,導致 App 崩潰。於是我們這樣設計:
1 |
func remove(observer: Any) |
然後將它新增在類 deinit 中:
1 2 3 |
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 新增通知方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
extension NSObject { /* 註冊觀察 * observer:說明誰是觀察者,此例中是 UIImageView * property: 指出被觀察的屬性,此例中是 UIImageView 中的 image * options:通知中應該傳遞的資訊,比如 UIImageView 中新的 image 資訊 */ func addObserver(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 來對屬性變化做出處理。比如下面這樣:
1 2 3 4 5 6 7 8 9 10 11 |
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
打賞支援我寫出更多好文章,謝謝!
打賞作者
打賞支援我寫出更多好文章,謝謝!
任選一種支付方式