天天看點

IOS 10 适配 系列_0_ iOS10 UserNotifications 架構解析

iOS 10 中以前雜亂的和通知相關的 API 都被統一了,現在開發者可以使用獨立的 UserNotifications.framework 來集中管理和使用 iOS 系統中通知的功能。在此基礎上,Apple 還增加了撤回單條通知,更新已展示通知,中途修改通知内容,在通知中展示圖檔視訊,自定義通知 UI 等一系列新功能,非常強大。

對于開發者來說,相較于之前版本,iOS 10 提供了一套非常易用通知處理接口,是 SDK 的一次重大重構。而之前的絕大部分通知相關 API 都已經被标為棄用 (deprecated)。

這篇文章将首先回顧一下 Notification 的發展曆史和現狀,然後通過一些例子來展示 iOS 10 SDK 中相應的使用方式,來說明新 SDK 中通知可以做的事情以及它們的使用方式。

您可以在 WWDC 16 的 Introduction to Notifications 和 Advanced Notifications 這兩個 Session 中找到詳細資訊;另外也不要忘了參照 UserNotifications 的官方文檔以及本文的執行個體項目 UserNotificationDemo。

Notification 曆史和現狀

碎片化時間是移動裝置使用者在使用應用時的一大特點,使用者希望随時拿起手機就能檢視資訊,處理事務,而通知可以在重要的事件和資訊發生時提醒使用者。完美的通知展示可以很好地幫助使用者使用應用,展現出應用的價值,進而有很大可能将使用者帶回應用,提高活躍度。正因如此,不論是 Apple 還是第三方開發者們,都很重視通知相關的開發工作,而通知也成為了很多應用的必備功能,開發者們都希望通知能帶來更好地體驗和更多的使用者。

但是理想的豐滿并不能彌補現實的骨感。自從在 iOS 3 引入 Push Notification 後,之後幾乎每個版本 Apple 都在加強這方面的功能。我們可以回顧一下整個曆程和相關的主要 API:

  • iOS 3 - 引入推送通知

    UIApplication

    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 10 适配 系列_0_ iOS10 UserNotifications 架構解析

首先你需要向使用者請求推送權限,然後發送通知。對于發送出的通知,如果你的應用位于背景或者沒有運作的話,系統将通過使用者允許的方式 (彈窗,橫幅,或者是在通知中心) 進行顯示。如果你的應用已經位于前台正在運作,你可以自行決定要不要顯示這個通知。最後,如果你希望使用者點選通知能有打開應用以外的額外功能的話,你也需要進行處理。

權限申請

通用權限

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
           

第一次調用這個方法時,會彈出一個系統彈窗。

IOS 10 适配 系列_0_ iOS10 UserNotifications 架構解析

要注意的是,一旦使用者拒絕了這個請求,再次調用該方法也不會再進行彈窗,想要應用有機會接收到通知的話,使用者必須自行前往系統的設定中為你的應用打開通知,而這往往是不可能的。是以,在合适的時候彈出請求窗,在請求權限前預先進行說明,而不是直接粗暴地在啟動的時候就進行彈窗,會是更明智的選擇。

遠端推送

一旦使用者同意後,你就可以在應用中發送本地通知了。不過如果你通過伺服器發送遠端通知的話,還需要多一個擷取使用者 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...
}
           
關于權限方面的使用,可以參考 Demo 中

AuthorizationViewController

的内容。

發送通知

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)")
    }
}
           
  1. 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

    進行處理。我們會在 delegaet 中接收到這個通知請求對應的 response,另外我們也有機會在應用的 extension 中對 request 進行處理。我們在接下來的章節會看到更多這方面的内容。

在送出通知請求後,我們鎖屏或者将應用切到背景,并等待設定的時間後,就能看到我們的通知出現在通知中心或者螢幕橫幅了:

IOS 10 适配 系列_0_ iOS10 UserNotifications 架構解析
關于最基礎的通知發送,可以參考 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 中找到相關資訊。

處理通知

應用内展示通知

現在系統可以在應用處于背景或者退出的時候向使用者展示通知了。不過,當應用處于前台時,收到的通知是無法進行展示的。如果我們希望在應用内也能顯示通知的話,需要額外的工作。

