【iOS】朴实 Push 普识——了解 Push Notifications 全貌

1,642 阅读26分钟

概述

推送通知(远程/本地)非常适合让用户及时了解相关内容,无论应用程序是在后台运行还是处于非活动状态。 通知可以显示消息、播放独特的声音或更新应用图标上的徽章,以及显示图像或播放视频。也可以提供几个选项供用户选择, 展示 UIViewController 或 UIView 可以实现的任何东西。

image.png

无论是本地通知还是远程通知,处理通知的一般过程都是相同的:

1.(强制)询问用户是否允许接收通知。

2.(可选)在显示前更改消息。

3.(可选)添加自定义按钮供用户交互。

4.(可选)配置自定义用户界面以显示通知。

5.(可选)用户对通知所做的操作的响应。

远程通知

Apple 遵循传输层安全性协议 (TLS) 构建了 Apple 推送通知服务 (APN)。可确保你(并且只有你)可以控制应用程序的通知。

通知消息流:

一般来说,1.iOS 设备通过 APN 进行身份验证,2.收到一个不透明的 Data 实例(设备令牌 device token)其中包含 APN 能够解码的唯一标识符。3.然后将 token 发送给程序服务器(提供程序 provider),4.provider 存储 token 以便将来可以触发通知。5.触发通知时,provider 利用 TLS 向 APNs 发送通知请求,并且携带 token。

本地通知

本地通知允许与远程通知相同的所有功能。唯一的区别是本地通知是根据设定的时间流逝或进入/离开地理区域触发的,而不是被推送到设备。

远程通知负载

远程推送是通过通过 Internet 发送数据来实现的。该数据称为有效负载,包含应用程序在推送通知到达时要做什么所需的所有信息。云服务负责构建该有效负载并将其与一个或多个唯一设备令牌一起发送到 APNs。

最初,通知使用二进制接口,其中数据包的每一位都有特定的含义。Apple 后更改了有效负载结构,使用单一简单的 JSON 结构。

有几个键是 Apple 定义的,其中一些是强制性的,其余的键和值由开发者根据需要定义。 对于常规远程通知,当前最大有效负载大小为 4KB(4,096 字节)。如果通知太大,Apple 会直接拒绝,并且会从 APNs 收到错误消息。

Aps 字典

aps 字典 Key 是通知有效负载的核心,其中包含 Apple 定义和拥有的所有内容。在此键的对象中,我们将配置以下项目:

  • 要显示给最终用户的消息。
  • 应将应用徽章编号设置为什么。
  • 通知到达时是否播放应该播放什么声音。
  • 通知是否在没有用户交互的情况下发生。
  • 通知是否触发自定义操作或用户界面。

Alert

最常使用的是 Alert Key。 允许开发者指定向用户显示的消息。 当推送通知首次发布时,alert 键只需要一个带有消息的字符串。 出于遗留原因,我们可以继续将值设置为字符串,但最好使用字典。 消息最常见的有效负载将包括一个简单的标题和正文:

{
  "aps": {
    "alert": {
      "title": "Your food is done.",
      "body": "Be careful, it's really hot!"
    }
  }
}

由于本地化,可能会遇到一些问题。

如何使用字典满足多语言用户需求。有两个选项可以解决此问题:

  • 在注册时调用 Locale.preferredLanguages 并将用户使用语言发送到服务器。
  • 将所有通知的本地化版本存储在应用程序包中。

每种方法都有利有弊。如果决定在应用程序端处理本地化,而不是传递 title 和 body 键,你可以使用 title-loc-key 和 title-loc-args 作为标题,使用 loc-key 和 loc-args 作为正文。

{
  "aps": {
    "alert": {
      "title-loc-key": "FOOD_ORDERED",
      "loc-key": "FOOD_PICKUP_TIME",
      "loc-args": ["2018-05-02T19:32:41Z"]
    }
  }
}

当 iOS 收到通知时,它会在你的应用程序中查找正确的 Localizable.strings 文件以自动获取正确的翻译,然后将日期和时间替换正确。

Group

从 iOS 12 开始,将 thread-id Key 添加到 aps 字典将让 iOS 将具有相同标识符值的所有通知合并到通知中心中的单个组中。 如果不使用此键,iOS 将默认将一个应用程序中的所有内容归为一组。 用户可以在 iOS 设置应用程序中关闭通知分组。

{
  "aps": {
    "alert": {
      "title": "Your food is done.",
      "body": "Be careful, it's really hot!",
    },
    "thread-id": "casserole-12345"
  }
}

Badge

