iOS 遠程推送開發詳解

Notification 歷史和現狀

iOSVersion 新增推送特性描述
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 等
iOS 10 添加新框架:UserNotifications.framework ,使用 UserNotifications 類輕松操作通知內容

前言


蘋果在 iOS 10 中添加了新的框架:UserNotifications.framework ,極大的富化了推送特性,讓開發者可以很方便的將推送接入項目中,也可以更大程度的自定義推送界面,同時,也讓用戶可以與推送消息擁有更多的互動,那么,這篇文章我會盡量詳細的描述 iOS 10推送新特性的使用方法。
本文參考自:AppleDevelop 遠程推送官方文檔

APNS(Apple Push Notification Service)-遠程推送原理解析


iOS app大多數都是基于client/server模式開發的,client就是安裝在我們設備上的app,server就是遠程服務器,主要給我們的app提供數據,因為也被稱為Provider。那么問題來了,當App處于Terminate狀態的時候,當client與server斷開的時候,client如何與server進行通信呢?是的,這時候Remote Notifications很好的解決了這個囧境,當客戶端和服務端斷開連接時,蘋果通過 APNS 與client 建立長連接。蘋果所提供的一套服務稱之為Apple Push Notification service,就是我們所謂的APNs。

推送消息傳輸路徑: Provider-APNs-Client App

我們的設備聯網時(無論是蜂窩聯網還是Wi-Fi聯網)都會與蘋果的APNs服務器建立一個長連接(persistent IP connection),當Provider推送一條通知的時候,這條通知并不是直接推送給了我們的設備,而是先推送到蘋果的APNs服務器上面,而蘋果的APNs服務器再通過與設備建立的長連接進而把通知推送到我們的設備上(參考圖1-1,圖1-2)。而當設備處于非聯網狀態的時候,APNs服務器會保留Provider所推送的最后一條通知,當設備轉換為連網狀態時,APNs則把其保留的最后一條通知推送給我們的設備;如果設備長時間處于非聯網狀態下,那么APNs服務器為其保存的最后一條通知也會丟失。Remote Notification必須要求設備連網狀態下才能收到,并且太頻繁的接收遠程推送通知對設備的電池壽命是有一定的影響的。

圖1-1Delivering a remote notification from a provider to an app
圖1-2 Pushing remote notifications from multiple providers to multiple devices
DeviceToken 的詳細說明

當一個App注冊接收遠程通知時,系統會發送請求到APNs服務器,APNs服務器收到此請求會根據請求所帶的key值生成一個獨一無二的value值也就是所謂的deviceToken,而后APNs服務器會把此deviceToken包裝成一個NSData對象發送到對應請求的App上。然后App把此deviceToken發送給我們自己的服務器,就是所謂的Provider。Provider收到deviceToken以后進行儲存等相關處理,以后Provider給我們的設備推送通知的時候,必須包含此deviceToken。(參考圖1-3,圖1-4)

圖1-3 share the deviceToken
圖1-4 Identifying a device using the device token

推送前期:推送證書的配置


在開始使用推送新特性前,我們必須準備三張配置證書(如果還不清楚要如何如何配置證書,可回顧前言中的相關文章),分別是:

  • iOS development (ios_development.cer)證書
    iOS 測試證書

  • CertificateSigningRequest.certSigningRequest 文件
    導出CSR的過程其實就是電腦向證書機構申請憑證的過程。該證書是通過電腦制作并頒發給你的電腦的。而從電腦導出的 CSR 文件就是用于證明你的電腦具有制作證書的能力的

  • aps_development.cer 證書
    通過 appID 生成的推送證書,而 App ID其實就是一個App的身份證,一個App的唯一標示。在Project中稱為Bundle ID,用于指明哪個項目要開啟推送通知的服務。

  • mobileprovisioning 配置文件
    描述文件描述了可由哪臺電腦,把哪個App,安裝到哪臺手機上面。一個描述文件的制作是需要App ID、Device、Certificate這些信息的,即簡單來說:該配置文件可以用于說明哪臺電腦中的哪個 app 需要開啟推送服務,并用哪臺手機作為調試工具

推送前期:在程序中的相關配置


按照路徑: target - 程序名字 - capabilities ,打開頁面,按照 圖1-5 所示設置:

圖1-5 設置程序中 background Modes

