傲视苍穹iOS《Objective-C》VIP专题布袋的世界之Apple苹果家园iOS + RxSwift 开发专栏

iOS 远程推送开发详解

2017-01-11  本文已影响3187人  灵度Ling

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

推送前期:推送证书的配置


在开始使用推送新特性前,我们必须准备三张配置证书(如果还不清楚要如何如何配置证书,可回顾前言中的相关文章),分别是:

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


按照路径: target - 程序名字 - capabilities ,打开页面,按照 图1-5 所示设置:

图1-5 设置程序中 background Modes

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

图1-6 打开 pushNotifications 开关

推送初探:推送权限申请 与 推送基础设置


权限申请是在用户第一次启动 app 的时候跳出来的权限申请框,请求用户授权推送请求,故而自然而然我需要在 AppDelegate 的 didFinishLaunchingWithOptions 方法中请求授权。

第三步: (非必须)添加 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 中包括本地推送),除非你到手机设置中重新打开该程序的推送通知按钮

大家需要注意的是,拿到的 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 的使用


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 ,系统会难以识别。

本地推送测试

远程推送测试

网上很多第三方可以用于远程推送测试,我们公司是使用 LeadCloud (测试流程见 leadCloud 官网),这里提供测试的 playload:
{
"aps": {
"alert":{
"title": "每日精选限免 APP",
"body": "梦境旋律:¥ 25 —> 0,首次限免,appsoStore 本周限免,AppStore 本周限免,日式画风的音乐游戏从战场到宇宙,背负绝症女孩踏遍梦境"
},
"mutable-content":1
},
"sound": "default",
"image": "https:https://img.haomeiwen.com/i2691764/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
上一篇下一篇

猜你喜欢

热点阅读