可以标记应用程序图标。 如果希望应用程序图标显示数字徽章编号,只需使用 Badge 键指定它。 要清除徽章并将其移除,请将值设置为 0。如果直接使用这个值,这个值不是数学加法或减法,是一个将在应用程序图标上设置的绝对值。

{
  "aps": { 
    "alert": { 
        "title": "Your food is done.",
        "body": "Be careful, it's really hot!"
    },
    "badge": 12
  }
}

Sound

当 Alert 到达时,可以播放通知声音。 最常见的值只是字符串“default”, iOS 播放标准 Alert 声音。 如果想使用应用程序包中包含的自定义声音,可以改为在应用程序的主包中指定声音文件的名称。

声音必须为 30 秒或更短。 如果超过 30 秒,iOS 将忽略自定义声音并回退到默认声音。

{
  "aps": { 
    "alert": { 
        "title": "Your food is done.",
        "body": "Be careful, it's really hot!"
    },
    "sound": "filename.caf"
  }
}

可以使用 Mac 上的 afconvert 工具将自定义声音转换为以下四种可接受的格式之一:

Linear PCM
MA4 (IMA/ADPCM)
𝝁Law
aLaw

强 Alert

如果你的应用与健康和医学、家庭安全、公共安全或任何其他即使用户拒绝警报也可能需要提供通知的事情有关,将绕过“请勿打扰”和铃声开关设置,并始终播放声音。由于强 Alert 的破坏性,必须向 Apple 申请特殊权利才能启用它们。可以通过 Apple 开发者门户 进行操作。

developer.apple.com/contact/req…

如果应用程序需要显示一个强 Alert,需要使用字典作为声音键的值,而不仅仅是一个字符串:

  • critical:将此设置为 1 将指定此声音为强 Alert。
  • name:应用程序主包中的声音文件。
  • volume:介于 0.0(静音)和 1.0(全音量)之间的值。
{
  "aps": { 
    "alert": { 
        "title": "Your food is done.",
        "body": "Be careful, it's really hot!"
    },
    "badge": 12,
    "sound": {
      "critical": 1,
      "name": "filename.caf",
      "volume": 0.75
    }
  }
}

其他预定义 Key

Apple 定义了一些其他键作为 aps 字典的一部分,将在后面详细地讨论。 这些可用于后台更新通知,自定义通知类型、用户界面和通知分组。

自定义数据

aps 密钥之外的所有内容仅供你个人使用。 我们可能需要将额外的数据与推送通知一起传递给应用程序。 我们将像这样发送有效负载:

{
  "aps": { 
    "alert": { 
      "title": "Save The Princess!"
    }
  },
  "coords": {
    "latitude": 37.33182, 
    "longitude": -122.03118
  }
}

只要所有自定义数据都保存在 aps 字典之外,就永远不必担心与 Apple 发生冲突。

HTTP headers

developer.apple.com/documentati…

有效负载只是服务器发送给 APNs 的少数内容之一。除了唯一的 device token 之外,还可以发送额外的 HTTP 标头字段来指定 Apple 应如何处理通知以及如何将通知传递到用户的设备。目前尚不清楚为什么 Apple 选择将这些作为标头而不是有效负载的一部分。

折叠通知

这些标头之一是 apns-collapse-id HTTP 标头字段。当较新的通知取代较旧的通知时,Apple 使多个通知折叠为一个。

可以将任何唯一标识符放入字段中,最多 64 个字节。当一个通知被传递并且这个值被设置时,iOS 将删除之前传递的任何其他具有相同值的通知。

推送类型

从 iOS 13 开始,需要在标题中指定要发送的推送通知类型。当你的通知发送显示 Alert、播放声音或更新徽章时,应该指定 Alert 值。对于不与用户交互的静默通知,你可以指定 background 的值。

Apple 的文档指出,“此标头的值必须准确反映通知有效负载的内容。如果不匹配,或者所需系统上缺少标头,APNs 可能会延迟通知的传递或完全放弃通知。”

优先级

apns-priority,如果未指定,默认值为 10。指定优先级 10 将立即发送通知,但仅适用于包含 Alert、声音或徽章更新的通知。

任何包含 content-available 键的通知都必须指定优先级为 5。优先级为 5 的通知可能会被批处理并在稍后的时间点一起交付。

发送通知

Xcode 设置

添加功能

要告诉 Xcode 将在这个项目中使用推送通知,只需遵循以下简单步骤:

然后就:

注册通知

在 AppDelegate 文件中,修改以下代码:

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
            guard granted else { return }
            DispatchQueue.main.async {
                application.registerForRemoteNotifications()
            }
        }
        return true
    }