UNUserNotificationCenterDelegate

提供了兩個方法,分别對應如何在應用内展示通知,和收到通知響應時要如何處理的工作。我們可以實作這個接口中的對應方法來在應用内展示通知:

class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
    func userNotificationCenter(_ center: UNUserNotificationCenter, 
                       willPresent notification: UNNotification, 
                       withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) 
    {
        completionHandler([.alert, .sound])

        // 如果不想顯示某個通知,可以直接用空 options 調用 completionHandler:
        // completionHandler([])
    }
}
           

實作後,将

NotificationHandler

的執行個體指派給

UNUserNotificationCenter

delegate

屬性就可以了。沒有特殊理由的話,AppDelegate 的

application(_:didFinishLaunchingWithOptions:)

就是一個不錯的選擇:

class AppDelegate: UIResponder, UIApplicationDelegate {
    let notificationHandler = NotificationHandler()
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
        UNUserNotificationCenter.current().delegate = notificationHandler
        return true
    }
}
           

對通知進行響應

UNUserNotificationCenterDelegate

中還有一個方法,

userNotificationCenter(_:didReceive:withCompletionHandler:)

。這個代理方法會在使用者與你推送的通知進行互動時被調用,包括使用者通過通知打開了你的應用,或者點選或者觸發了某個 action (我們之後會提到 actionable 的通知)。因為涉及到打開應用的行為,是以實作了這個方法的 delegate 必須在

applicationDidFinishLaunching:

傳回前就完成設定,這也是我們之前推薦将

NotificationHandler

盡早進行指派的理由。

一個最簡單的實作自然是什麼也不錯,直接告訴系統你已經完成了所有工作。

func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
    completionHandler()
}
           

想讓這個方法變得有趣一點的話,在建立通知的内容時,我們可以在請求中附帶一些資訊:

let content = UNMutableNotificationContent()
content.title = "Time Interval Notification"
content.body = "My first notification"

content.userInfo = ["name": "onevcat"]
           

在該方法裡,我們将擷取到這個推送請求對應的 response,

UNNotificationResponse

是一個幾乎包括了通知的所有資訊的對象,從中我們可以再次擷取到

userInfo

中的資訊:

func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
    if let name = response.notification.request.content.userInfo["name"] as? String {
        print("I know it's you! \(name)")
    }
    completionHandler()
}
           

更好的消息是,遠端推送的 payload 内的内容也會出現在這個

userInfo

中,這樣一來,不論是本地推送還是遠端推送,處理的路徑得到了統一。通過

userInfo

的内容來決定頁面跳轉或者是進行其他操作,都會有很大空間。

Actionable 通知發送和處理

注冊 Category

iOS 8 和 9 中 Apple 引入了可以互動的通知,這是通過将一簇 action 放到一個 category 中,将這個 category 進行注冊,最後在發送通知時将通知的 category 設定為要使用的 category 來實作的。

IOS 10 适配 系列_0_ iOS10 UserNotifications 架構解析

注冊一個 category 非常容易:

private func registerNotificationCategory() {
    let saySomethingCategory: UNNotificationCategory = {
        // 1
        let inputAction = UNTextInputNotificationAction(
            identifier: "action.input",
            title: "Input",
            options: [.foreground],
            textInputButtonTitle: "Send",
            textInputPlaceholder: "What do you want to say...")

        // 2
        let goodbyeAction = UNNotificationAction(
            identifier: "action.goodbye",
            title: "Goodbye",
            options: [.foreground])

        let cancelAction = UNNotificationAction(
            identifier: "action.cancel",
            title: "Cancel",
            options: [.destructive])

        // 3
        return UNNotificationCategory(identifier:"saySomethingCategory", actions: [inputAction, goodbyeAction, cancelAction], intentIdentifiers: [], options: [.customDismissAction])
    }()

    UNUserNotificationCenter.current().setNotificationCategories([saySomethingCategory])
}
           
  1. UNTextInputNotificationAction

    代表一個輸入文本的 action,你可以自定義框的按鈕 title 和 placeholder。你稍後會使用

    identifier

    來對 action 進行區分。
  2. 普通的

    UNNotificationAction

    對應标準的按鈕。
  3. 為 category 指定一個

    identifier

    ,我們将在實際發送通知的時候用這個辨別符進行設定,這樣系統就知道這個通知對應哪個 category 了。