同樣的,按照 target - 程序名字 - capabilities 路徑,當你相關證書都配置完全時,程序中才會出現 pushNotifications 的按鈕,打開按鈕,如 圖1-6 所示,你會發現程序中多出現了 XXX.entitlements 文件,這就是你程序中的推送配置文件。
到這里,配置相關的東西都搞定了,你終于可以開始在程序中碼代碼了。

圖1-6 打開 pushNotifications 開關

推送初探:推送權限申請 與 推送基礎設置


權限申請是在用戶第一次啟動 app 的時候跳出來的權限申請框,請求用戶授權推送請求,故而自然而然我需要在 AppDelegate 的 didFinishLaunchingWithOptions 方法中請求授權。

  • 推送基礎設置:定義一個推送設置方面的類,繼承 UNUserNotificationCenterDelegate 代理,當推送成功之后,開發者可以在通知中心代理方法中去設置 推送界面顯示之前的 UI 樣式 和 ** 推送界面提示彈出框的點擊方法 **
    // 當一個通知被提交到前臺的時候,該方法會被調用。// 如果你想在前臺顯示推送消息,那么你必須返回一個有內容的數組
    func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {

      guard let notificationType = UserNotificationType(rawValue: notification.request.identifier) else {
          completionHandler([])
          return
      }
      
      let option: UNNotificationPresentationOptions
      
      switch notificationType {
      case .normalNotification:
          option = [.alert,.sound]
          
      default:
          option = []
      }
      
      completionHandler(option)
      
    }
    
    // 第二個方法嚴格來說只要用戶點擊消息推送彈出框就會調用。
    func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
      
      print(response.actionIdentifier)
      completionHandler()
    }
    
  • 申請授權:
    淺談:在 AppDelegate 的 didFinishLaunchingWithOptions 方法中請求授權,iOS 8 之前,本地推送 (UILocalNotification) 和遠程推送 (Remote Notification) 是區分對待的,應用只需要在進行遠程推送時獲取用戶同意。iOS 8 對這一行為進行了規范,因為無論是本地推送還是遠程推送,其實在用戶看來表現是一致的,都是打斷用戶的行為。因此從 iOS 8 開始,這兩種通知都需要申請權限。iOS 10 里進一步消除了本地通知和推送通知的區別。向用戶申請通知權限也變得十分簡單;
    第一步: 導入 UserNotifications 框架
    import UserNotifications
    第二步: 在你要申請推送授權的地方,進行注冊推送通知 和 注冊

    let center = UNUserNotificationCenter.current() // 申請一個通知中心
      center.delegate = notificationHandler // 將代理設置給我們自定義的一個自定義通知類,該類專門用于管理推送設置相關屬性、方法、代理方法
      // 如果用戶需要跟推送過來的內容進行交互,那么需要注冊 Action,詳見下面方法 
      registerNotificationCategory()
      center.requestAuthorization(options: [.sound,.alert,.badge]){ // 請求授權
          granted,error in
          if granted { // 是否被授權成功
              UIApplication.shared.registerForRemoteNotifications()  // 注冊遠程推送,向 APNs 請求 token;
          } else {
              if error != nil {
                  print("授權的時候出現錯誤")
              }
          }
      }
    

第三步: (非必須)添加 Category 的 actions 方法
func registerNotificationCategory() {
if #available(iOS 10.0, *) {
let customUICategory: UNNotificationCategory = {
var collectedActionOption: UNNotificationActionOptions = .Foreground
ASUserManager.sharedInstance.hadLogin ? (collectedActionOption = .Destructive) : (collectedActionOption = .Foreground)
let viewDetailsAction = UNNotificationAction(
identifier: CustomizedUICategoryAction.viewDetails.rawValue,
title: NSLocalizedString("PushNotification_Action_ViewDetails", comment: "查看詳情"),
options: [.Foreground])
let collectedAction = UNNotificationAction(
identifier:CustomizedUICategoryAction.collected.rawValue,
title: NSLocalizedString("PushNotification_Action_Collected", comment: "收藏"),
options: [collectedActionOption])
return UNNotificationCategory(identifier: UserNotificationCategoryType.customizedCategoryIdentify.rawValue, actions: [viewDetailsAction, collectedAction], intentIdentifiers: [], options: [.CustomDismissAction])
}()
UNUserNotificationCenter.currentNotificationCenter().setNotificationCategories([customUICategory])
}
}
到這一步,我們打開我們的app,會出現以下授權提示框:

圖1-7 授權提示框

需要注意,我們可以點擊 不允許 或者 允許,而如果不是用戶很鐘愛的 app 的話,一般用戶都會點擊 不允許 ,而當你點擊 不允許 之后,你基本就享受不到這個 app 所有的推送通知服務(iOS 10 中包括本地推送),除非你到手機設置中重新打開該程序的推送通知按鈕

  • 另外,用戶可以在系統設置中修改你的應用的通知權限,除了打開和關閉全部通知權限外,用戶也可以限制你的應用只能進行某種形式的通知顯示,比如只允許橫幅而不允許彈窗及通知中心顯示等。一般來說你不應該對用戶的選擇進行干涉,但是如果你的應用確實需要某種特定場景的推送的話,你可以對當前用戶進行的設置進行檢查:
    center.getNotificationSettings { (UNNotificationSettings) in // 還是在 didFinishLaunchingWithOptions 方法中添加該設置

      } 
    
  • 通過授權申請之后,我們可以通過 AppDelegate 的代理方法中拿到 deviceToken
    // 該方法返回 deviceToken
    func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    let tokenString = deviceToken.hexString
    print("tokenString(tokenString)")
    print("deviceToken(deviceToken)")
    }

大家需要注意的是,拿到的 deviceToken 是 Data 類型的,其間會有空格隔開的,所以這里我使用了 Data 的拓展方法去掉了空格,如下:

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

推送中期:開始真正意義上的推送 - NotificationViewController 的使用


  • 淺談: NotificationViewController 其實我們可以在這里類的 main.storyboard 中自定義 UI 界面, 并拿到從 NotificationService (下個介紹的類)下載到磁盤的資源為 UI 賦值數據,讓界面活起來
  • 使用步驟:
    首先,按照圖片路徑路徑新建一個 Target:
UNNotificationContent 類新建過程 - 1.png

點擊創建如下文件,并命名為 NotificationViewController 類:

UNNotificationContent 類新建過程 - 2.jpg

到這一步,你會發現項目中多了三個文件:

新增的三個文件.jpg

點開 info.plist 文件:

info.plist文件解釋

不得不解釋如下參數:
UNNotificationExtensionCategory: 這里務必要和代碼中的 categoryID 一樣 ,否則推送無法識別其中點擊方法;
UNNotificationExtensionInitialContentSizeRatio:自定義 UI 界面在屏幕顯示時,占屏幕的比例大小;

接下來我們先看看我創建好的文件 以及 我剛剛默默敲好的代碼

import UIKit
import UserNotifications
import UserNotificationsUI

class NotificationViewController: UIViewController, UNNotificationContentExtension {

@IBOutlet weak var descriptionImageView: UIImageView!

override func viewDidLoad() {
    super.viewDidLoad()
    // Do any required interface initialization here.
}

/**
 1. 拿到后臺的推送通知內容,自定義顯示樣圖的 UI 樣式
 2.  - 若為遠程推送,我們必須通過 NotificationService 將后臺的資源下載到本地磁盤中
     - 若為本地推送,一般情況下,我們也會把資源事先保存在本地磁盤中
   故而,由于資源在本地磁盤中,我們需要先獲得授權才可以訪問磁盤的內容,這里調用 startAccessingSecurityScopedResource 去獲得訪問權限
 */
func didReceiveNotification(notification: UNNotification) {
    let content = notification.request.content
    if let attachments = content.attachments.last {
        if attachments.URL.startAccessingSecurityScopedResource() {
            descriptionImageView.image = UIImage(contentsOfFile: attachments.URL.path!)
        }
    }
}

// 通過反饋,用戶可以自定義觸發的 action 方法
func didReceiveNotificationResponse(response: UNNotificationResponse, completionHandler completion: (UNNotificationContentExtensionResponseOption) -> Void) {
    
    if response.actionIdentifier == "action.viewDetails" { // 查看詳情
        completion(.DismissAndForwardAction)
    } else if response.actionIdentifier == "action.collected" { // 收藏
        completion(.DismissAndForwardAction)
    }
}
}

推送后期:推送即將結束 - NotificationViewService 的使用


