六游的博客小站
iOS远程推送通知全解析
发布于: 2020-08-18 更新于: 2020-08-18 阅读次数: 

iOS中的推送通知可以分为两种:远程推送通知与本地推送通知。本地通知可以直接通过应用程序中的代码设置用户进入某个区域或者到达某个时间点的时候触发通知,而远程通知必须有远端服务器的参与。

苹果为远程推送提供了远端服务系统,这个系统叫做APNs(Apple Push Notification service,苹果通知推送服务)。当在远端想要向特定的设备推送内容的时候,我们需要向苹果官方的APNs系统发起一个请求,请求中携带我们想要通知的用户以及通知的内容信息,APNs接收到这个请求吗,确定无误且拥有权限之后,会将这些内容推送给每一个指定的终端,终端在接收到这些内容之后就会触发通知。下面我们来看一下远程通知的大致流程。

  1. 通常在application(_:didFinishLaunchingWithOptions:)方法之中,我们向APNs发起请求,以获取授权并取得本设备的token。token是APNs系统为每一个设备生成的唯一标识,我们在远端请求APNs系统给设备发送通知的时候,需要使用token指明我们要给哪些设备发。
  2. APNs接收到设备的请求时候返回一个设备token,然后会调用application(_:didRegisterForRemoteNotificationsWithDeviceToken:)方法通知APP设备token已经拿到
  3. 拿到token之后我们通常会将这个token发送给后端处理,后端通常会将这些token存储起来。一些为了精准推送,可能还会在发送token给后端的同时携带上用户账号信息以及设备信息。
  4. 后端服务器推送数据时,向APNs系统发送一个请求,其中包括了一个或多个的token信息
  5. APNs接收到请求之后将推送的数据发送给这些token对应的设备上去

Payload

后端服务器在向APNs系统发送请求的时候,要携带数据,这个数据被称为Payload。苹果官方指定JSON为Payload的格式,并且预定义了一些特殊的键供推送使用,同时限制Payload数据大小最大为4KB,如果超出这个大小APNs系统在接收到你服务端发来的请求时会拒绝它。使用官方预定义的一些键你可以完成很多关于推送通知的定制化功能,比如:定义通知的消息、在接受到通知的同时设置APP图标的badge数字、定义手机接收到通知时的响声、在推送通知上附加自定义的用户交互逻辑

一个最基本的alert推送Payload

1
2
3
4
5
6
7
8
{
"aps": {
"alert": {
"title": "通知标题",
"body": "通知内容"
}
}
}

分组通知

在alert中提供一个thread_identifier,iOS系统会自动将接收到的拥有同样identifier的通知合并起来,如果我们不提供这个key,那么苹果默认会将该应用的所有通知都合并为一个组

1
2
3
4
5
6
7
8
9
{
"aps": {
"alert": {
"title": "通知标题",
"body": "通知内容",
"thread_identifier": "Identifier1"
}
}
}

Badge

在aps字典中,提供一个badge键值对,iOS系统会在接收到该通知时自动更新APP图标上的badge数量。这个数值是一个绝对值,并不是在应用原有badge的基础之上做加减运算,系统接收到的Payload中如果设置了badge,就会直接把badge的值更新到APP中,所以如果要使用这个特性,则服务器需要清楚地知道各个客户端的badge数量

1
2
3
4
5
6
7
8
9
10
{
"aps": {
"alert": {
"title": "通知标题",
"body": "通知内容",
"thread_identifier": "Identifier1"
},
"badge": 12
}
}

通知声音

在默认情况下,iOS系统在接收到通知时会播放系统的通知声音来提醒用户,你也可以通过设置Payload来自定义通知声音。在aps字典中提供一个键为sound,值为APP Bundle中存在的一个声音文件即可设置,声音文件不能超过30s,如果超过iOS会忽略你的自定义声音,继续播放系统的通知声音。

自定义通知声音所支持的音频文件类型:

  • Linear PCM
  • MA4(IMA/ADPCM)
  • uLaW
  • aLaW
1
2
3
4
5
6
7
8
9
10
{
"aps": {
"alert": {
"title": "通知标题",
"body": "通知内容",
"thread_identifier": "Identifier1"
},
"sound": "filename.caf"
}
}

除了给通知附带自定义的声音之外,还可以为特定的通知指定critical alert sounds,这种声音会无视系统的音量,使用自定义的音量播放通知提示音,适用于一些比较紧急的通知。想要在APP中使用critical alert,需要申请特殊的通知授权。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"aps": {
"alert": {
"title": "通知标题",
"body": "通知内容",
"thread_identifier": "Identifier1"
},
"sound": {
// 设置为1代表这是一个critical alert
"critical": 1,
"name": "filename.caf",
// 0-1的浮点数,控制音量大小
"volume": 0.75
}
}
}