构建并运行应用程序,会看到允许通知的请求。

获取 device token

如果应用成功注册通知,iOS 将调用另一个代理方法,为应用提供设备令牌(device token)。 token 是一种不透明的数据类型,它是全球唯一的,它使用 APNs 标识一个应用程序设备组合。iOS 将其作为Data 类型而不是字符串提供,因此必须对其进行转换,大多数推送服务提供商也都需要字符串。

在 AppDelegate 中新增以下代码:

    func application(_ application: UIApplication,
                     didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        let token = deviceToken.reduce("") { $0 + String(format: "%02x", $1) }
        print(token)
    }

一旦设备成功注册推送通知,iOS 将调用此方法。令牌是一组十六进制字符;上面的代码只是将令牌转换为十六进制字符串。

设备令牌本身可以更改。当用户在另一台设备上安装应用程序、从旧备份恢复、重新安装 iOS 以及在某些其他情况下,Apple 将发布新的设备令牌。永远不应尝试将令牌链接到特定用户。

在物理设备上构建和运行应用程序。 会在 Xcode 控制台窗口中看到一个设备令牌(一串随机字符)。如果你在模拟器上运行,将不会在控制台中看到任何输出。 由于模拟器无法远程接收通知,因此永远不会调用委托方法。

发送通知

身份验证令牌类型

当 Apple 首次开始允许发送推送通知时,它使用 PKCS #12 存档文件格式,通常也称为 PFX 格式。

由于多种原因,这种格式使用起来非常麻烦:

  • 仅在一年内有效,需要每年“维护”证书。
  • 需要为生产和开发发行版提供单独的证书。
  • 发布的每个应用都需要单独的证书。
  • Apple 没有提供实际需要发送通知的“最终”格式的证书,要求从终端运行多个 openssl 命令以进行多次转换。

2016 年左右,为了解决上述问题,Apple 开始支持行业标准 RFC 7519,也就是众所周知的 JSON Web Tokens 或 JWT (jwt.io/)。这些令牌使用较新的 .p8 文件扩展名。不需要更新,不区分生产和开发,并且可以由该账号的所有应用程序使用。

获取身份验证令牌

创建身份验证令牌是一个简单的过程,只需要做一次。 前往 Apple 开发者中心 (apple.co/2HRPzxv),然后使用你的 Apple ID 登录。

发送推送

在模拟器上推送通知

创建一个名为 payload.apns 的文件,其中包含你要发送的通知,如下所示:

{
  "aps": {
    "alert": "Hello"
  },
  "Simulator Target Bundle": "Layer.Pusher"
}

该文件是推送通知的标准 JSON 表示形式,有两个不同之处。首先添加了 Simulator Target Bundle 键,它应该指定项目的包标识符的名称。确保更改它以匹配你的项目。

其次,在文件名上使用了 apns 扩展名。当你将文件拖到具有 apns 扩展名的模拟器时,它知道该文件是推送通知有效负载,而不是要保存的文件。

ezgif-1-986d69ab7f.gif

在设备上推送通知

GitHub 上有许多免费和开源项目,可让你向你的设备发送推送通知;考虑使用 PushNotifications (bit.ly/2jvEUtK),因为它支持较新的身份验证密钥,而其他一些应用程序不支持。只要它们支持身份验证密钥,你就可以使用任何应用程序。

确保选择 TOKEN 身份验证选项,然后选择你从 Developer Portal 下载的 p8 文件,并填写你的 Key ID、Team ID、Bundle ID 和 Device Token。

设备.gif

服务器端推送

虽然已成功发送通知,但手动执行此操作不会很有用。 当客户运行应用程序并注册接收通知时,服务器需要以某种方式存储他们的设备令牌,以便你可以在以后向他们发送通知。

有许多在线服务将为你处理服务器端。 可以简单地在 Google 上搜索类似“Apple 推送通知公司”的内容,会找到多个示例。 一些最受欢迎的是:

  • Amazon Simple Notification Service (SNS) (aws.amazon.com/sns/)

Vapor 项目

vapor.codes/

Vapor 是使用 Swift 进行服务器端开发的一个非常受支持的实现。无需太多代码,你就可以使用它来控制你的 SQL 数据库以及你的 RESTful API。该部分建议有 Vapor 基础阅读,或者查阅提供的参考详细阅读相关内容。

Vapor 提供了 APNs 的包可以方便的调用 APNs。

处理简单场景

显示前台通知