淺談: 該類主要是便于用戶推送一些較為私密的信息,這是 iOS 10 的一大亮點,解決了過去信息泄露的問題。其邏輯是,你可以在后臺推送一些私密信息過來該類,該類通過拿到信息之后,進行解密,甚至還可以修改這些信息(30 s 修改時間),然后再保存到本地磁盤中,等待被 NotificationContent 類調用。注意: iOS 10本地推送是不需要經過該類的,所以只在遠程推送的情況下,暢談該類才會有意義。
詳看該類的代碼結構: 默默的敲了一些代碼如下
在該類,我們需要解析后臺給我們的 JSON 數據,并拿到所有的資源存儲到磁盤中,這里我通過 image 字段去拿到圖片資源,并保存到磁盤,你也可以自定義你個人喜歡的字段。

class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
var attachments: [UNNotificationAttachment] = []

override func didReceiveNotificationRequest(request: UNNotificationRequest, withContentHandler contentHandler: (UNNotificationContent) -> Void) {
    self.contentHandler = contentHandler
    bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
    if let bestAttemptContent = bestAttemptContent {
        // Modify the notification content here...
        if let userInfo = bestAttemptContent.userInfo as? [String: AnyObject], let imageString = userInfo["image"] as? String, let imageURL = NSURL(string: imageString) {
            downloadImageToLocalWithURL(imageURL, fileName: "7.jpg", completion: { (localURL) in
                if let localURL = localURL {
                    do {
                        // 在本地拿到縮略圖
                        if let thumbImageURL = NSBundle.mainBundle().URLForResource("thumbnailImage", withExtension: "png") {
                            do {
                                let lauchImageAttachment = try UNNotificationAttachment(identifier: "thumbnailImage", URL: thumbImageURL, options: nil)
                                self.attachments.insert(lauchImageAttachment, atIndex: 0)
                            } catch {
                                print("在拿到縮略圖的時候拋出異常\(error)")
                            }
                        }
                        let attachment = try UNNotificationAttachment(identifier: "thePushImage-\(localURL)", URL: localURL, options: nil)
                        self.attachments.append(attachment)
                        bestAttemptContent.attachments = self.attachments
                        contentHandler(bestAttemptContent)
                    } catch {
                        print("拋出異常: \(error)")
                    }
                }
            })
        }
    }
}

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

let documentsDirectoryPath = NSSearchPathForDirectoriesInDomains(.DocumentDirectory, .UserDomainMask, true)[0]
private func downloadImageToLocalWithURL(url: NSURL, fileName: String, completion: (localURL: NSURL?) -> Void) {
    guard let imageFormURL = self.getImageFromURLWithURLString(url.absoluteString!) else {
        print("loacl image nil")
        return
    }
    
    // 將圖片保存到本地中
    self.saveImageToLoaclWithImage(imageFormURL, fileName: fileName, imageType: "jpg", directoryPath: self.documentsDirectoryPath) { (wasWritenToFileSucessfully) in
        guard wasWritenToFileSucessfully == true else {
            print("文件寫入過程出錯")
            return
        }
        
        if let urlString = self.loadImagePathWithFileName(fileName, directoryPath: self.documentsDirectoryPath) {
            completion(localURL: NSURL(fileURLWithPath: urlString))
        }
    }
}

private func getImageFromURLWithURLString(urlString: String) -> UIImage? {
    if let url = NSURL(string: urlString) {
        if let data = NSData(contentsOfURL: url) {
            return UIImage(data: data)
        }
    }
    return nil
}

private func loadImagePathWithFileName(fileName: String, directoryPath: String) -> String? {
    let path = "\(directoryPath)/\(fileName)"
    return path
}

private func saveImageToLoaclWithImage(image: UIImage, fileName: String, imageType: String, directoryPath: String, completion:(wasWritenToFileSucessfully: Bool?) -> Void) {
    if imageType.lowercaseString == "png" {
        let path = directoryPath.stringByAppendingPathComponent("\(fileName)")
        if let _ = try? UIImagePNGRepresentation(image)?.writeToFile(path, options: NSDataWritingOptions.DataWritingAtomic) {
            completion(wasWritenToFileSucessfully: true)
        } else {
            completion(wasWritenToFileSucessfully: false)
        }
        
    } else if imageType.lowercaseString == "jpg" || imageType.lowercaseString == "jpeg" {
        let path = directoryPath.stringByAppendingPathComponent("\(fileName)")
        if let _ = try? UIImageJPEGRepresentation(image, 1.0)?.writeToFile(path, options: NSDataWritingOptions.DataWritingAtomic) {
            completion(wasWritenToFileSucessfully: true)
        } else {
            completion(wasWritenToFileSucessfully: false)
        }
        
    } else {
        print("Image Save Failed\nExtension: (\(imageType)) is not recognized, use (PNG/JPG)")
    }
}

  }