自定义数据

aps字典中的数据都是iOS预留的字段,除此之外我们可以提供自定以的一些数据。比如以下Payload中除了发送通知内容,还附加了一组地理信息。

1
2
3
4
5
6
7
8
9
10
11
12
{
"aps": {
"alert": {
"title": "通知标题",
"body": "通知内容"
}
},
"coords": {
"latitude": 37.33182,
"longitude": -122.03118
}
}

HTTP Header

远程服务器向APNs发送的请求是HTTP请求,苹果还定义了一系列的HTTP Header来明确更多信息。

  • apns-collapse-id

你可以为这个Header提供一个最大64bytes的标识符,当一个apns-collapse-id不为空的通知到达设备时,iOS系统会删除当前通知中心中具有相同的apns-collapse-id值的通知,也就是说系统在任何时候最多只会保持一个具有特定apns-collapse-id值的通知

  • push-type

在iOS13之后规定,必须要通过这个头部来明确你要进行的通知类型,如果你想触发一个alert弹出式通知,就设置为alert,如果你想要触发一个静默的通知,就设置为background

  • apns-priority

该Header定义了通知的优先级,如果没有设置,默认值为10。苹果规定如果你的Payload中含有content-available这个键,那么你的anps-priority必须被设置为5,当设置为5的时候通知可能会延迟到达。

为项目添加通知能力

  1. 在XCode项目左侧的导航栏中选择最上面的一项
  2. 在弹出的页面中选择代表你APP的Target,而不是Project
  3. 选择Signing&Capabilities选项卡
  4. 点击左上角+Capability按钮
  5. 从弹出的页面中搜索到Push Notification
  6. 确定PushNotification的能力已经被添加到你的签名信息下面

代码演示基本的远程推送

请求授权

1
2
3
4
5
6
7
8
9
10
11
12
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions:[UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// 1
UNUserNotificationCenter.current().requestAuthorization(options: [.badge, .sound, .alert]) {granted, _ in
// 2
guard granted else {return}
// 3
DispatchQueue.main.async {
application.registerForRemoteNotifications()
}
return true
}
}
  1. 调用UNUserNotificationCenter的requestAuthorization方法来向用户申请通知权限,这里申请了通知、声音以及更改Badge的权限。另外当你的options参数中包含.provisional时,iOS系统就不需要向用户询问通知权限,所有的通知会悄悄地(没有声音,也没有弹出通知)被送进用户的通知中心。当你需要弹出critical alert通知时,你需要在options中加入.criticalAlert,criticalAlert会在用户已经在设置中设置了拒绝通知的时候依然显示,并且可以自己控制音量,当你在授权中加入了这个的时候,你必须向苹果申请特殊的entitlement。
  2. 闭包中的granted参数表示用户是否接受了这个权限申请
  3. 用于requestAuthorization方法的闭包并不是在主队列中运行的,所以你需要声明在主队列中运行,然后调用UIApplication的registerForRemoteNotifications方法向APNs申请设备token

这个样子,当你首次打开应用程序时,就会弹出一个请求通知权限的弹框

处理返回的token

1
2
3
4
5
6
7
8
9
// 1
func application(_ application: UIApplication,didRegisterForRemoteNotificationsWithDeviceToken deviceToken:Data) {
// 2
let token = deviceToken.reduce("") { $0 + String(format:
"%02x", $1) }
print(token)
// 3
UploadTokenToServer(token)
}
  1. 当APNs系统受理了你的请求,并返回了你这个设备的token,则该回调方法就会被调用
  2. 可以通过这种方式将接收到的token数据转化为一个十六进制字符串,方便服务器存储与使用
  3. 获取到token之后你可以调用你自定义的方法将token发送给后端服务器

使用通知代理

默认情况下,当一个通知到达时,如果你正位于该APP的界面,通知弹框是不会显示的。当点击通知中心的某个通知时,默认是直接打开APP加载首屏。这些行为都由UNUserNotificationCenter的代理定义,如果以上这些情况不符合我们的需求时,可以为通知中心提供一个自定义的代理,在代理中控制这些行为。

为了代码整洁,且拥有条理性,我们对上一节远程推送的演示代码做一些改变。首先我们将在程序启动时向用户请求通知授权以及进行的其他一系列对与通知相关的逻辑抽离成一个单独的方法。在该代码中我们添加了一行代码,指定了UNUserNotificationCente单例对象的代理对象,同样这里我们为了精简AppDelegate类的代码,将代理抽取作为一个新的类