當然,不要忘了在程式啟動時調用這個方法進行注冊:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
    registerNotificationCategory()
    UNUserNotificationCenter.current().delegate = notificationHandler
    return true
}
           

發送一個帶有 action 的通知

在完成 category 注冊後,發送一個 actionable 通知就非常簡單了,隻需要在建立

UNNotificationContent

時把

categoryIdentifier

設定為需要的 category id 即可:

content.categoryIdentifier = "saySomethingCategory"
           

嘗試展示這個通知,在下拉或者使用 3D touch 展開通知後,就可以看到對應的 action 了:

IOS 10 适配 系列_0_ iOS10 UserNotifications 架構解析

遠端推送也可以使用 category,隻需要在 payload 中添加

category

字段,并指定預先定義的 category id 就可以了:

{
  "aps":{
    "alert":"Please say something",
    "category":"saySomething"
  }
}
           

處理 actionable 通知

和普通的通知并無二緻,actionable 通知也會走到

didReceive

的 delegate 方法,我們通過 request 中包含的

categoryIdentifier

和 response 裡的

actionIdentifier

就可以輕易判定是哪個通知的哪個操作被執行了。對于

UNTextInputNotificationAction

觸發的 response,直接将它轉換為一個

UNTextInputNotificationResponse

,就可以拿到其中的使用者輸入了:

func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {

    if let category = UserNotificationCategoryType(rawValue: response.notification.request.content.categoryIdentifier) {
        switch category {
        case .saySomething:
            handleSaySomthing(response: response)
        }
    }
    completionHandler()
}

private func handleSaySomthing(response: UNNotificationResponse) {
    let text: String

    if let actionType = SaySomethingCategoryAction(rawValue: response.actionIdentifier) {
        switch actionType {
        case .input: text = (response as! UNTextInputNotificationResponse).userText
        case .goodbye: text = "Goodbye"
        case .none: text = ""
        }
    } else {
        // Only tap or clear. (You will not receive this callback when user clear your notification unless you set .customDismissAction as the option of category)
        text = ""
    }

    if !text.isEmpty {
        UIAlertController.showConfirmAlertFromTopViewController(message: "You just said \(text)")
    }
}
           

上面的代碼先判斷通知響應是否屬于 "saySomething",然後從使用者輸入或者是選擇中提取字元串,并且彈出一個 alert 作為響應結果。當然,更多的情況下我們會發送一個網絡請求,或者是根據使用者操作更新一些 UI 等。

關于 Actionable 的通知,可以參考 Demo 中

ActionableViewController

的内容。

Notification Extension

iOS 10 中添加了很多 extension,作為應用與系統整合的入口。與通知相關的 extension 有兩個:Service Extension 和 Content Extension。前者可以讓我們有機會在收到遠端推送的通知後,展示之前對通知内容進行修改;後者可以用來自定義通知視圖的樣式。

IOS 10 适配 系列_0_ iOS10 UserNotifications 架構解析

截取并修改通知内容

NotificationService

的模闆已經為我們進行了基本的實作:

class NotificationService: UNNotificationServiceExtension {

    var contentHandler: ((UNNotificationContent) -> Void)?
    var bestAttemptContent: UNMutableNotificationContent?

    // 1
    override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
        self.contentHandler = contentHandler
        bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)

        if let bestAttemptContent = bestAttemptContent {
            if request.identifier == "mutableContent" {
                bestAttemptContent.body = "\(bestAttemptContent.body), onevcat"
            }
            contentHandler(bestAttemptContent)
        }
    }

    // 2
    override func serviceExtensionTimeWillExpire() {
        // Called just before the extension will be terminated by the system.
        // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
        if let contentHandler = contentHandler, let bestAttemptContent =  bestAttemptContent {
            contentHandler(bestAttemptContent)
        }
    }
}
           
  1. didReceive:

    方法中有一個等待發送的通知請求,我們通過修改這個請求中的 content 内容,然後在限制的時間内将修改後的内容調用通過

    contentHandler

    返還給系統,就可以顯示這個修改過的通知了。
  2. 在一定時間内沒有調用

    contentHandler

    的話,系統會調用這個方法,來告訴你大限已到。你可以選擇什麼都不做,這樣的話系統将當作什麼都沒發生,簡單地顯示原來的通知。可能你其實已經設定好了絕大部分内容,隻是有很少一部分沒有完成,這時你也可以像例子中這樣調用

    contentHandler

    來顯示一個變更“中途”的通知。

