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 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。我們使用 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) -> String in let buffer = UnsafeBufferPointer(start: bytes, count: count) return buffer.map {String(format: "hhx", $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 進行處理。我們會在 delegate 中接收到這個通知請求對應的 response,另外我們也有機會在應用的 extension 中對 request 進行處理。我們在接下來的章節會看到更多這方面的內容。
在提交通知請求後,我們鎖屏或者將應用切到後台,並等待設定的時間後,就能看到我們的通知出現在通知中心或者屏幕橫幅了:
關於最基礎的通知發送,可以參考 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 來實現的。
注冊一個 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]) }
UNTextInputNotificationAction 代表一個輸入文本的 action,你可以自定義框的按鈕 title 和 placeholder。你稍後會使用 identifier 來對 action 進行區分。
普通的 UNNotificationAction 對應標准的按鈕。
為 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 了:
遠程推送也可以使用 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。前者可以讓我們有機會在收到遠程推送的通知後,展示之前對通知內容進行修改;後者可以用來自定義通知視圖的樣式。
截取並修改通知內容
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) } } }
didReceive: 方法中有一個等待發送的通知請求,我們通過修改這個請求中的 content 內容,然後在限制的時間內將修改後的內容調用通過 contentHandler 返還給系統,就可以顯示這個修改過的通知了。
在一定時間內沒有調用 contentHandler 的話,系統會調用這個方法,來告訴你大限已到。你可以選擇什麼都不做,這樣的話系統將當作什麼都沒發生,簡單地顯示原來的通知。可能你其實已經設置好了絕大部分內容,只是有很少一部分沒有完成,這時你也可以像例子中這樣調用 contentHandler 來顯示一個變更“中途”的通知。
Service Extension 現在只對遠程推送的通知起效,你可以在推送 payload 中增加一個 mutable-content 值為 1 的項來啟用內容修改:
{ "aps":{ "alert":{ "title":"Greetings", "body":"Long time no see" }, "mutable-content":1 } }
這個 payload 的推送得到的結果,注意 body 後面附上了名字。
使用在本機截取推送並替換內容的方式,可以完成端到端 (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 通知或者下拉通知顯示詳細內容時,圖片也會被放大展示:
除了圖片以外,通知還支持音頻以及視頻。你可以將 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 標識符:
系統在接收到通知後會先查找有沒有能夠處理這類通知的 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 對代碼設計和組織上帶來的好處足以彌補適配上的麻煩,而且它還能為你的應用提供更好的通知特性和體驗,何樂不為呢?