1
2
3
4
5
6
7
8
9
10
11
func registerForPushNotifications(application: UIApplication) {
let center = UNUserNotificationCenter.current()
center.requestAuthorization(options: [.badge, .sound, .alert]){ [weak self] granted, _ in
guard granted else {return}
// 1
center.delegate = NotificationDelegate()
DispatchQueue.main.async {
application.registerForRemoteNotifications()
}
}
}

然后在AppDelegate程序刚启动会调用的方法中调用我们这个函数

1
2
3
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions:[UIApplication.LaunchOptionsKey: Any]?) -> Bool {
registerForPushNotifications(application: application)
}

之后我们就可以覆写我们通知代理中的方法来实现对通知各种各样的控制了

应用处于前台时通知的处理

默认情况下,当一个通知到达时,如果你的APP位于系统前台,该条通知就会静悄悄的添加到用户的通知中心,没有弹框也没有声音。当位于前台的APP收到一条通知时,就会调用通知中心的代理方法userNotificationCenter(_:willPresent:withCompletionHandler:),iOS系统处理此次通知的行为也有该方法定义

1
2
3
4
5
6
7
8
9
10
11
12
13
class NotificationDelegate: UNUserNotificationCenterDelegate {
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler:
@escaping (UNNotificationPresentationOptions) -> Void) {
// 默认实现是传入一个空的options列表,表示什么都不做
completionHandler([])
// 在前台的APP收到通知时,只弹出
completionHandler([.alert])
// 在前台的APP收到通知时,弹出、播放声音并更新badge
completionHandler([.alert, .sound, .badge])
}

处理通知被点击而启动应用

在很多APP中,我们都会遇到以下的情景:用户点击一个文章推送通知之后,打开APP立即跳转到该篇文章的详情页。这样的行为也是在代理方法中控制的,当用户点击了通知之后,系统会启动对应的APP,启动之后会调用代理的userNotificationCenter(_:,didReceive:,withCompletionHandler:)方法来通知系统处理。当然,默认实现是什么都不做,继续进行以下步骤。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void)
{
// 在该方法结束之前必须调用参数中的completionHandler闭包
defer { completionHandler() }
guard response.actionIdentifier == UNNotificationDefaultActionIdentifier else {return}
// 接下来下面可以放处理用户点击通知事件的逻辑代码,这里有一个小例子
// 1
let payload = response.notification.request.content
guard let articleId = payload.userInfo["articleID"] else {return}
// 2
let vc = ArticleDetailVC(articleID)
// 3
let appDelegate = UIApplication.shared.delegate as! AppDelegate
appDelegate.window?.rootViewController?.navigationController?.push(vc, animated: false)
}
  1. 拿到此次通知的payload,并检查其中是否有设置的自定义键值articleID
  2. 如果有就根据这个ID创建一个文章详情控制器,该控制器会根据id去网络请求文章详情数据
  3. 之后拿到rootController,通过navigationController将刚刚创建的文章详情控制器push弹出

静默通知

静默通知没有弹出通知、没有声音、甚至不会出现在用户的通知中心中,正是因为这些特性使用静默通知无需获取用户授权。静默通知一般用来通知APP更新数据,比如在一个RSS订阅APP时,服务器发送一个静默通知通知APP,APP接收到通知之后去更新相应的内容到本地,用户下次打开APP不需要等待网络请求就可以看到新内容。

  1. 设置静默通知的Payload。
  • 注意:不要以为把content-available的值设置为1就是关闭了静默通知,当你不需要静默通知的时候不要在payload中附带content-availabel这个键
  • 注意:在payload中设置了content-available开启静默通知的时候,请不要忘记在HTTPHeader中将apns-priority值设为5。