Service Extension 現在隻對遠端推送的通知起效,你可以在推送 payload 中增加一個

mutable-content

值為 1 的項來啟用内容修改:

{
  "aps":{
    "alert":{
      "title":"Greetings",
      "body":"Long time no see"
    },
    "mutable-content":1
  }
}
           

這個 payload 的推送得到的結果,注意 body 後面附上了名字。

IOS 10 适配 系列_0_ iOS10 UserNotifications 架構解析

使用在本機截取推送并替換内容的方式,可以完成端到端 (end-to-end) 的推送加密。你在伺服器推送 payload 中加入加密過的文本,在用戶端接到通知後使用預先定義或者擷取過的密鑰進行解密,然後立即顯示。這樣一來,即使推送信道被第三方截取,其中所傳遞的内容也還是安全的。使用這種方式來發送密碼或者敏感資訊,對于一些金融業務應用和聊天應用來說,應該是必備的特性。

在通知中展示圖檔/視訊

相比于舊版本的通知,iOS 10 中另一個亮眼功能是多媒體的推送。開發者現在可以在通知中嵌入圖檔或者視訊,這極大豐富了推送内容的可讀性和趣味性。

為本地通知添加多媒體内容十分簡單,隻需要通過本地磁盤上的檔案 URL 建立一個

UNNotificationAttachment

對象,然後将這個對象放到數組中指派給 content 的

attachments

屬性就行了:

let content = UNMutableNotificationContent()
content.title = "Image Notification"
content.body = "Show me an image!"

if let imageURL = Bundle.main.url(forResource: "image", withExtension: "jpg"),
   let attachment = try? UNNotificationAttachment(identifier: "imageAttachment", url: imageURL, options: nil)
{
    content.attachments = [attachment]
}
           

在顯示時,橫幅或者彈窗将附帶設定的圖檔,使用 3D Touch pop 通知或者下拉通知顯示詳細内容時,圖檔也會被放大展示:

IOS 10 适配 系列_0_ iOS10 UserNotifications 架構解析
IOS 10 适配 系列_0_ iOS10 UserNotifications 架構解析

除了圖檔以外,通知還支援音頻以及視訊。你可以将 MP3 或者 MP4 這樣的檔案提供給系統來在通知中進行展示和播放。不過,這些檔案都有尺寸的限制,比如圖檔不能超過 5MB,視訊不能超過 50MB 等,不過對于一般的能在通知中展示的内容來說,這個尺寸應該是綽綽有餘了。關于支援的檔案格式和尺寸,可以在文檔中進行确認。在建立

UNNotificationAttachment

時,如果遇到了不支援的格式,SDK 也會抛出錯誤。

通過遠端推送的方式,你也可以顯示圖檔等多媒體内容。這要借助于上一節所提到的通過 Notification Service Extension 來修改推送通知内容的技術。一般做法是,我們在推送的 payload 中指定需要加載的圖檔資源位址,這個位址可以是應用 bundle 内已經存在的資源,也可以是網絡的資源。不過因為在建立

UNNotificationAttachment

時我們隻能使用本地資源,是以如果多媒體還不在本地的話,我們需要先将其下載下傳到本地。在完成

UNNotificationAttachment

建立後,我們就可以和本地通知一樣,将它設定給

attachments

屬性,然後調用

contentHandler

了。

簡單的示例 payload 如下:

{
  "aps":{
    "alert":{
      "title":"Image Notification",
      "body":"Show me an image from web!"
    },
    "mutable-content":1
  },
  "image": "https://onevcat.com/assets/images/background-cover.jpg"
}
           