应用程序处于后台或终止状态,iOS 就会自动处理你的通知。当应用程序运行时会发生什么?默认情况下,iOS 只是简单地接收通知并且从不显示它。如果想让 iOS 当应用程序在前台运行时显示你的通知,你需要实现 UNUserNotificationCenterDelegate 方法 userNotificationCenter(_:willPresent:withCompletionHandler:),当应用程序在前台时,通知发送到应用程序时将调用。 此方法的唯一要求是在返回之前调用 complation。 在这里,可以确定收到通知时想要发生的事情。

回到之前的代码,添加代理:

    func application(_ application: UIApplication, didFinishLaunchingWithOptions
                     launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        UNUserNotificationCenter.current()
            .requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
                guard granted else { return }
                UNUserNotificationCenter .current().delegate = self
                DispatchQueue.main.async {
                    application.registerForRemoteNotifications()
                }
            }
        return true
    }

并实现对应方法:

extension AppDelegate:UNUserNotificationCenterDelegate
{
    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                willPresent notification: UNNotification,
                                withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
        completionHandler([.banner, .sound, .badge]) 
    }
}

具体的枚举含义参考developer.apple.com/documentati…

最终效果:

前台通知.gif

点击通知

用户实际上会点击通知,这将触发应用程序启动。有时,通知应该将用户带到应用程序中的特定的页面。userNotificationCenter(_:didReceive:withCompletionHandler:)代理方法是处理该路由的地方。

可以使用以下远程通知负载:

{
  "beach": true,
  "aps": {
    "alert": {
      "body": "Tap me!"
    }
  }
}

并添加以下代码:

func userNotificationCenter(_ center: UNUserNotificationCenter,
                            didReceive response: UNNotificationResponse,
                            withCompletionHandler completionHandler: @escaping () -> Void) {
    defer { completionHandler() }
    guard response.actionIdentifier == UNNotificationDefaultActionIdentifier else {
        return
     }
    let payload = response.notification.request.content
    guard payload.userInfo["beach"] != nil else { return }
    UIApplication.shared.windows.first?.rootViewController?.present(BeachViewController(), animated: true)
    }

最终效果如下:

点击通知.gif

静默通知

有时发送通知时,不希望用户在收到通知时真正得到视觉提示。

为了启用静默通知,你必须采取三个不同的步骤:

  • 更新有效负载。
  • 添加后台模式功能。
  • 实现一个新的 UIApplicationDelegate 方法。

更新有效负载

第一步是简单地向的有效负载添加一个新的键值对。 在 aps 字典中,添加一个值为 1 的 content-available 新键。这将告诉 iOS 在收到推送通知时唤醒应用程序,以便它可以预取与通知相关的任何内容。

{
  "aps": {
    "content-available": 1
  },
  "image": "https://bit.ly/3dfsW2n",
  "text": "A nice picture of the Earth"
}

注意:不要将值设置为 0,以为你已禁用此功能。 如果不想要静默通知,请不要包含 content-available 键!

添加后台模式功能

在 Signing & Capabilities 选项卡上,按 + Capability 按钮并添加 Background Modes 功能。 从选项中选中列表底部的远程通知复选框。

应用委托更新

Demo 程序使用了 CoreData 框架,我们使用名为 Message 的 Model 存储标题、图像、时间。

在 AppDelegate 添加以下代码:

func application(_ application: UIApplication,
                 didReceiveRemoteNotification userInfo: [AnyHashable : Any],
                 fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
    guard let text = userInfo["text"] as? String,
          let image = userInfo["image"] as? String,
          let url = URL(string: image) else {
        completionHandler(.noData)
        return
    }
    let context = persistentContainer.viewContext
    context.perform {
        do {
            let message = Message(context: context)
            message.image = try Data(contentsOf: url)
            message.received = Date()
            message.text = text
            try context.save()
            completionHandler(.newData)
        } catch {
            completionHandler(.failed)
        }
    }
}

此时,我们收到 Push 后打开 App:

静默.gif

CoreData 的使用在本文也不会详细介绍,可以当前简单参考示例代码。

自定义操作

有时,一个简单的点击是不够的。

category

通知类别允许为每个 category 指定最多四个自定义操作,这些操作将与推送通知一起显示。 如果通知出现在横幅中,系统只会显示前两个操作,因此首先配置最相关的操作。

注意:模拟器当前不显示类别。 请务必在物理设备上进行测试。

将使用以下有效负载进行演示:

{
  "aps": {
    "alert": {
      "title": "Long-press this notification"
    },
    "category": "AcceptOrReject",
    "sound": "default"
  }
}