1
2
3
4
5
6
7
8
{
"aps": {
// 在aps字典中设置content-availabel为1
"content-available": 1
},
// 自定义的键
"article": "https://someDomain/api/xxx"
}
  1. 在APP项目的Sign&Capabilities选项卡中,为你的APP添加Background Modes选项

  1. 在AppDelegate中响应静默通知
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    // 应用收到静默通知时会调用以下代理方法
    // 该方法结束前必须调用completionHandler闭包,通知系统此次通知数据更新的结果
    func application(
    _ application: UIApplication,
    didReceiveRemoteNotification userInfo: [AnyHashable : Any],
    fetchCompletionHandler completionHandler:
    @escaping (UIBackgroundFetchResult) -> Void)
    {
    // 先拿到payload中的数据
    guard let article = userInfo["article"] as? String,
    let url = URL(string:article) else {
    // 调用completionHandler传入noData,向系统说明此次通知不需要更新数据
    completionHandler(.noData)
    }
    // 根据通知发送过来的URL,去请求数据并保存在本地
    SaveArticleDataToLocal(url) { result in
    // 最后根据结果,向completionHandler闭包中传入不同的参数
    switch result {
    case .success:
    completionHandler(.newData)
    case .fail:
    completionHandler(.failed)
    }
    }

    return
    }

    带有按钮的通知

  2. 在iOS中,一类带有相同按钮以及相同点击逻辑的通知被称作一个category,每个通知上附加的每个按钮对应一个action。我们在使用带有按钮的通知之前,首先要想通知系统注册category和action,这两个都需要唯一的identifier来标识,为了之后的编码不出错,我们定义一系列用来标识各种identifier的常量
    1
    2
    3
    4
    5
    private let categoryId = "AcceptOrReject"

    private enum ActinoId: String {
    case accept, reject
    }
  3. 注册category和action。以下定义了一个方法用来注册category以及对应的action到通知中心中去,你可以在每次申请到用户的用户的通知授权之后调用下面的方法。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    private func registerCustomActions() {
    // 创建两个Action
    let accept = UNNotificationAction(identifier: ActionIdentifier.accept.rawValue, title: "接受")
    let reject = UNNotificationAction(identifier: ActionIdentifier.reject.rawValue, title: "拒绝")
    // 创建一个category,并将刚创建的两个Action添加该category中
    let category = UNNotificationCategory(
    identifier: categoryIdentifier,
    actions: [accept, reject],
    intentIdentifiers: [])
    // 将创建的category添加到通知中心中
    UNUserNotificationCenter.current().setNotificationCategories([category])
    }
  4. 设置按钮通知的payload,除了常规的payload信息之外,在aps字典中新增一个category键值对,其值要设置为第二步中你注册到通知中心的category的Identifier。这样在iOS收到通知之后就可以根据categoryIdentifier找到该通知需要附加多少个按钮,每个按钮的名称是什么,点击每个按钮之后要怎么做
    1
    2
    3
    4
    5
    6
    7
    8
    9
    {
    "aps": {
    "alert": {
    "title": "Long-press this notification"
    },
    "category": "AcceptOrReject",
    "sound": "default"
    }
    }
  5. 响应按钮点击
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    // 点击了通知中的某个按钮之后,会调用通知中心代理的下面这个方法
    // 注意:该方法与上面用户点击通知本身唤醒APP时调用的方法是同一个,所以注意该方法要处理多个事件
    func userNotificationCenter(
    _ center: UNUserNotificationCenter,
    didReceive response: UNNotificationResponse,
    withCompletionHandler completionHandler: @escaping () -> Void)
    {
    // 在方法结束之前必须调用completionHandler回调
    defer { completionHandler() }
    // 获取到此次点击的按钮所属的categoryId与actionId
    let categoryId = response.notification.request.content.categoryIdentifier
    let actionId = response.actionIdentifier
    // 判断此次点击的categoryID与actionId是否正确
    guard categoryId == categoryIdentifier,
    let action = ActionIdentifier(rawValue:actionId) else {
    return
    }
    // 处理按钮点击相关逻辑
    print("你点击了 \(actionId)")
    }

使用通知服务扩展

当手机收到一个APNs发来的远程通知时,我们可以使用通知服务扩展在这条通知显示在通知中心之前对通知进行一些处理。我们可以将通知扩展想象成是位于APNs系统与手机之间的一个中间件。

创建并使能通知服务扩展

首先你需要添加Notification Service Target到你的工程项目中去:

  1. Xcode中,选择File->New->Target
  2. iOS选项卡中选择Notification Service Target
  3. 给你的Target命名,可以是任何具有一定意义的名字
  4. 点击完成
  5. 如果Xcode询问你scheme activation的话,选择Cancel