mutable-content

表示我們會在接收到通知時對内容進行更改,

image

指明了目标圖檔的位址。

NotificationService

裡,加入如下代碼來下載下傳圖檔,并将其儲存到磁盤緩存中:

private func downloadAndSave(url: URL, handler: @escaping (_ localURL: URL?) -> Void) {
    let task = URLSession.shared.dataTask(with: url, completionHandler: {
        data, res, error in

        var localURL: URL? = nil

        if let data = data {
            let ext = (url.absoluteString as NSString).pathExtension
            let cacheURL = URL(fileURLWithPath: FileManager.default.cachesDirectory)
            let url = cacheURL.appendingPathComponent(url.absoluteString.md5).appendingPathExtension(ext)

            if let _ = try? data.write(to: url) {
                localURL = url
            }
        }

        handler(localURL)
    })

    task.resume()
}
           

然後在

didReceive:

中,接收到這類通知時提取圖檔位址,下載下傳,并生成 attachment,進行通知展示:

if let imageURLString = bestAttemptContent.userInfo["image"] as? String,
   let URL = URL(string: imageURLString)
{
    downloadAndSave(url: URL) { localURL in
        if let localURL = localURL {
            do {
                let attachment = try UNNotificationAttachment(identifier: "image_downloaded", url: localURL, options: nil)
                bestAttemptContent.attachments = [attachment]
            } catch {
                print(error)
            }
        }
        contentHandler(bestAttemptContent)
    }
}
           

關于在通知中展示圖檔或者視訊,有幾點想補充說明:

  • UNNotificationContent

    attachments

    雖然是一個數組,但是系統隻會展示第一個 attachment 對象的内容。不過你依然可以發送多個 attachments,然後在要展示的時候再重新安排它們的順序,以顯示最符合情景的圖檔或者視訊。另外,你也可能會在自定義通知展示 UI 時用到多個 attachment。我們接下來一節中會看到一個相關的例子。
  • 在目前 beta (iOS 10 beta 4) 中,

    serviceExtensionTimeWillExpire

    被調用之前,你有 30 秒時間來處理和更改通知内容。對于一般的圖檔來說,這個時間是足夠的。但是如果你推送的是體積較大的視訊内容,使用者又恰巧處在糟糕的網絡環境的話,很有可能無法及時下載下傳完成。
  • 如果你想在遠端推送來的通知中顯示應用 bundle 内的資源的話,要注意 extension 的 bundle 和 app main bundle 并不是一回事兒。你可以選擇将圖檔資源放到 extension bundle 中,也可以選擇放在 main bundle 裡。總之,你需要保證能夠擷取到正确的,并且你具有讀取權限的 url。關于從 extension 中通路 main bundle,可以參看這篇回答。
  • 系統在建立 attachement 時會根據提供的 url 字尾确定檔案類型,如果沒有字尾,或者字尾無法不正确的話,你可以在建立時通過

    UNNotificationAttachmentOptionsTypeHintKey

    來指定資源類型。
  • 如果使用的圖檔和視訊檔案不在你的 bundle 内部,它們将被移動到系統的負責通知的檔案夾下,然後在當通知被移除後删除。如果媒體檔案在 bundle 内部,它們将被複制到通知檔案夾下。每個應用能使用的媒體檔案的檔案大小總和是有限制,超過限制後建立 attachment 時将抛出異常。可能的所有錯誤可以在

    UNError

    中找到。
  • 你可以通路一個已經建立的 attachment 的内容,但是要注意權限問題。可以使用

    startAccessingSecurityScopedResource

    來暫時擷取以建立的 attachment 的通路權限。比如:
    let content = notification.request.content
    if let attachment = content.attachments.first {  
        if attachment.url.startAccessingSecurityScopedResource() {  
            eventImage.image = UIImage(contentsOfFile: attachment.url.path!)  
            attachment.url.stopAccessingSecurityScopedResource()  
        }  
    }  
               
關于 Service Extension 和多媒體通知的使用,可以參考 Demo 中

NotificationService

MediaViewController

的内容。

自定義通知視圖樣式

