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必须要求设备连网状态下才能收到,并且太频繁的接收远程推送通知对设备的电池寿命是有一定的影响的。

iOS 远程推送开发详解_第1张图片
图1-1Delivering a remote notification from a provider to an app
iOS 远程推送开发详解_第2张图片
图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)

iOS 远程推送开发详解_第3张图片
图1-3 share the deviceToken
iOS 远程推送开发详解_第4张图片
图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 所示设置:

iOS 远程推送开发详解_第5张图片
图1-5 设置程序中 background Modes

同样的,按照 target - 程序名字 - capabilities 路径,当你相关证书都配置完全时,程序中才会出现 pushNotifications 的按钮,打开按钮,如 图1-6 所示,你会发现程序中多出现了 XXX.entitlements 文件,这就是你程序中的推送配置文件。
到这里,配置相关的东西都搞定了,你终于可以开始在程序中码代码了。

iOS 远程推送开发详解_第6张图片
图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,会出现以下授权提示框:

iOS 远程推送开发详解_第7张图片
图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) -> 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:
iOS 远程推送开发详解_第8张图片
UNNotificationContent 类新建过程 - 1.png

点击创建如下文件,并命名为 NotificationViewController 类:

iOS 远程推送开发详解_第9张图片
UNNotificationContent 类新建过程 - 2.jpg

到这一步,你会发现项目中多了三个文件:

iOS 远程推送开发详解_第10张图片
新增的三个文件.jpg

点开 info.plist 文件:

iOS 远程推送开发详解_第11张图片
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

你可能感兴趣的:(iOS 远程推送开发详解)