完成创建之后,在你的工程文件导航栏中,就会出现一个你刚刚新创建的通知扩展的文件夹。该文件夹下有一个NotificationService.swift文件,该文件中已经为你提供了通知扩展的模板代码。其中有两个方法:didReceive(_:withContentHandler:)serviceExtensionTimeWillExpire。模板代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class NotificationService: UNNotificationServiceExtension {

var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
// 1
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
// 2
self.contentHandler = contentHandler
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
if let bestAttemptContent = bestAttemptContent {
// 3
bestAttemptContent.title = "\(bestAttemptContent.title) [modified]"
// 4
contentHandler(bestAttemptContent)
}
}
// 5
override func serviceExtensionTimeWillExpire() {
if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent {
contentHandler(bestAttemptContent)
}
}

}
  1. 系统在接收到通知之后会调用这个方法,该方法的第一个参数是接收到的通知,第二个参数是一个闭包回调,我们在逻辑处理完成之后需要调用这个闭包将一个UNNotificationContent对象传递给系统。UNNotificationContent对象中装载着应用程序显示一个通知所需要的所有信息,包括标题、文字、badge、attachment等。
  2. 这里先将参数接收到的闭包回调保存起来,以便之后使用。之后根据接受到的通知生成一个UNMutableNotificationContent对象并保存起来,该对象只是UNNotificationContent的可变版本。我们之后可以对该对象中的内容进行修改,最后再将该对象通过闭包回调传递给系统,以达到修改通知的目的
  3. 在这里我们就可以进行真正的通知修改和一系列逻辑代码的书写了
  4. 在完成所有的逻辑之后,调用闭包回调传递给系统一个UNNotificationContent对象,系统会根据这个对象来显示一个通知
  5. 如果系统在第一个方法中等待了30s,闭包回调还没有被调用,系统就会调用这个函数。该函数是再给你最后一次更新通知数据的机会,系统一旦调用该函数,说明通知扩展系统马上就会被关闭,所以该方法中只能书写一些可以立即完成的逻辑代码,并且一定要在最后调用回调函数,如果第二次函数结束或者通知扩展系统被关闭之前,回调函数还没有被调用,系统就会显示默认的没有经过修改的通知

另外值得注意的是,如果想要让通知可以被通知扩展处理,那么需要在发送通知的payload的aps字典中添加j键为mutable-content,值为1的键值对,示例payload如下

1
2
3
4
5
6
7
8
9
{
"aps": {
"alert": {
"title": "通知标题",
"body": "通知内容"
},
"mutable-content": 1
}
}

示例:修改通知内容并下载数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

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

private func handleNotification() {
// 2
guard let content = self.bestAttemptContent else { return }
// 3
content.title = content.title.decrypt
content.body = content.body.decrypt

// 4
guard let urlPath = request.content.userInfo["media-url"] as? String,
let url = URL(string: urlPath) else { return }
// 5
let destination = URL(fileUrlWithPath: NSTemporaryDirectory().appendingPathComponent(url.lastPathComponent))

do {
// 6
let data = try Data(contentOf: url)
try data.write(to: destination)
// 7
let attachment = try UNNotificationAttachment(
identifier: "",
url: destination
)
// 8
content.attachments = [attachment]
} catch {
// 9
}
// 10
self.contentHandler?(content)
}
  1. 为了避免过度嵌套,在第一个方法中,在系统模板代码完成对对象的保存之后,我们调用了一个自定义的方法来完成后续的处理
  2. 首先取得由通知所生成的UNMutableNotificationContent对象,之后就是对该对象中内容的读取与修改
  3. 这里我们假设服务器发来的通知数据是加了密的,通知要显示解密后的数据,修改了通知显示的标题和文本
  4. 检查payload中是否有自定义的文件下载地址
  5. 拼接出一个用来临时存储下载的数据的路径
  6. 下载数据,并将数据暂存到本地
  7. 基于下载的数据创建一个attachment
  8. 将刚创建的attachment附加到要显示的通知内容之上
  9. 如果下载或者文件存储出了错,什么也做不了,只能放弃此次下载,或者修改通知中的文字来提醒用户网络异常等错误
  10. 最后调用保存的contentHandler,将修改后的通知数据交给系统处理

示例:通知扩展与主target共享数据

通知扩展与你的主target是两个彼此独立的进程。我们可以通过ApplicationGroups来进行,ApplicationGroups允许我们在多个应用或者扩展之间共享数据

首先我们需要使能应用的这项能力:

  1. Xcode的导航栏中选择你的工程,并点击应用的主target
  2. 选择Signing & Capabilities选项卡,点击左上角的加号按钮添加Capability
  3. 在弹出的Capabilities选择框中选择App Groups并添加
  4. 添加之后会在Signing & Capabilities选项卡中看到一个名字叫做App Group的新section
  5. App Group中点击加号按钮,为你要添加的Group设置一个名字,这个名字由group前缀加上你的应用的BundleID组成,代表你要共享该应用下的扩展

我们已经知道,通知时可以通过payload的设置来使应用程序在接收到通知的时候更改对应的 badge值,但是payload中设置的badge值是一个绝对值,应用程序在接收到通知之后,如果看到payload中设置了badge值那么就直接将这个值设置为当前的badge值,而不是采用一种累加的策略。这个样子如果我们想要让远端通知具有增加应用程序的badge的能力,那么远端服务器就不得不追踪所有用户当前应该有的badge数量,这对于服务器还是客户端都是比较难实现的。下面我们会使用通知扩展与主target共享数据来实现一个自增badge的通知。

首先在主target中做一些扩展