我们可以在 AppDelegate 新增枚举:

public let categoryIdentifier = "AcceptOrReject"
public enum ActionIdentifier: String {
  case accept, reject
}

在 AppDelegate 添加方法:

private func registerCustomActions() {
  let accept = UNNotificationAction(
    identifier: ActionIdentifier.accept.rawValue,
    title: "Accept")
  let reject = UNNotificationAction(
    identifier: ActionIdentifier.reject.rawValue,
    title: "Reject")
  let category = UNNotificationCategory(
    identifier: categoryIdentifier,
    actions: [accept, reject],
    intentIdentifiers: [])
  UNUserNotificationCenter.current().setNotificationCategories([category])
}

在这里,创建一个带有两个按钮的通知 category。 当推送通知到达且 category 设置为 AcceptOrReject 时,自定义操作将被触发,iOS 将在推送通知底部包含两个按钮。

application(_:didRegisterForRemoteNotificationsWithDeviceToken:) 末尾添加对 registerCustomActions() 的调用,进行注册。

func application(_ application: UIApplication,
                 didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    let token = deviceToken.reduce("") { $0 + String(format: "%02x", $1) }
    print(token)
    registerCustomActions()
}

可以在 userNotificationCenter(_:didReceive:withCompletionHandler:).里处理用户的点击:

// AcceptOrReject
if response.notification.request.content.categoryIdentifier == categoryIdentifier {
    let action = ActionIdentifier(rawValue: response.actionIdentifier)
    print("You pressed (response.actionIdentifier)")
}

我们稍作一下处理,就可以达到以下的效果:

自定义操作.gif

Notification Service Extension

有时我们需要采取额外的步骤才能将通知呈现给用户。例如我们可能希望下载图像或更改通知的文本。

假如我们有一个加密的远程通知负载:

{
  "aps": {
    "alert": {
      "title": "Pbatenghyngvbaf!",
      "body": "Yrg'f pbagvahr!"
    },
    "sound": "default",
    "badge": 1,
    "mutable-content": 1
  },
  "media-url": 
          "uggcf://jbyirevar.enljraqreyvpu.pbz/obbxf/abg/ohaal.zc4"
}

这像是胡言乱语,此通知是由加密的,我们需要在设备上显示通知之前对内容进行解密。

胡言乱语.gif

创建 Service Extension

我们可以新建一个 Target,选择 Notification Service Extension,命名为 Payload Modification:

自动生成 NotificationService 文件,我们能看到默认的两个方法:

import UserNotifications

class NotificationService: UNNotificationServiceExtension {

    var contentHandler: ((UNNotificationContent) -> Void)?
    var bestAttemptContent: UNMutableNotificationContent?