iOS 10 SDK 新加的另一個 Content Extension 可以用來自定義通知的詳細頁面的視圖。建立一個 Notification Content Extension,Xcode 為我們準備的模闆中包含了一個實作了

UNNotificationContentExtension

UIViewController

子類。這個 extension 中有一個必須實作的方法

didReceive(_:)

,在系統需要顯示自定義樣式的通知詳情視圖時,這個方法将被調用,你需要在其中配置你的 UI。而 UI 本身可以通過這個 extension 中的 MainInterface.storyboard 來進行定義。自定義 UI 的通知是和通知 category 綁定的,我們需要在 extension 的 Info.plist 裡指定這個通知樣式所對應的 category 辨別符:

IOS 10 适配 系列_0_ iOS10 UserNotifications 架構解析

系統在接收到通知後會先查找有沒有能夠處理這類通知的 content extension,如果存在,那麼就交給 extension 來進行處理。另外,在建構 UI 時,我們可以通過 Info.plist 控制通知詳細視圖的尺寸,以及是否顯示原始的通知。關于 Content Extension 中的 Info.plist 的 key,可以在這個文檔中找到詳細資訊。

雖然我們可以使用包括按鈕在内的各種 UI,但是系統不允許我們對這些 UI 進行互動。點選通知視圖 UI 本身會将我們導航到應用中,不過我們可以通過 action 的方式來對自定義 UI 進行更新。

UNNotificationContentExtension

為我們提供了一個可選方法

didReceive(_:completionHandler:)

,它會在使用者選擇了某個 action 時被調用,你有機會在這裡更新通知的 UI。如果有 UI 更新,那麼在方法的

completionHandler

中,開發者可以選擇傳遞

.doNotDismiss

來保持通知繼續被顯示。如果沒有繼續顯示的必要,可以選擇

.dismissAndForwardAction

或者

.dismiss

,前者将把通知的 action 繼續傳遞給應用的

UNUserNotificationCenterDelegate

中的

userNotificationCenter(:didReceive:withCompletionHandler)

,而後者将直接解散這個通知。

如果你的自定義 UI 包含視訊等,你還可以實作

UNNotificationContentExtension

裡的

media

開頭的一系列屬性,它将為你提供一些視訊播放的控件和相關方法。

關于 Content Extension 和自定義通知樣式,可以參考 Demo 中

NotificationViewController

CustomizeUIViewController

的内容。

總結

iOS 10 SDK 中對通知這塊進行了 iOS 系統釋出以來最大的一次重構,很多“老朋友”都被标記為了 deprecated:

iOS 10 中被标為棄用的 API

  • UILocalNotification
  • UIMutableUserNotificationAction
  • UIMutableUserNotificationCategory
  • UIUserNotificationAction
  • UIUserNotificationCategory
  • UIUserNotificationSettings
  • handleActionWithIdentifier:forLocalNotification:
  • handleActionWithIdentifier:forRemoteNotification:
  • didReceiveLocalNotification:withCompletion:
  • didReceiveRemoteNotification:withCompletion:

等一系列在

UIKit

中的發送和處理通知的類型及方法。

現狀以及盡快使用新的 API

相比于 iOS 早期時代的 API,新的 API 展現出了高度的子產品化和統一特性,易用性也非常好,是一套更加先進的 API。如果有可能,特别是如果你的應用是重度依賴通知特性的話,直接從 iOS 10 開始可以讓你充分使用在新通知體系的各種特性。

雖然原來的 API 都被标為棄用了,但是如果你需要支援 iOS 10 之前的系統的話,你還是需要使用原來的 API。我們可以使用

if #available(iOS 10.0, *) {
    // Use UserNotification
}
           

的方式來指針對 iOS 10 進行新通知的适配,并讓 iOS 10 的使用者享受到新通知帶來的便利特性,然後在将來版本更新到隻支援 iOS 10 以上時再移除掉所有被棄用的代碼。對于優化和梳理通知相關代碼來說,新 API 對代碼設計群組織上帶來的好處足以彌補适配上的麻煩,而且它還能為你的應用提供更好的通知特性和體驗,何樂不為呢?

繼續閱讀