1
2
3
4
5
6
7
8
9
10
11
12
13
extension UserDefaults {
static let suiteName = "group.com.xxx.PushNotifications"
static let extensions = UserDefaults(suiteName: suiteName)!

var badge: Int {
get {
return UserDefaults.extensions.integer(forKey: "badge")
}
set {
UserDefaults.extensions.set(newValue, forKey: "badge")
}
}
}

该扩展只是为了方便使用UserDefaults将程序的badge存储起来,由于该代码是写在主target中的,而我们想要在通知扩展target中使用上面的代码,所以我们需要进行一些设置,使该文件中的代码可以被主target与通知扩展target同时访问。首先打开这个文件,然后在编辑器右边的File inspector的`Target Membership 中同时勾选两个target,这样会使该文件在编译时暴露给两个target。

然后我们就可以在通知扩展中来处理远程通知发送过来的badge

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private func handleNotification {
// 1
guard let content = self.bestAttemptContent else { return }
guard let incr = content.badge as? Int { return }
switch incr {
case 0
// 2
UserDefaults.extensions.badge = 0
content.badge = 0
default:
// 3
let current = UserDefaults.badge
let new = current + incr
UserDefaults.extensions.badge = new
content.badge = NSNumber(value: new)
}
// 4
self.contentHandler?(content)
}
  1. 与上面的方法一样,我们将处理通知的逻辑代码封装到一个方法中,并在该方法的开头确定条件的满足
  2. 我们的逻辑是如果发送的通知中badge设置为0,则说明要对程序的badge进行清空
  3. 如果没有设置0,则将该值与程序中存储的旧的badge值累加,得到新值,再应用这个新值
  4. 最后调用contentHandler回调函数将修改后的通知内容交给系统

使用通知内容扩展

在手机收到通知并经过通知服务扩展处理之后,我们可以使用通知内容扩展来自定义通知的UI,并且处理通知中的交互

创建通知内容扩展

  1. 打开Xcode,在菜单栏中选择File -> New -> Target来创建一个Target
  2. 选择Notification Content Extension并点击下一步创建
  3. 为你的Target输入一个名字,这个名字可以是有意义的任何内容
  4. 点击Finish完成创建,如果Xcode询问你scheme activation相关内容,选择cancel

通知内容扩展是靠一个唯一的Category Identifier触发的,每一个通知内容扩展都有一个唯一对应的Category Identifier。当发送来的通知payload中有category值时,系统根据这个值去寻找对应的通知内容扩展并应用到这个通知上。在Xcode工程的设置界面,选择一个创建的通知内容扩展Target,然后展开NSExtension项,找到UNNotificationExtensionCategory后修改其值即可为当前的通知内容扩展设置Category Identifier

设计通知界面

我们打开新创建的通知内容扩展Target时,会在其目录中发现默认存在一个storyboard和一个viewController,这里就是我们设计通知UI的地方。这里我们会创建一个可以显示地图的通知界面,服务器在通知信息中携带一个坐标信息,我们接收到通知之后使用坐标信息来初始化地图显示的位置。

首先在MainInterface.storyboard文件中拖拽一个MKMapView到你的界面中。

打开NotificationViewController.swift文件,将刚刚拖拽的控件绑定到你的ViewController中,并依据自己的逻辑书写didReceive(_:)方法中的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 1
func didReceive(_ notification: UNNotification) {
// 2
let userInfo = notification.request.content.userInfo
guard let latitude = userInfo["latitude"] as? CLLocationDistance,
let longitude = userInfo["longitude"] as? CLLocationDistance,
let radius = userInfo["radius"] as? CLLocationDistance else {
return
}
// 3
let location = CLLocation(latitude: latitude, longitude: longitude)
let region = MKCoordinateRegion(center: location.coordinate,
latitudinalMeters: radius,
longitudinalMeters: radius)
// 4
mapView.setRegion(region, animated: false)
}
  1. 当一个通知到达并经过通知服务扩展的处理(可选)之后,就会调用该方法
  2. 根据参数传入的通知对象拿到payload数据,并视图从中解析出我们自定义提供的内容
  3. 根据payload中的数据初始化一个MKCoordinateRegion对象
  4. 设置通知界面中的MapView的显示区域

之后在我们远端发送通知Payload来测试通知

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"aps": {
"alert" : {
"title" : "长按以展开"
},
// 设置与我们创建的通知内容扩展的Category Identifier一样的category以激活此次通知的内容扩展
"category" : "ShowMap",
"sound": "default"
},
// 我们自定义的数据
"latitude" : -33.859574,
"longitude" : 151.204576,
"radius" : 500
}

之后我们就可以在应用程序中看到对应的通知到达,我们长按这个通知就会使之展开,显示我们自定义的界面

不过这里还有个小问题,如果你仔细观察这个自定义界面弹出的过程,你会发现这个UI界面的高度并不是一开始就是你在storyboard中设置的高度,而是会以一个你不知道是多少的高度出现,随后又立马变成你设置的高度。这是因为,苹果将通知界面的初始宽度设置为一个与宽度的比值,我们可以在对应的通知内容扩展Target中的Info.plist文件中修改这个比值。打开当前通知内容扩展Target的Info.plist文件,展开NSExtension,你可以看到一个UNNotificationExtensionInitialContentSizeRatio的配置项,其默认值为1,你可以为其设置一个小数值,来指定你的通知UI的高度与宽度之比。

为通知界面添加按钮或文本输入

在前面介绍通知代理的时候,我们讲解了如何通过向当前的通知中心中注册一个Category来向某个特定的通知中添加按钮。除此之外,我们还可以通过通知内容扩展来向通知中添加按钮,并且使用通知内容扩展会使按钮的添加更加灵活与高效

1
2
3
4
5
6
7
8
9
10
// 1
func didReceice(_ notification: UNNotification) {
// 2
...
// 3
let comment = UNTextInputNotificationAction(identifier: "comment", title: "评论")
let acceptAction = UNNotificationAction(identifier: "accept")
// 4
extensionContext?.notificationActions = [comment, acceptAction]
}
  1. 因为我们想要让通知刚被展开就有一系列的按钮,所以我们在didReceice(_:)方法中来书写添加按钮的逻辑
  2. 其他的逻辑代码
  3. 创建一系列的Actions,每个action代表一个用户输入,comment是一个文本输入,accept是一个按钮。如果你给一个通知只创建了一个文本输入的时候,那么当通知被展开是,这个文本输入框会自动被设置为FirstResponder对象,键盘会直接自动弹出。
  4. 通过类中个属性extensionContext来获取到当前显示的通知,将我们创建的action设置进去

处理用户与通知的交互

通知内容扩展对应的控制器中有两个方法值得我们注意,一个是didReceive(_:),这个我们前面已经了解并使用过了,另外一个是didReceive(_:completionHandler:)。前一个方法是在通知将要显示到手机上之前调用,以供我们在这个方法中配置通知的UI和其他信息;后一个方法会在用户点击了按钮或者输入了文本之后调用,以供我们的应用对用户的交互做出反应。下面我们使用这个方法来响应用户的输入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 1
func didReceive(_ response: UNNotificationResponse, completionHandler completion: @escaping (UNNotificationContentExtensionResponseOption)->Void) {
switch response.actionIdetifier {
// 2
case "comment":
let res = response as! UNTextInputNotificationResponse
handleUserText(res.text)
case "accept":
// 3
handleUserAccept()
// 4
let refuseAction = UNNotificationAction(identifier:"refuse", title:"拒绝")
// 5
let currentActions = extensionContext?.notificationActions ?? []
extensionContext?.notificationActions = currentActions.map {
$0.identifier == "accept" ? cancelAction : $0
}
// 6
case "refuse":
handleUserRefuse()
let acceptAction = UNNotificationAction(identifier: "accept", title: "接受")
let currentActions = extensionContext?.notificationActions ?? []
extensionContext?.notificationActions = currentActions.map {
$0.identifier == "refuse" ? acceptAction : $0
}
default:
break
}
// 7
completion(.doNotDismiss)
}
  1. 该方法会在用户响应通知的输入之后调用,第一个参数使用户的交互响应对象,第二个参数是一个回调闭包,作用是用于通知系统已经完成对用户输入的响应,我们需要在代码逻辑结束之后调用这个回调函数
  2. 如果我们收到的是文字响应,就取出文字对其进行处理,比如网络请求
  3. 如果用户点击了接受按钮,那么就先调用函数发送网络请求
  4. 然后创建一个拒绝按钮
  5. 将通知中的接受按钮替换为接受按钮
  6. 如果用户点击了拒绝按钮,则与接受按钮类似,先发送网络请求再将拒绝按钮替换为接受按钮
  7. 在方法的最后,调用completion回调函数,并传入一个枚举值。这个枚举常见有三个值:dismiss代表立即清理通知、doNotDismiss代表让通知还保持被展开为自定义UI的状态、dimissAndForwardAction代表让通知收缩成原始未被展开为自定义UI的状态

自定义的用户输入界面

上文中我们为了让用户可以在通知界面中与应用进行交互创建了一系列的Action,这些Action都是以系统自定义的UI而展现的,而这有时并不符合我们的要求,苹果还为我们提供了一种可以自定义用户输入的UI界面的方式。

首先我们给通知添加一个系统原生的按钮,用来触发自定义的用户输入界面的显示

1
2
3
4
5
6
func didReceive(_ request: notification: UNNotification) {
...
let pay = UNNotificationAction(identifier: "pay", title: "支付")
// 4
extensionContext?.notificationActions = [pay]
}

随后在用户点击按钮之后,将该通知设置为应用的First Responder

1
2
3
4
5
6
7
override var canBecameFirstResponder: Bool { return true }
func didReceive(_ response: UNNotificationResponse, completionHandler completion: @escaping (UNNotificationContentExtensionResponseOption)->Void) {
if response.actionIndetifier == "pay" {
becomeFirstResponder()
}
completion(.doNotDismiss)
}

如果你将代表通知内容的ViewController设置为了First Responder,iOS系统就会自动访问该ViewControllerinputView属性,并把返回值作为该通知的自定义的用户输入,所以我们需要重新inputView这个属性,并返回我们自定义的交互界面。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 1
private lazy var customPayView: PayView = {
let payView = PayView()
// 2
payView.onPayFinished = {[weak self] in
self?.resignFirstResponder()
}
return payView
}()
// 3
override var inputView: UIView? {
return customPayView
}
  1. 为了提升性能,自定义的用户输入界面使用懒加载的方式初始化
  2. 在自定义的输入界面中完成了交互之后,调用resignFirstResponder,iOS系统会隐藏这个自定义的界面,继续回到原始的自定义通知的状态
  3. 重写inputView属性,返回我们的自定义界面

这些工作全部做完之后,发送一个通知,在系统中收到通知之后长按这个通知出现了自定义的通知界面以及界面下方的支付按钮,点击界面下方的支付按钮,之后就可以看到系统的Action消失不见,取而代之的是我们自定义的输入界面出现在自定义通知界面下方。当我们点击界面上的按钮完成交互之后,就会调用对应的闭包,由于上面的代码我们在闭包中执行了resignFirstResponder方法,所以自定义的输入界面就会消失,取而代之的是系统的Action出现在自定义通知界面的下方。

隐藏系统默认元素

仔细观看上一张图片可以发现,在自定义界面的下方,还会存在着原始通知的titlebodyThe Sydney Observatory),如果你不想要这个默认显示的元素,需要在通知内容扩展Target中的Info.plist文件中做修改。打开Info.plist文件,展开NSExtension行,再展开NSExtensionAttributes行,找到一个叫做UNNotificatinoExtensionDefaultContentHidden的布尔型属性,将其值更改为YES即可。

使自定义通知界面可交互

默认情况下,自定义通知界面不支持触摸交互,如果你像要在你自定义的通知界面中支持用户触摸交互,需要修改通知内容扩展Target中的Info.plist文件。打开Info.plist文件,展开NSExtension行,再展开NSExtensionAttributes行,添加一个名字叫做UNNotificationExtensionUserInteractionEnabled的布尔型属性,并将其值设置为YES

在通知内容扩展中启动APP

在你的自定义通知界面上提供一个按钮,当用户点击这个按钮的时候启动对应的应用,这是一个再正常不过的需求,苹果也提供了非常简单的实现方式。当你在通知内容扩展的Target中执行以下代码的时候,该通知内容扩展进程就会结束,转而启动应用并调用应用中的通知代理方法userNotifcationCenter(_:didReceive:withCompletionHandler:)方法通知应用已经因为通知被启动。

1
extensionContext?.performNotificationDefaultAction()

总结

本篇文章从零开始讲解了iOS远程通知的基本所有的知识。首先我们认识了iOS应用中一个远程通知是如何从服务器发送到手机上的,也就是iOS远程通知的工作流程,介绍了与远程推送通知相关的PayloadHTTPHeader,还提供了一个最基础的远程通知案例;之后我们介绍了通知代理的使用,我们可以使用通知代理来处理用户点击通知打开应用的行为、为通知添加一些基本的按钮等;然后我们又介绍了通知服务扩展,我们可以使用服务通知扩展来完成类似中间件的任务,在通知真正到达手机之前对通知的内容进行一些修改或者发起一些网络请求等动作;最后我们介绍了通知内容代理,我们可以使用通知内容代理给通知设置一个自定义的UI界面,并且可以灵活的管理其中的按钮以及用户交互。在介绍这些与远程通知相关的特性的时候,中间穿插着许多小案例,这些案例所实现的功能是非常有用的,比如使通知中的badge可以累加到本地应用中的badge之上,你在这篇文章中除了可以学习关于iOS远程通知的API特性之外,还可以学到很多使用这些特性来解决一些问题的思想。看完了这篇文章即使发现自己记不住这些东西也没有问题,只要对文章的内容有一个印象,如果遇到问题及时来查阅就可以,本篇文章分章脉络清晰,就是为了可以让你快速找到你想要的答案。我是六游,现在我走了!

--- 本文结束 The End ---