    override func didReceive(_ request: UNNotificationRequest,
                             withContentHandler contentHandler: 
                             @escaping (UNNotificationContent) -> Void) {
        self.contentHandler = contentHandler
        bestAttemptContent = (request.content.mutableCopy() as? 
        UNMutableNotificationContent)
        
        if let bestAttemptContent = bestAttemptContent {
            // Modify the notification content here...
            bestAttemptContent.title = 
            "(bestAttemptContent.title) [modified]"
            
            contentHandler(bestAttemptContent)
        }
    }
    
    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(_:withContentHandler:) 在通知到达时被调用。我们有大约 30 秒的时间来执行我们需要采取的任何操作。如果时间用完了,iOS 会调用第二种方法 serviceExtensionTimeWillExpire 给我们最后一次快点完成的机会。如果在时间用完之前没有调用完成处理程序,iOS 将继续使用原始有效负载。

这里会默认使用 Xcode 支持的最高 iOS 版本,记得进行修改。

解密有效负载

我们可以在 Payload Modification里新增一个 ROT13.swift 文件进行解密,进行简单的 13 位的偏移:

import Foundation
struct ROT13 {
  static let shared = ROT13()
  private let upper = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
  private let lower = Array("abcdefghijklmnopqrstuvwxyz")
  private var mapped: [Character: Character]= [:]
  private init() {
    for i in 0 ..< 26 {
      let idx = (i + 13) % 26
      mapped[upper[i]] = upper[idx]
      mapped[lower[i]] = lower[idx]
    }
  }
  public func decrypt(_ str: String) -> String {
    return String(str.map { mapped[$0] ?? $0 })
  }
} 

我们在// Modify the notification content here...下,可以将代码修改为:

bestAttemptContent.title = ROT13.shared.decrypt(bestAttemptContent.title)
bestAttemptContent.body = ROT13.shared.decrypt(bestAttemptContent.body)

解密.gif

下载视频

服务扩展也是我们可以下载视频或其他内容的地方。 首先,我们需要找到附加媒体的 URL。 一旦有了它,我们可以尝试将它下载到用户设备上的某个临时目录中。 获得数据后,可以创建一个 UNNotificationAttachment 对象,将其附加到实际通知中。

我们可以将代码简单修改,下载图片到本地后,构造 attachment 给 bestAttemptContent:

override func didReceive(_ request: UNNotificationRequest,
                         withContentHandler contentHandler:
                         @escaping (UNNotificationContent) -> Void) {
    self.contentHandler = contentHandler
    bestAttemptContent = (request.content.mutableCopy()
                          as? UNMutableNotificationContent)
    
    guard let bestAttemptContent = bestAttemptContent else {
        return
    }
    // title & body
    bestAttemptContent.title = ROT13.shared.decrypt(bestAttemptContent.title)
    bestAttemptContent.body = ROT13.shared.decrypt(bestAttemptContent.body)
    
    // Attachment
    guard let urlPath = request.content.userInfo["media-url"] as? String,
          let url = URL(string: ROT13.shared.decrypt(urlPath)) else {
        contentHandler(bestAttemptContent)
        return
    }
    URLSession.shared.dataTask(with: url) { data, response, _ in
        defer { contentHandler(bestAttemptContent) }
        guard let data = data else { return }
        let file = response?.suggestedFilename ?? url.lastPathComponent
        let destination = URL(fileURLWithPath: NSTemporaryDirectory())
            .appendingPathComponent(file)
        do {
            try data.write(to: destination)
            let attachment = try UNNotificationAttachment(identifier: "",
                                                          url: destination)
            bestAttemptContent.attachments = [attachment]
        } catch {
        }
    }.resume()
}

运行后最终效果如下:

下载视频.gif

我们不一定总是希望每次收到推送通知时都运行扩展程序 。 要告诉 iOS 应该使用服务扩展,只需在 aps 字典中添加一个 mutable-content key,其整数值为 1。可以查看上述远程负载通知。

与 Main target 共享数据

Main target 和扩展程序是两个独立的进程。默认情况下,不能在它们之间共享数据。如果需要,可以通过应用程序组完成,它允许访问在多个相关应用程序和扩展之间共享的组容器。

新增一个 Group:

将 Main Target 和 Payload Modification 都加入到该 Group 中。

标记应用程序图标

应用程序开发人员会向服务器发送有关应用程序图标当前显示多少徽章的信息,然后推送通知会将该数字加一。虽然这是可行的,但要在服务器上处理相当多的额外开销。

通过使用服务扩展,我们现在可以假装 badge key 在那里意味着将徽章计数增加该数字。我们现在只需在本地存储多少项目未读,而不必将这些详细信息发送回你的服务器进行跟踪。

我们将一个名为 UserDefaults.swift 的新 Swift 文件添加到 Main target:

import Foundation
extension UserDefaults {

  static let suiteName = "group.Layer.Pusher"
  static let extensions = UserDefaults(suiteName: suiteName)!
  
  private enum Keys {
    static let badge = "badge"
  }
  
  var badge: Int {
    get { UserDefaults.extensions.integer(forKey: Keys.badge) }
    set { UserDefaults.extensions.set(newValue, forKey: Keys.badge) }
  }
}

在 Target Membership 部分中选中服务扩展及 Main target:

回到 NotificationService.swift,编辑 didReceive(_:withContentHandler:) 方法。可以添加以下代码来检查标记信息:

if let increment = bestAttemptContent.badge as? Int {
  if increment == 0 {
    UserDefaults.extensions.badge = 0
    bestAttemptContent.badge = 0
  } else {
    let current = UserDefaults.extensions.badge
    let new = current + increment
    UserDefaults.extensions.badge = new
    bestAttemptContent.badge = NSNumber(value: new)
  }
}

最终效果如下:

未读数.gif

调试

有时候事情总是不顺利。调试服务扩展的工作方式几乎与任何其他 Xcode 项目相同。但是,由于它是目标而不是应用程序,因此我们必须采取一些额外的步骤进行调试。