private extension String {
func stringByAppendingPathComponent(path: String) -> String {
    return (self as NSString).stringByAppendingPathComponent(path)
}

注意: 這里的 fileName 要盡量簡單些且以圖片的后綴進行命名(JPG、PNG...),太復雜的 fileName ,系統會難以識別。

本地推送測試

  • 為 UNMutableNotificationContent 類設置相關信息
    ** UNNotificationAttachment:** 可以將本地的資源放在該類
    的對象中并返回給 UNMutableNotificationContent 的 attachments(數組);
    categoryIdentifier:必須設置得和之前在 UNNotificationContent 的 info.plist 的 category 一樣,否則無效;
    UNTimeIntervalNotificationTrigger: 推送的時間和重復情況
    最后,只需要在本地的通知中心中添加通知請求即可生效;
    // iOS 10 本地推送
    if #available(iOS 10.0, *) {
    let content = UNMutableNotificationContent()
    content.title = "推送就得用這個標題才有用"
    content.body = "推送的內容: 在這里,你想說什么都可以"
    let imageNames = ["AppSo_avatar","AppSo_Icon"]
    let attachments = imageNames.flatMap { (name) -> UNNotificationAttachment? in
    if let imageURL = NSBundle.mainBundle().URLForResource(name, withExtension: "jpeg") {
    return try? UNNotificationAttachment(identifier: "image-(name)", URL: imageURL, options: nil)
    }
    return nil
    }
    content.attachments = attachments
    content.categoryIdentifier = UserNotificationCategoryType.customizedCategoryIdentify.rawValue
    let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 3,repeats: false)
    let requestIdentifier = UserNotificationType.customizedInterfaceNotification.rawValue
    let pushNotificationRequest = UNNotificationRequest(identifier:requestIdentifier, content: content, trigger: trigger)
    UNUserNotificationCenter.currentNotificationCenter().addNotificationRequest(pushNotificationRequest){ error in
    if error != nil {
    print("come out a error,when add the push request")
    } else {
    print("customized UI Notificaiton scheduled: (requestIdentifier)")
    }
    }
    }

遠程推送測試

網上很多第三方可以用于遠程推送測試,我們公司是使用 LeadCloud (測試流程見 leadCloud 官網),這里提供測試的 playload:
{
"aps": {
"alert":{
"title": "每日精選限免 APP",
"body": "夢境旋律:¥ 25 —> 0,首次限免,appsoStore 本周限免,AppStore 本周限免,日式畫風的音樂游戲從戰場到宇宙,背負絕癥女孩踏遍夢境"
},
"mutable-content":1
},
"sound": "default",
"image": "https://upload-images.jianshu.io/upload_images/2691764-7859401c51e1e9b9.png",
"category": "AppSoPushCategory"
}
注意點1 : mutable-content 要為 1 ,系統才會讓通過 NotificationService 去下載圖片資源,否則不會使用該類;
注意點2 : image 資源路徑一定要是 https 的,否則無法生效,還有圖片大小不宜過大,因為通過 NotificationService 下載圖片資源的時間很短,圖片資源太大,系統會來不及下載;
注意點3 : category 要與代碼中的 category 一致 ,否則會導致找不到推送通知的路徑;

推送收尾:效果圖樣


重按前
重按后

推送過程出現的 bugs

當在 Xcode 8 swift 2.3 或者以下版本的情況下創建新類:NotificationService 和 NotificationViewController 時都會出現版本不兼容的情況,因為我們創建代碼出來是 swift 3.0 的,但我們的代碼環境并不是 3.0 的,故而會出現這種情況,解決方法是將如下鍵設置為 YES:

其路徑: TARGETS - Build Settings - Swift Compiler
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容