iOS 通知框架該怎樣使用?

前言

在 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 步:

UserNotifications 流程
  • 注冊

通過調用 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分鐘再提醒我這樣的選項,我們又該怎么做呢?這時候我們引入UNNotificationActionUNNotificationCategory

  • 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)
    }
  }
}

遠程通知

再接觸遠程代碼的具體實現之前,我們先來看看遠程通知的原理:

遠程通知
  1. App 向 iOS 系統申請推送權限
  2. iOS 系統向 APNs(Apple Push Notification Service) 請求手機 device token,并告訴 App,能接受推送的通知。
  3. App 將手機的 device token 傳給后端
  4. 后端向 APNs 推送通知
  5. 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 ExtensionNotification 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 就在其中。
APNs Auth Key
  • Team ID: 你 Apple ID 對應的號碼。可以在 App Settings -> Bundle Identifier 里找到。

這樣服務器就可以向你的手機發送通知了。加入響應操作,同樣是借助UNNotificationActionUNNotificationCategory ,并調用userNotificationCenter(_:didReceive:withCompletionHandler:),與本地推送的響應處理是一模一樣的。

觀察者模式

觀察者模式是設計模式中的一種,就是說一個對象當自身某些狀態發生變化的時候,自身發生相應操作或通知給另一個對象。對象之間無需有直接或間接的關系。這種設計模式的最大的好處是在于解耦。因為兩個對象可以分別單獨設計,只需在特定情況下通知對方即可。

下面請看一道面試題:請自行設計 Swift 的 Notification API,使其能夠實現 iOS 中的觀察者模式。

拿到這道題目,我們首先要分析 Notification API 對于觀察者模型的使用場景,無非就是兩種:跨 object 通知,以及 KVO(Key-Value-Observing)。

跨 object 通知以及 NotificationCenter 設計

首先我們來看跨 object 通知。一個最簡單的應用場景,當一個 ViewController 初始化時,它要通知 Network 部分去下載相應的圖片以填充對應的 UIImageView。所以流程如下:

  1. Network 注冊觀察 ViewController 初始化行為
  2. ViewController 發生初始化行為,并發出相應通知
  3. Network 得到通知,觀察到 ViewController 行為的發生
  4. 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 動畫的相應操作

所以基本流程如下:

  1. ViewController 給 UIImageView 添加 activityIndicator,啟動動畫效果
  2. ViewController 觀察 UIImageView 的 image 屬性
  3. ViewController 通過上面提到的跨 object 通知,從 Network 里下載 image,并給 UIImageView 賦值
  4. 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 APIobserveValue 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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,818評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,185評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,656評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,647評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,446評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,951評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,041評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,189評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,718評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,602評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,800評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,316評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,045評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,419評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,671評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,420評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,755評論 2 371

推薦閱讀更多精彩內容