  • 打开 NotificationService.swift 文件并在解码标题的行上设置断点。
  • 构建并运行你的应用程序。
  • 在 Xcode 的菜单栏中,选择 Debug ▸ Attach to Process by PID or Name...。
  • 在出现的对话框窗口中,输入 Payload Modification — 或你为目标命名的任何名称。
  • 点击 Attach button。

如果你向自己发送另一个推送通知,Xcode 应该在你设置的断点处停止执行。 请注意,调试服务扩展有点挑剔,有时它根本不起作用。 如果无法找到列出的进程,可能必须完全重新启动 Xcode,甚至可能需要重新启动你的设备。

Notification Content Extension

创建 Content Extension

我们可以创建一个新的通知内容扩展来处理并显示自定义 UI。

每个自定义 UI 都必须有唯一的类别标识符。找到 UNNotificationExtensionCategory 的 Key。此标识符将其与的 main target 连接起来。可以自行修改。

如果有多个类别类型都将使用相同的 UI,只需将 UNNotificationExtensionCategory 的类型从 String 更改为 Array 并列出想要支持的每个类别名称。

设计 UI

我们会注意到包括一个故事板和 VC 供我们使用。 我们将向用户展示我们通过推送通知发送给他们的坐标地图。

给它加一个 MKMapView:

回到 NotificationViewController 修改 didReceive 方法:

func didReceive(_ notification: UNNotification) {
    guard let mapView = mapView else { return }
    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
    }
    let location = CLLocation(latitude: latitude, longitude: longitude)
    let region = MKCoordinateRegion(
        center: location.coordinate,
        latitudinalMeters: radius,
        longitudinalMeters: radius)
    mapView.setRegion(region, animated: false)
}

使用以下 JSON:

{
  "aps": {
    "alert" : {
      "title" : "Dazhongsi Subway Station"
    },
    "category" : "myNotificationCategory",
    "sound": "default"
  },
  "latitude" : 39.964478,
  "longitude" : 116.339401,
  "radius" : 500
}

最终效果如下:

contentExt.gif

如果我们尝试平移或缩放地图,自定义 UI 视图控制器虽然功能齐全,但不接受任何类型的用户输入。 如果我们想在你的自定义用户界面上支持交互式触摸,需要编辑扩展的 Info.plist 并添加值为 YES 的 UNNotificationExtensionUserInteractionEnabled 属性键。此时,我们可以像在普通视图控制器上一样进行操作。重要的是要记住,完成此操作后,我们将负责处理所有操作和回调。例如,点击 UI 将不再打开我们的应用程序。

启动应用程序

extensionContext?.performNotificationDefaultAction()

关闭用户界面

extensionContext?.dismissNotificationContentExtension()

调整初始视图的大小

如果在自定义 UI 到位时仔细观察,可能会注意到它可能开始有点太大,然后缩小到适当的大小。 Apple 将视图的初始高度实现为宽度的百分比,而不是我们指定特定的大小。

在目标扩展的 Info.plist 中,我们可以再次展开 NSExtension 行,在这里看到 UNNotificationExtensionInitial ContentSizeRatio,默认为 1。我们应该将其设置为小于或等于 1 的十进制值,表示高度与宽度的比率。例如,如果你指定 0.8,则 UI 将以宽度为 80% 的高度开始。

接受文本输入

有时,我们可能希望允许用户键入一些文本以响应推送通知。

转到 AppDelegate.swift 文件。添加以下枚举:

private let CommentCategoryIdentifier = "myNotificationCategory"
private enum CommentActionIdentifier: String {
  case comment
}

并新增以下方法,并在 didRegisterForRemoteNotificationsWithDeviceToken 进行调用:

private func registerCommentActions() {
  let ident = CommentActionIdentifier.comment.rawValue
  let comment = UNTextInputNotificationAction(
    identifier: ident,
    title: "Comment")
  let category = UNNotificationCategory(
    identifier: CommentCategoryIdentifier,
    actions: [comment],
    intentIdentifiers: [])
  UNUserNotificationCenter.current().setNotificationCategories([category])
}

最终表现如下:

input.gif

在 NotificationViewController.swift 中,添加以下方法,可以接收文本输入:

    func didReceive(_ response: UNNotificationResponse,
                    completionHandler completion:
                    @escaping (UNNotificationContentExtensionResponseOption) -> Void) {
        defer { completion(.dismiss) }
        guard let response = response as? UNTextInputNotificationResponse else {
          return
        }
        let text = response.userText
    }

如果希的 UI 可以进行多次交互,则将 .doNotDismiss 传递给完成处理程序。

还有第三种不常用的类型。可以指定 .dismissAndForwardAction 来简单地关闭自定义 UI 并将通知直接发送到 Main Target。

附件

如果项目还包括服务通知扩展,它将在通知内容扩展之前执行。我们同时拥有这两个扩展的一个常见原因是前者会下载后者想要使用的附件。

我们可以在 NotificationViewController.swift 的 didReceive(_:) 中获取到附件:

let images: [UIImage] = notification.request.content.attachments
  .compactMap { attachment in
    guard attachment.url.startAccessingSecurityScopedResource(),
      let data = try? Data(contentsOf: attachment.url),
      let image = UIImage(data: data) else {
      return nil
    }
    attachment.url.stopAccessingSecurityScopedResource()
    return image
  }
imageView?.image = images.first

由于 iOS 执行沙盒的方式,出于安全原因,我们不能直接访问附件, 必须在调用中包装对附件进行访问。

隐藏默认内容

如果我们正在创建自定义 UI,很可能我们已经在 UI 中的某个位置显示了通知的标题和正文。 如果是这种情况,我们可以通过编辑内容扩展的 Info.plist 来告诉 iOS 不要在你的视图下显示该默认数据。 再次展开 NSExtension 属性。 这一次,在 NSExtensionAttributes 下,添加一个名为 UNNotificationExtensionDefaultContentHidden 的 key 并设置为 1。

本地通知

虽然你设备上显示的绝大多数通知都是远程通知,但也可以在本地显示源自用户设备的通知。有三种不同类型的本地通知:

  • 日历:通知发生在特定日期。
  • 间隔:在特定时间后发生通知。
  • 位置:进入特定区域时会发出通知。

虽然使用频率较低,但本地通知仍然对许多应用程序发挥着重要作用。

创建触发器

本地通知使用所谓的触发器,这是将通知传递给用户的条件。共有三种可能的触发器,每种触发器对应一种通知类型:

  • UNCalendarNotificationTrigger
  • UNTimeIntervalNotificationTrigger
  • UNLocationNotificationTrigger

所有三个触发器都包含一个重复属性,它允许我们多次触发触发器。

UNCalendarNotificationTrigger

例如,我们可能希望在早上 8:30 或仅在星期一触发。使用 DateComponents 可以让我们根据需要指定尽可能多的需求。要让闹钟在每周一上午 8:30 响起,我们可以编写如下代码:

let components = DateComponents(hour: 8, minute: 30, weekday: 2)
let trigger = UNCalendarNotificationTrigger(
  dateMatching: components,
  repeats: true)

UNTimeIntervalNotificationTrigger

这个触发器非常适合计时器。 你可能希望在 10 分钟后显示通知,而不是在特定时间显示。

let trigger = UNTimeIntervalNotificationTrigger(
  timeInterval: 10 * 60,
  repeats: false)

UNLocationNotificationTrigger

我们需要知道目标位置中心的纬度和经度以及应该使用的半径。这三个项目在地图上定义了一个圆形区域,利用此触发器,我们可以指定要监视的 CLCircularRegion。当设备进入所述区域时,将触发通知。

必须有权使用 Core Location,并且必须有权在用户使用应用程序时监控用户的位置。

例如,如果想在用户进入某地周围 1 英里半径范围内时安排通知,可以使用类似于以下的代码:

let oneMile = Measurement(value: 1, unit: UnitLength.miles)
let radius = oneMile.converted(to: .meters).value
let coordinate = CLLocationCoordinate2D(
  latitude: 37.33182,
  longitude: -122.03118)
let region = CLCircularRegion(
  center: coordinate,
  radius: radius,
  identifier: UUID().uuidString)
region.notifyOnExit = false
region.notifyOnEntry = true
let trigger = UNLocationNotificationTrigger(
  region: region,
  repeats: false)

定义内容

我们使用以下代码通过本地通知完全模仿相同的数据:

let content = UNMutableNotificationContent()
content.title = "New Calendar Invitation"
content.badge = 1
content.categoryIdentifier = "CalendarInvite"
content.userInfo = [
  "title": "Family Reunion",
  "start": "2022-04-10T08:00:00-08:00",
  "end": "2022-04-10T12:00:00-08:00"
]
content.sound = UNNotificationSound.default // 声音
content.threadIdentifier = "My group identifier here" // 分组

调度

现在我们已经定义了通知应该何时发生以及显示什么,我们只需要让 iOS 为你处理它:

let identifier = UUID().uuidString
let request = UNNotificationRequest(
  identifier: identifier,
  content: content,
  trigger: trigger)
UNUserNotificationCenter.current().add(request) { error in
  if let error = error {
    // Handle unfortunate error if one occurs.
  }
}

每个请求都需要有一个唯一标识符,以便以后如果我们希望在实际触发通知之前取消通知。 UUID 根据定义是唯一的,因此它是一个很好的选择。

参考