“实时活动”功能是指在配送员接近时,该功能会自动显示在锁屏界面上,并且无需你打开应用程序就能进行更新。苹果在iOS 16.1版本中推出了这一API;而谷歌则在Android 16版本中提供了自己的实现方案,称为“实时更新”功能。
这两个平台上的产品需求是相同的,但相应的开发协议却截然不同。
在iOS系统中,Apple Push Notification服务会自动更新系统自带的SwiftUI组件,而你的应用程序代码根本不会被执行。而在Android系统中,则不存在这种由系统管理的远程更新机制。相反,Firebase Cloud Messaging只会发送数据消息,这些消息会触发后台服务,然后由你的应用程序代码来重新显示通知内容。
在这本手册中,你将学习如何同时实现这两种功能。你需要编写一个TypeScript编写的API,并为其提供两种原生实现方案:一种是使用Swift语言实现的,它与ActivityKit接口进行交互;另一种则是使用Kotlin语言实现的,它与NotificationManager进行交互。你还需要从头开始编写APNs客户端和FCM客户端,每段代码大约只有六十行长,且不需要使用任何第三方库。同时,你也会了解到那些会导致这些API功能出现异常的隐藏问题——因为在这里,几乎所有的错误都不会产生任何明显的提示信息。
为了帮助大家更好地理解这些内容,我制作了一个名为DropTrack的配送跟踪演示项目。下面介绍的所有内容都来自于这个项目,其中还包括一项针对三星三款设备的测试实验,实验的最后还附有一张显示了硬编码允许列表的截图。
目录
先决条件
要顺利完成学习,您需要准备以下工具:
-
Node.js 20或更高版本。
-
Xcode 26或更高版本,同时需要拥有付费的Apple开发者账户。APNs功能需要使用真实的签名密钥以及设备构建文件;模拟器无法接收Live Activity推送通知。
-
安装Android Studio,并使用基于
google_apis系统镜像生成的API 36模拟器。FCM功能需要Google Play服务,而纯Android开源项目镜像中并不包含这些服务。 -
为Android部分的学习内容,您还需要一个Firebase项目。
-
您需要具备Swift、Kotlin或TypeScript编程语言的相关知识。本文介绍的是与原生模块相关的内容,并非仅针对JavaScript编写的教程。
我在文中使用的具体版本如下:
| 工具或库 | 版本号 |
|---|---|
| Expo SDK | 57.0.4 |
| React Native | 0.86.0 |
| React | 19.2.3 |
| TypeScript | 6.0.3 |
@bacons/apple-targets |
4.0.7 |
androidx.core |
1.17.0 |
firebase-bom |
33.7.0 |
| Xcode | 26.3 |
androidx.core的版本是必须使用的。1.17.0这个版本回退了Android 16时期的相关API,因此这些API可以与基础SDK 36一起使用进行编译。稍后我会解释其中的原因。
Live Activities与Live Updates的本质
Live Activity并不属于推送通知范畴。推送通知是一种一次性的通知方式,而Live Activity则是一种持久的、可以快速查看的通知形式;它具有自己的状态,并且这种状态会实时发生变化;同时,Live Activity也有特定的系统显示界面。
不同平台对Live Activity的支持情况如下:
-
iOS 16.1引入了锁屏界面上的Live Activities功能;16.2版本增加了Dynamic Island功能;17.2版本添加了“点击即可启动”功能;18版本则引入了广播频道功能。更多详细信息,请参阅Apple的ActivityKit文档以及Live Activities人机界面指南。
-
Android 16(API 36)引入了
Notification.ProgressStyle,即分段式进度条功能。 -
Android 16的QPR1版本(API 36.1),也就是第一个季度发布的版本,引入了“推广”功能模块:状态栏提示图标以及锁屏界面上的特殊显示区域。Google将这一系列功能称为以进度为中心的通知系统。
我开发了DropTrack来测试所有这些功能。一个订单会经历七个步骤,从“下单”到“正在配送中”。在配送过程中,也可以重新分配快递员。用户可以通过应用内的控制面板在本地查看订单状态,或者通过推送通知远程获取信息。

您之前在哪里见过这个功能
在开始编写任何代码之前,先了解为什么需要这个API是很重要的,因为仅仅用“锁屏界面上的卡片”来描述它的作用是不够的。
想想你上次点餐的经历:你下了单,然后锁上了手机,接下来会发生什么?如果没有实时更新功能,应用只能通过状态变化时发送推送通知来与你联系——订单已确认、餐厅正在准备食物、快递员已被分配、快递员距离目的地还有两站路程、快递员已经到达……
对于同一笔订单来说,这就意味着会有五条通知。如果每笔订单都是这样,用户很快就会选择关闭这些通知功能。
实时更新功能可以将这五条通知合并成一张会在锁屏界面上动态变化的卡片。同样的信息通过一个界面呈现出来,而且不会让用户感到疲劳。这就是我们开发这个功能的全部意义所在。
苹果自己的《人类界面指南》中提到了这类功能的常见应用场景:体育比赛比分、健身训练、出行服务以及配送任务。谷歌的文档也列出了几乎相同的场景,指出“关键的应用场景包括共享出行、配送服务和导航功能”。那页文档甚至用“进度点”来描述食物准备和配送过程中的各个阶段,并用不同颜色来表示交通状况,而这正是你即将开发的这个界面的核心功能。
已经实现这一功能的应用
为了避免重复介绍这些应用,我直接查看了它们在App Store上的官方介绍。因为一家公司自己描述自己的功能,才是最有力的证据。以下列出的每个应用都用自己官方的语言说明了它们是如何使用实时更新功能的:
| 应用名称 | 实时更新功能展示的内容 | 来源链接 |
|---|---|---|
| Chowdeck | 配送进度,直接显示在锁屏界面和动态界面上 | App Store页面 |
| Flighty | 出发倒计时、登机口变更信息、候机时间以及到达进度 | Flighty的帮助文档 |
| ESPN | 主要足球联赛、NHL和NBA的比赛关键数据和统计信息 | App Store页面 |
| MLB | 锁屏界面上的比赛更新信息 | App Store页面 |
| FotMob | 锁屏界面上的足球比分 | App Store页面 |
| CARROT Weather | 即将到来的降雨信息和风暴强度 | App Store页面 |
| Structured | 番茄工作法计时器 | App Store页面 |
苹果也会在自己的软件中提供这一功能。根据Apple Sports的帮助页面介绍,当启用“实时动态”功能后,“你可以在iPhone的锁屏界面或Apple Watch上实时获取相关信息,从而随时了解比赛的进展。”
还有另外两种功能,主要是由科技媒体进行报道的,而非相关公司自己发布的。Uber Eats在2023年5月推出了这一功能,可以显示订单状态、预计送达时间以及司机的姓名和照片。DoorDash则在2023年12月跟进推出了这一功能,在动态岛界面中实时显示预计送达时间。
关于这个表格,有两条需要说明的事项。首先,如果某个应用程序在列表中没有提到“实时动态”功能,并不意味着它没有这个功能,只是我还没有亲自确认而已。Uber Eats就是这样的例子。其次,这个表格反映的是我阅读时看到的信息,任何应用程序都可能在后续版本中添加或删除这一功能。
食品配送服务是这类功能的典型代表,DropTrack也是根据这种模式进行设计的。而打车服务则是应用这一功能风险最高的领域:比如“司机将在3分钟内到达”、“车牌号码”、“当前行程状态”等等——这些信息都是在手机被锁住的情况下,站在路边时可以快速查看的。动态岛界面之所以设计成这样的形式,正是为了满足这类需求:用户可以在半秒钟内快速看到相应的图标和进度百分比。
金融领域则是人们容易忽略的一个例子,在这个领域,分段进度条会发挥很大的作用。例如,加密货币的存款流程并不是“待确认”然后就“完成”,而是需要经过“12次网络确认中的3次已完成”,这种分段进度条能够清晰地反映这一过程,与ProgressStyle中定义的各个阶段以及SwiftUI中的显示方式完全匹配。同样的展示形式也适用于银行转账的结算过程或信用卡支付的完成情况。
不过,在这里我需要特别提醒大家:我查看了各大交易所和新型银行的App Store列表,发现没有一家应用程序明确提到了“实时动态”功能。因此,可以把这一功能视为一个非常适合但尚未被纳入该分类的功能,并不能将其视为现有的成熟技术。
以下是我希望在开始讨论之前就能看到的表格:
| 类别 | 更新内容 | 负责提供这些更新的团队 | 为什么使用卡片形式来显示信息比使用通知更好 |
|---|---|---|---|
| 食品配送 | 快递员的位置、预计送达时间、骑手更换情况 | 后端系统 | 多项更新内容会合并成一张动态变化的卡片来显示 |
| 打车服务 | 司机即将到达、当前行程状态、车费信息 | 后端系统 | 手机被锁住时也可以快速查看这些信息 |
| 加密货币存款 | 12次确认中的3次已完成 | 后端系统 | 采用进度条来显示更新状态,而不是简单的“完成”提示 |
| 银行转账 | 转账结算阶段 | 后端系统 | 因此类操作产生的技术支持请求较少 |
| 包裹配送 | 包裹已出发、正在运送中、已送达 | 后端系统 | 相关信息会持续显示在屏幕上,而不会被其他信息覆盖 |
| 计时器或运动应用 | 已经过的时间 | 设备本身 | 不需要服务器支持,这是最简单的应用场景 |
请看第三列。在所有具有商业价值的场景中,更新都是由服务器发起的,而不是设备本身。这意味着该功能的全部价值都体现在推送路径上。而恰恰在这一点上,两个平台出现了差异;也正是在这里,问题会悄然发生,而本手册中大部分关于使用这些功能的难点也集中在这一环节。
使用SwiftUI来设计界面其实是比较简单的一部分。真正困难的是如何让后端在应用程序关闭的情况下依然能够可靠地更新这些界面元素,而且这个过程在不同的平台上会有所不同。
为什么你需要一个自定义的原生模块
在开始编写任何Swift代码之前,我首先考虑了其他替代方案,但它们都存在问题——这些问题其实很有启发性。
expo-live-activity提供了一个预定义的界面布局以及固定的内容显示形式。然而DropTrack需要一个自定义的分段进度条、一种用于重新分配任务的处理机制,以及专用的动态岛界面布局。所有这些功能都需要使用ActivityAttributes以及相应的SwiftUI代码来实现。你可以通过最快的方式完成初始渲染,然后再进行后续的调整。
Notifee是大多数React Native开发者都会选择的通知库,但它目前已经不再更新了。截至本文撰写时,它的GitHub仓库已被标记为“已归档”,最后一次发布的版本是@notifee/react-native@9.1.8,发布于2024年12月。此外,它的Android模块仍然使用compileSdk 34作为开发标准,因此它不支持实时更新功能。目前,自定义原生模块才是实现React Native与Android 16实时更新功能的唯一途径。
所以,最终我们需要:一个本地Expo模块、一套TypeScript API以及两个原生后端服务。
modules/droptrack-live/
├── expo-module.config.json ← 自动链接功能(适用于苹果和安卓平台)
├── index.ts ← 公开的JavaScript API
├── src/ ← TypeScript类型定义、原生绑定代码以及Web版本的空操作逻辑
├── ios/
│ ├── DeliveryAttributes.swift ← ActivityKit相关契约代码
│ └── DroptrackLiveModule.swift ← 将JavaScript代码转换为ActivityKit可识别的格式
└── android/
├── DroptrackLiveModule.kt ← 将JavaScript代码转换为NotificationManager可识别的格式
├── DeliveryNotifier.kt ← 公用的通知构建模块
└── DroptrackFcmService.kt ← 负责向NotificationManager发送推送通知的代码
JavaScript接口被刻意设计得非常简洁,并且在不同的平台上都是相同的:
// 卡片的动态部分:每次更新都会替换这个对象。
// 需要保持它的体积小,因为iOS系统会拒绝接收超过4KB的ActivityKit推送数据。
export type DeliveryState = {
status: string; // 显示在标题处的文本,例如“已取件”
progress: number; // 值范围为0.0到1.0,在两个平台上都会用来显示进度条
etaEpochMillis: number; // 以Unix毫秒为单位,JavaScript使用这个值来决定进度条的形状,Swift会对其进行转换
stopsRemaining: number; // 显示为“还有2站”或“下一个就是您”
courierName: string; // 这个值是动态变化的,因为任务可能会在运输过程中被重新分配
riderReassigned: boolean; // 如果任务被重新分配,这个值会变为true,从而改变界面的显示方式
};
// 三个动词,对应ActivityKit的生命周期。Android平台也使用类似的三个方法
// 来实现相同的功能,因此调用者无需根据平台不同而进行任何调整。
// startDelivery:启动任务并返回系统分配的唯一标识符。这个标识符在后续的所有操作中都是必需的,
// 但是应用程序重启后,这个标识符就会丢失。
startDelivery(info: DeliveryInfo, state: DeliveryState): Promise;
// updateDelivery:直接替换界面的显示内容。卡片会发生变化,但不会重新出现。
updateDelivery(id: string, state: DeliveryState): Promise;
// endDelivery:完成任务并关闭界面。如果省略dismissAfterSeconds参数,
// 则界面会持续显示一段时间。
endDelivery(id: string, state: DeliveryState, dismissAfterSeconds?: number): Promise;
isSupported(): boolean; // 如果iOS版本低于16.2、Android版本低于16,或者是在Web环境中,这个方法的返回值为false
areActivitiesEnabled(): boolean; // 如果用户已经在设置中关闭了这些功能,这个方法的返回值为false
最后这两项被故意设置成了独立的。即使用户在设置中关闭了实时活动功能,设备仍然可以支持这种功能,而只有`areActivitiesEnabled()`方法能够检测到这一情况。在这种情况下,`Activity.request`方法会抛出异常,因此`startDelivery`方法也会抛出异常。
`dismissAfterSeconds`这个参数实际上对应于ActivityKit的“关闭策略”。根据苹果官方文档中的默认设置,“系统会在实时活动在锁屏界面结束后的四小时内继续保留该活动的记录”。如果传入一个较小的数值,这个时间窗口就会缩短。
这个项目使用了Expo的“持续原生代码生成”功能,这意味着`ios`和`android`文件夹是由`prebuild`命令生成的,并不会被提交到版本控制系统中。因此,所有的原生代码都存储在模块文件中或配置插件中,这样在执行`prebuild –clean`命令时,这些代码也不会被删除。出于同样的原因,小部件扩展功能也是通过`@bacons/apple-targets`这个工具生成的。
还有一个结构上的细节需要注意:该模块是作为独立的CocoaPod进行编译的,而不是被合并到应用程序的目标代码中。
iOS合约的工作原理
ActivityKit中的活动被分成了两部分。`ActivityAttributes`用于存储静态数据,这些数据一旦设置完毕就不会再发生变化;而其嵌套的`ContentState`则用于存储动态数据,每次更新时,这部分数据都会被全部替换。
大多数教程都会直接介绍这一规则而不解释其原因,但实际上了解为什么平台要这样设计是非常重要的,因为这个原因能帮助我们明确理解某个字段应该属于哪一部分数据。
为什么要进行这种分割
这种分割并不是某种编程规范,而是基于物理层面的限制来决定的。
当我们调用`Activity.request(...)`方法时,实际上是在向ActivityKit提供两样信息:属性数据和初始的内容状态。系统会保存这些属性数据,而不会接受新的副本;从那一刻起,我们能够发送的只有新的`ContentState`数据而已。
这一点可以从ActivityKit的推送消息格式中看出来。以下是我使用的APNs客户端实际传输的数据结构:
aps: {
// APNs会丢弃早于最新时间戳的数据。
timestamp: Math.floor(Date.now() / 1000),
event, // "update"或"end"
'content-state': contentState, // 这是唯一包含数据的字段
}
不存在 `attributes` 这个键。它本来就不应该存在。负载数据中的任何内容都无法到达那部分静态数据区域。
所以,“这个字段应该是静态的还是动态的?”这个问题实际上是在问:“我的服务器是否会有需要修改这个值的情况?”如果答案是肯定的,那么这个字段就必须被存储在ContentState中,因为只有这个字段才能通过推送通知来更新。
整个设计思路就是这样:ActivityAttributes代表活动的身份信息,这些信息在活动创建之初就被确定下来;而ContentState则代表着活动当前的实时状态,每次更新时都会被整体替换,而不是仅仅进行部分修改。如果发送的ContentState中缺少某个字段,那就意味着你并没有正确地解析这些数据。Codable协议使得系统能够在不同的进程之间序列化这些状态数据,而Hashable协议则能让系统判断接收到的状态数据是否与当前屏幕上显示的状态有所不同。
我输掉的那场“赌注”
这是我最初设计的代码,看起来挺合理的:
// 之前的设计:courierName是静态字段。这样的设计可以正常编译、运行,
// 但一旦调度系统在配送过程中重新分配骑手,问题就出现了。
struct DeliveryAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var status: String
var progress: Double
var eta: Date
var stopsRemaining: Int
}
var orderId: String
var courierName: String // 这里其实是一个“赌注”:一次配送应该只使用一个骑手。
}
将某个字段设置为静态字段,就意味着在活动的整个生命周期内,这个字段的值永远不会发生变化。我当初假设一次配送只需要使用一个骑手,但后来我发现调度系统确实可以在配送过程中重新分配骑手,因此我的这个假设被打破了。
因为courierName是静态字段,所以根本没有办法反映这种变化。无论是通过activity.update()方法,还是通过推送通知,都无法实现这一目的。唯一的解决办法就是结束当前活动并重新开始一个新的活动,但这会导致屏幕上显示的信息发生变化——在配送过程中,卡片上的信息会突然变成另一张不同的卡片。这根本不是解决问题的办法,反而会让问题更加复杂。
因此,我最终改变了这个字段的设计:
// 现在的设计:只有orderId是真正不可变的字段。调度系统可以修改的
// 所有内容都会影响到ContentState,因为ContentState才是APNs推送通知能够携带的唯一数据。
struct DeliveryAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
/// 人类可读的状态信息,例如“已取件”、“还有2个站点”
var status: String
/// 整体配送进度,范围为0.0到1.0
var progress: Double
/// 预计到达时间
var eta: Date
/// 骑手在到达用户之前还需要经过的站点数量
var stopsRemaining: Int
/// 骑手的显示名称,这个字段是动态的,因为骑手可以在配送过程中被重新分配
var courierName: String
/// 在骑手被重新分配后,这个字段会变为true,从而影响系统的处理逻辑
var riderReassigned: Bool
}
/// 卡片上显示的订单编号,这是唯一真正不可变的信息
var orderId: String
}
修改一个字段竟然涉及到十二个文件,因为每当有代码层需要对这些数据进行编码或解码时,相关的契约规则都会被重复应用。
9 DeliveryNotifier.kt Android通知构建工具
3 DroptrackLive.types.ts TypeScript契约文件
3 DroptrackLiveModule.swift Swift桥接文件
3 App.tsx React状态管理及推送处理逻辑
2 DeliveryLiveActivity.swift widget的SwiftUI实现代码
2 DeliveryAttributes.swift 该模块的相关配置文件
2 DeliveryAttributes.swift widget的配置文件(重复了一次)
2 DroptrackLiveModule.kt Kotlin桥接文件
1 DroptrackFcmService.kt, dispatch-server.mjs, push-update.mjs,
apns.test.mjs, delivery.ts
这里的关键在于这种不对称性:一个从不被修改的动态字段,在每次推送时只会消耗几字节的空间;而一个需要被修改的静态字段,则会占用大量的系统资源。如果有疑问,最好将这个字段放在ContentState中。最终,只有orderId被归类为静态字段。
“相同副本的陷阱”
Widget扩展部分是一个独立的二进制文件,它由系统启动执行,且不会与你的应用程序代码合并。你的应用程序会编译模块中的DeliveryAttributes.swift文件,而Widget则会编译自己对应的配置文件。两次编译会产生两个独立的文件,它们只是名称相同而已。
在运行时,ActivityKit需要将这两个文件连接起来。你的应用程序会请求系统“启动类型为DeliveryAttributes的Activity”,然后系统会询问Widget是否拥有一个名为该类型的WidgetConfiguration对象,其ContentState字段能够解码这些数据。这种匹配是基于类型名称及其Codable结构的,由系统后台进程在运行时跨进程边界进行判断。
现在,将其中一个副本中的courierName改成riderName,然后忽略另一个副本。应用程序和Widget都会正常编译通过,内部逻辑也是一致的。Activity.request()方法会成功执行并返回一个ID。但当系统将编码后的数据传递给Widget时,Widget的解码器会查找riderName,却找不到它,从而导致另一个进程中的后台程序出现异常,而你的断点设置和日志记录在这种情况下是无法起作用的。
结果就是:相关界面根本不会显示出来,也没有任何崩溃提示或控制台错误信息,而Activity.activities列表中仍然会显示该Activity正在运行。你可能会花费一小时时间去调试SwiftUI布局,但实际上问题并不出在布局代码上。
由此可以得出两个结论:首先,共享源文件并不能解决这个问题,因为该模块是作为独立的CocoaPod进行编译的,并不会被合并到应用程序中;其次,由于编译器本身无法检测到这种错误,因此必须通过外部手段来进行检查:diff modules/droptrack-live/ios/DeliveryAttributes.swift \
targets/widgets/DeliveryAttributes.swift
如果这个命令执行后输出了任何内容,那就说明你的Live Activity功能已经出现了故障。在预提交阶段添加这样的检查代码几乎不会增加任何开发成本,但这对保护项目的稳定性来说却至关重要。
如何在SwiftUI中构建这个小部件
这个小部件定义了一个Widget,它有两种显示形式:锁屏界面和动态岛。
在阅读代码之前,请记住一点:这些闭包实际上是从状态到视图的纯函数。小部件扩展程序本身并不是一个正在运行的程序。系统会启动这个进程,使用当前的ContentState来调用你的闭包,然后保留渲染后的结果,最后再终止这个进程。
当有新的状态信息传入时,系统会再次执行这些闭包。这里没有@State指令,也没有定时器、网络请求或onAppear>方法在起作用。正因为如此,小部件扩展程序才无法访问网络:因为根本没有人会在那里发起请求。
关于代码前面的类型声明,有几点需要注意:Widget是WidgetKit协议,而不是SwiftUI的View;而body>返回的是some WidgetConfiguration,而不是some View。你并不是在描述像素信息,而是在说明这个小部件的具体类型。
第3行中的for:参数,正是前文提到的绑定点:在这个位置,应用程序的DeliveryAttributes>与小部件的相关配置会根据名称和Codable>格式进行匹配。因此,如果匹配失败,问题就会在这里显现出来。
struct DeliveryLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: DeliveryAttributes.self) { context in
// 第一个闭包:用于锁屏界面和横幅显示
DeliveryCardView(context: context)
.activityBackgroundTint(Color(red: 0.07, green: 0.07, blue: 0.12))
.activitySystemActionForegroundColor(.white)
} dynamicIsland: { context in // 第二个闭包:用于动态岛显示
DynamicIsland {
// 长按展开时显示的内容:四个可自定义的区块
DynamicIslandExpandedRegion(.leading) {
Image(systemName: "bicycle").foregroundStyle(brandOrange)
}
DynamicIslandExpandedRegion(.trailing) {
Text(context.state.eta, style: .time).font(.callout.bold())
}
DynamicIslandExpandedRegion(.center) {
Text(context.state.status).font(.callout.weight(.semibold)).lineLimit(1)
}
DynamicIslandExpandedRegion(.bottom) {
VStack(spacing: 4) {
SegmentedProgressBar.progress: context.state.progress)
HStack {
CourierLabel(state: context.state, compact: true)
Spacer()
Text(stopsLabel(context.state.stopsRemaining))
}
.font(.caption2).foregroundStyle(.secondary)
}
}
} compactLeading: { // 默认显示区域,位于动态岛左侧
Image(systemName: "bicycle").foregroundStyle(brandOrange)
} compactTrailing: { // 位于右侧的内容
Text“(Int(context.state.progress * 100))%”).font(.caption2.bold())
} minimal: { // 当其他应用程序共享动态岛时显示的内容
Image(systemName: "bicycle").foregroundStyle(brandOrange)
}
.keylineTint(brandOrange)
}
}
}
这是一个状态对象,以四种不同的方式呈现出来,其中一些实现方式具有特定的功能:
-
这两个闭包: `ActivityConfiguration` 接受一个内容相关的闭包和一个名为 `dynamicIsland` 的闭包。这种将代码写在一起并加上标签的形式实际上符合 Swift 中多尾闭包的语法,并非两条独立的语句。第一个闭包用于锁屏界面和横幅卡片的设计,而第二个闭包则用于所有动态岛界面的实现。
-
context: 每个闭包都会接收 `context.state`——这个值代表会随着数据更新而变化的动态状态 `ContentState`——以及 `context.attributes`,这是一个静态值,仅会被读取一次,用于确定卡片上显示的顺序编号。 -
这两个 `.activity…` 修饰符: 这些是 ActivityKit 特有的属性,并非 SwiftUI 的通用规则。由于你无法绘制系统自带的滑动结束边框,因此你只能对其进行颜色调整。
-
eta, style: .time用于显示本地化的时间格式,例如 “下午5:42”。这个格式会根据用户设置的12小时制或24小时制来显示时间,并非倒计时功能。如果需要实现倒计时,应该使用 `style: .timer`,因为系统会自动处理相关的动画效果,而这样完全不需要与服务器进行交互。 -
lineLimit(1)并不是为了美观而设置的。中间的显示区域宽度只有几十像素,如果状态字符串的长度超过这个范围,就会破坏布局的整齐性。而将条形元素放在 `.bottom` 区域,恰恰是因为那里是唯一宽度足够的区域。 -
.font和.foregroundStyle在 `` 中会被应用到所有的子元素上。不过,如果某个子元素自己设置了这些属性,那么它的设置会优先于继承来的值。例如,`CourierLabel` 就可以通过覆盖这些属性来改变自身的颜色。 -
Int(progress * 100)这个表达式会截断数值而不是四舍五入,因此 0.999 会被显示为 “99%”。而脚本中定义的步骤值通常是 1.0,所以最终显示结果会是 100%,但如果使用服务器计算得出的进度值,就应该使用 `.rounded()` 方法来进行四舍五入。 -
人们常常会忽略 `
` 这个属性的作用。当其他应用程序也在运行时,你的动态岛界面会被缩放到一个圆形图标。如果忽略了这个属性,那么当有计时器在运行的时候,你的应用界面就会看起来不正常。 keylineTint用于设置系统在更新界面时会在整个动态岛周围绘制的发光效果。
分段进度条
分段进度条实际上是由多个圆形矩形组成的 `
private struct SegmentedProgressBar: View {
let progress: Double
var segments: Int = 7 // 每个步骤对应一个圆形元素
// ContentState 中存储的是 0 到 1 之间的分数,而不是步数索引,因此需要反向计算出实际需要显示的分段数量。
// 由于这个值是数字形式,所以组件根本不需要知道这七个步骤的具体名称是什么。
private var filled: Int {
// 首先对数值进行限制:如果服务器发送的值是 1.4,那么就不能让 7 个分段中的 10 个都被点亮。
let clamped = min(max(progress, 0), 1)
// 使用 `.rounded(.up)` 方法来确保即使进度只有 0.05,也会对应的圆形元素被点亮。
// progress * 7 = 0.35,向上取整后结果为 1,即有 1 个分段会被点亮。
// 外层的 `min()` 方法可以确保最终结果一定是 1.0,从而避免出现第 8 个分段被点亮的情况。
return min(segments, Int((clamped * Double(segments)).rounded(.up)))
}
var body: some View {
HStack(spacing: 5) {
// 使用 `ForEach` 遍历一个固定的范围,因此可以使用 `id: \.self` 来确保索引是唯一的。
ForEach(0..<segments, id: \.self) { i in
RoundedRectangle(cornerRadius: 3, style: .continuous)
// 这是整个组件中唯一需要根据状态来改变显示效果的代码部分:决定某个分段是否应该被点亮,或者显示什么颜色。
.fill(i < filled ? brandOrange : trackGray)
// 高度是固定的,宽度由 `HStack` 自动分配,因此同一个组件可以同时适应宽大的锁屏界面和狭窄的动态岛界面。
.frame(height: 7)
}
}
}
}
当进度值分别为0.05、0.15、0.35、0.55、0.7、0.85、0.95和1.0时,将progress * 7的结果向上取整后,就可以依次填充从1到7这七个进度段。无论是在锁屏界面还是展开后的动态信息栏中,显示效果都是一样的,因此这个组件在各种界面上的呈现方式都是一致的。


重新分配处理机制
重新分配实际上是一种原地更新操作:活动编号保持不变,步骤内容也不变,只是courierName会发生变化,并且riderReassigned会被设置为true。此时,对应快递员的图标和信息也会发生相应的变化。
这种重新分配机制在锁屏界面和动态信息栏中都会被使用,因此无论用户身处哪个界面,看到的显示内容都是一样的。
private struct CourierLabel: View {
let state: DeliveryAttributes.ContentState
let compact: Bool // 在动态信息栏中,当水平空间有限时,会采用这种紧凑显示方式
var body: some View {
// 这两个分支的显示内容完全由ContentState状态决定,并没有本地状态存储。
if state.riderReassigned {
Label(
// 在动态信息栏中,无法显示“新快递员 · Tunde”这样的完整名称,因此会省略前缀,
// 通过更换图标和使用橙色来表达这一变化。
compact ? state.courierName : "新快递员 · \(state.courierName)",
systemImage: "arrow.triangle.2.circlepath"
)
// 子组件的显式样式会覆盖父组件中设置的 secondary 样式,
// 这也是为什么更换后的快递员信息会在界面中格外显眼的原因。
.foregroundStyle(brandOrange).bold()
} else {
// 当状态没有发生变化时,就使用父组件的标题字体和次要颜色进行显示。
Label(state.courierName, systemImage: "bicycle")
}
}
}
每当完成一个步骤后,相应的徽章就会消失。这就是这个功能的全部作用,也是为什么courierName不能保持为静态文本的原因。

如何将ActivityKit与JavaScript连接起来
Expo提供的Record类型能够确保类型安全地进行数据转换。在你的函数体开始执行之前,Expo会先验证这些JavaScript对象是否符合规定的字段格式。
“Record”是Expo所使用的一种类型化桥接结构。在函数体执行之前,所有的@Field字段都会从JS对象中被解析出来;因此,如果某个键缺失或类型错误,错误会在编译阶段就被立即发现,而不会在Swift代码运行过程中才出现问题。当JS代码没有为某个字段指定值时,该字段就会使用默认值。以下是DeliveryStateRecord的结构定义:
struct DeliveryStateRecord: Record {
@Field var status: String = ""
@Field var progress: Double = 0
@Field var etaEpochMillis: Double = 0 // 使用Double类型,而不是Int:因为JS中的数值是f64格式的,
// 而以毫秒为单位表示的时间长度会超出Int32的范围。
@Field var stopsRemaining: Int = 0
@Field var courierName: String = ""
@Field var riderReassigned: Bool = false
// “桥接类型”与ActivityKit中的类型故意被设计成不同的结构。只有在这个函数中,这两种不同的类型系统才会发生交互。
@available(iOS 16.2, *)
func toContentState() -> DeliveryAttributes.ContentState {
DeliveryAttributes.ContentState(
status: status,
progress: min(max(progress, 0), 1), // 确保进度值在0到1的范围内
eta: Date(timeIntervalSince1970: etaEpochMillis / 1000), // 将毫秒转换为秒
stopsRemaining: stopsRemaining,
courierName: courierName,
riderReassigned: riderReassigned
)
}
}
下面是该模块的实现代码:
AsyncFunction("startDelivery") { (info: DeliveryInfoRecord, state: DeliveryStateRecord) -> String in
// 运行时检查:由于该模块是为较旧的系统版本编写的,因此需要确保调用的是现有的框架功能。
guard #available(iOS 16.2, *) else { throw LiveActivityUnsupportedException() }
let activity = try Activity.request(
attributes: DeliveryAttributes(orderId: info.orderId), // 静态参数,只需设置一次
content: ActivityContent(state: state.toContentState(), staleDate: nil), // 动态参数
pushType: .token // 请求APNs发送令牌
)
return activity.id // 这是JS代码能够获取的唯一标识符,而且在页面重新加载后,这个标识符也会被重置。
}
AsyncFunction("updateDelivery") { (activityId: String, state: DeliveryStateRecord) in
let activity = try Self.findActivity(id: activityId)
// .update()方法会替换整个ContentState对象,目前没有提供部分更新的接口。
await activity.update(ActivityContent(state: state.toContentState(), staleDate: nil))
}
关于这些参数的一些说明:之所以使用AsyncFunction而不是Function,是因为这种方式可以让JS代码返回一个Promise对象;而Expo则会根据参数的类型自动推断出函数的其余签名信息。
staleDate: nil表示该卡片永远不会被标记为“过期”。如果传入一个日期值,系统会在该日期之后更改活动的状态,这样你的小程序就可以通过context.isStale方法来判断卡片是否已过期,并据此显示相应的界面效果,但系统本身不会自动将卡片颜色变灰。
pushType: .token这一参数用于请求APNs为每个活动生成一个专用令牌;如果传递nil,则会得到一个完全本地的活动对象,而任何服务器都无法更新这个对象。
findActivity方法需要注意:每次调用时都会根据活动ID重新查找相应的活动信息,而不是从缓存中获取数据。这是因为活动相关的处理逻辑实际上是运行在ActivityKit框架内部,而非用户的应用程序进程中。这一区别非常重要,下一节将会进一步说明。
@available(iOS 16.2, *)
private static func findActivity(id: String) throws -> Activity {
// `Activity.activities`是一个由系统管理的实时列表。即使应用程序重新启动、JavaScript代码重新加载,甚至进程被终止,这个列表依然会保持有效。如果将这个列表的引用保存在Swift属性中,那么这些变化就会导致数据丢失,因此每次都需要重新查询。
guard let activity = Activity.activities.first(where: { $0.id == id }) else {
// 这种情况通常发生在用户将相关卡片滑出屏幕,或者该活动已经过了有效期限之后。
throw ActivityNotFoundException(id)
}
return activity
}
四个让我每晚都耗费大量时间的iOS使用陷阱
每一个这样的问题都让我浪费了一个晚上时间来解决问题——因为起初我以为这是代码中的错误,但实际上这只是平台的某些特殊行为所致。由于这些问题的表现形式相似,所以我都是用同样的方式来描述它们:首先说明你遇到的症状,然后解释为什么会出现这种问题,最后给出解决方法。
陷阱1:紧凑型动态岛显示异常
当你构建了紧凑型动态岛功能并运行应用程序时,却发现它根本不会显示出来。
这是因为当你的应用程序处于前台时,紧凑型动态岛的功能会被隐藏起来。虽然这是系统的设计机制,但并没有任何提示信息会告知用户这一点,所以我花了二十分钟时间才意识到是我的布局设置出了问题。
要解决这个问题,需要在判断动态岛功能是否正常工作之前,先锁定设备或切换到其他应用程序。代码中并没有需要修改的地方。
陷阱2:重新构建后旧活动无法显示
当你更改了小部件的相关设置并重新构建应用程序后,原本正在运行的活动就会变得空白,且没有任何错误提示出现。
这种情况发生的原因是:某个活动是与特定的构建版本相关联的。一旦你重新构建了小部件扩展程序,那么正在运行的活动所使用的代码就不再与设备上的实际代码相匹配了。
要解决这个问题,每次对小部件扩展程序进行修改后,都需要结束当前正在运行的活动并重新启动一个新的活动。需要注意的是,系统并不会发出任何警告来提醒你这一点。
陷阱3:重新安装可能导致系统后台进程出现故障
simctl install命令执行后,第一个活动就会无法正常显示。虽然`Activity.activities`列表表明这个活动是处于活跃状态的,但屏幕上仍然什么都没有。
这是因为实时活动的显示是由一个名为chronod的系统后台进程来处理的。重新安装应用程序可能会导致这个进程出现故障——在我的案例中,这个问题表现为出现widgetDescriptorNotFound错误,并且有内部错误日志被记录下来。无论你如何尝试结束并重启相关活动,都无法解决这个问题。
这是守护进程中的故障,并非你的应用程序出现了问题,因此常规的检查方法会让你产生误解。使用pluginkit -m命令查看扩展程序时,会显示它存在且运行正常,但实际上背后的守护进程已经陷入了故障。
要解决这个问题,请使用simctl shutdown && boot命令重启模拟器,然后重新启动一个新的应用程序活动。
发现第4个问题:点击卡片会打开一个“忘记一切”的应用程序
这是一个非常隐蔽的问题,但对用户来说最为明显,也正是接下来两个代码示例所要说明的原因。
当用户点击卡片时,你的应用程序会被启动,但会进入一个空白界面,系统中会显示“没有数据被跟踪”,所有功能都会被禁用,而卡片仍然会显示在锁屏界面上,并且还会继续更新信息。
出现这种问题的原因是,点击卡片会导致应用程序重新启动,这意味着JavaScript代码会从初始状态开始执行。之前存储在React状态中的活动ID已经丢失了,因为之前的进程已经结束了。
实际上,这个应用程序本身并没有问题:Activity.activities列表中仍然能找到它,而且通过APNs发送推送通知时,系统也会返回200码(如果应用程序已关闭,会返回410码,这可以用来判断应用程序是否还在运行)。只是应用程序忘记了重新连接所需使用的ID而已。
为了解决这个问题,不要将内存中的ID视为唯一有效的标识符,而应该向系统询问当前正在运行的应用程序是什么。需要让JavaScript能够访问这些活动的信息。
AsyncFunction("getRunningActivities") { () => {
guard #available(iOS 16.2, *) else {
return [];
}
// 这是系统提供的活动列表,即使是在全新的进程中也会被生成。关键就在于这里。
return Activity.activities.map(activity => {
self.observePushToken(of: activity); // 重新订阅推送通知
let state = activity.content.state; // 获取实时状态信息
return [
"activityId": activity.id, // 这个ID是JavaScript之前丢失的
"orderId": activity.attributes.orderId, // 静态信息
"status": state.status,
"progress": state.progress,
"courierName": state.courierName,
"riderReassigned": state.riderReassigned,
];
});
}
其中observePushToken这一行代码往往被人们忽略。由于for await循环会在上一个进程结束时终止,因此如果不重新订阅推送通知,卡片就会恢复正常显示。但是,在进程轮换后,服务器就无法再次获取到这个token了,从而导致推送通知无法正常送达。
因此,需要在组件挂载时以及每次回到前台时重新获取这些信息。因为当应用程序在后台运行时,即使收到了推送通知,也不会触发JavaScript代码的执行。React Native提供的AppState接口可以帮助你实现这一点。
useEffect(() => {
const sync = () => {
void DroptrackLive.getRunningActivities().then((running) => {
const activity = running[0];
if (!activity) return; // 如果没有活动在运行,就保持空白状态
setActivityId(activity.activityId); // 这一行代码会重新激活整个用户界面
setRider({ name: activity.courierName, justReassigned: activity.riderReassigned });
// 卡片上显示的状态字符串并不代表步骤索引,因此需要检查是否为-1。
const index = STEPS.findIndex((s) => s.status === activity.status);
if (index >= 0) setStepIndex(index);
});
};
sync(); // 情况1:应用程序重新启动
const sub = AppState.addEventListener("change", (s) => {
if (s === "active") sync(); // 情况2:从后台恢复运行
});
return () => { sub.remove(); };
}, []);
整个跟踪机制都是基于activityId来运行的,因此恢复这个ID才能让应用程序重新启动。如果遗漏了这一行代码,虽然应用在锁屏界面上仍然显示正常,但实际上应用程序已经处于关闭状态。
这两种调用方式分别对应了状态失效的两种情况:sync()方法用于处理应用程序初次启动时的状态初始化,而AppState监听器则用于处理应用程序恢复运行时的状态更新。因为当应用程序在后台运行时,可能不会执行任何JavaScript代码,从而导致锁屏界面上的信息未能及时更新;而通过设置空的依赖数组,可以确保这种状态更新机制在应用程序的整个生命周期内只被初始化一次。
最后这一点其实可以归结为我在前面就应该提到的那个要点:实时活动是由系统管理的,而不是由你的应用程序进程管理的。API只会提供一个活动ID给你,人们很容易误以为这个ID是可以被你自己永久保留的。但实际上并非如此——当用户点击锁屏界面上的实时活动卡片来打开你的应用时,内存中存储的那个活动ID就已经不存在了。
如何通过APNs从服务器端控制iOS应用程序
这样做的好处在于:即使应用程序被完全强制关闭,也可以及时更新锁屏界面上的信息。API提供的接口非常简洁,只需要修改一个参数即可完成操作。苹果公司在这里详细说明了如何使用ActivityKit的推送通知来启动和更新实时活动。
let activity = try Activity.request(
attributes: attributes,
content: ActivityContent(state: state.toContentState(), staleDate: nil),
pushType: .token // 这个参数之前被设置为`nil`,而实际上这个参数才是实现该功能的关键。
)
Task {
for await tokenData in activity.pushTokenUpdates {
let token = tokenData.map { String(format: "%02x", $0) }.joined() // 将原始数据转换为十六进制字符串
NSLog("[DropTrack] 接收到的推送令牌:%@: %@", activity.id, token) // 使用NSLog进行日志记录,以便devicectl能够接收到这些信息
self.sendEvent("onPushTokenReceived", ["activityId": activity.id, "token": token])
}
}
pushTokenUpdates实际上是一个AsyncSequence对象,而不是一个一次性获取数据的接口,因此这里使用了for await循环来遍历所有收到的推送令牌数据,而不会只进行一次读取操作。在request()方法调用之后立即尝试读取activity.pushToken>会返回`nil`,因为此时推送令牌还没有从APNs服务器发送过来;此外,这个令牌的值也可能会在传输过程中发生变化。
接收到的推送令牌是以原始Data格式存在的,因此在使用之前需要先将其转换为十六进制字符串。这里使用的是NSLog来进行日志记录,而不是print,因为独立的发布版本中并没有Metro环境来接收console.log的输出;而devicectl --console>命令输出的日志也是通过NSLog来显示的。另外,由于每个活动都有对应的推送令牌,因此在将令牌传递给JavaScript代码时,会根据活动ID来进行区分,而不是将其全局存储起来。
之后所做的所有操作都属于细节处理部分,其中有两处环节如果出现故障,也不会有任何错误提示信息显示出来。
pushType: .token 这个选项在没有 aps-environment 这一权限设置的情况下是无效的。Activity.request 方法仍然可以正常使用,本地更新功能也不会受到影响,只不过此时系统不会生成任何令牌信息,也不会有任何日志记录。因此,请在 app.json 文件中添加相应的权限设置:
"ios": {
"entitlements": {
"aps-environment": "development",
"com.apple.security.application-groups": ["group.com.fasarticle.droptrack"]
}
}
关于这些值的几点说明:由于app.json文件中不能包含注释,因此需要特别注意。aps-environment设置为development意味着你的令牌仅在api.sandbox.push.apple.com这个环境中有效。用于正式发布的版本应该将aps-environment设置为production,如果将这两种设置混合使用,就会导致出现“BadDeviceToken”错误。应用程序组是应用与插件之间共享的标识符,这两个组件必须使用相同的组ID;否则,相关操作将会失败且不会返回任何结果。
接下来需要确认这些权限配置在签名过程中是否没有被破坏,因为配置文件中的权限信息与最终生成的二进制文件中的权限信息必须是完全一致的:
# 用于读取被嵌入到签名后的.app文件中的权限信息。
# 如果这里没有指定aps-environment,那么无论app.json中如何设置,令牌获取操作都不会成功。
codesign -d --entitlements - --xml Build/Products/Release-iphoneos/DropTrack.app
推送令牌是针对特定活动而非特定设备生成的。它在request()方法执行后被异步发送过来,系统也可能会在传输过程中更换该令牌。你需要通过处理pushTokenUpdates这些异步事件来获取最新的令牌信息。在request()方法执行后立即读取activity.pushToken>的值,其结果通常会是nil;而每次调用startDelivery>方法时都会生成一个新的令牌。我通过同时启动两个活动并比较它们生成的令牌的十六进制值来验证了这一点。
如何从零开始编写APNs客户端
你不需要使用任何第三方库,只需要利用Node.js的两个内置模块以及大约六十行代码就可以完成这个任务。
发送一条推送通知总共分为三个步骤:首先生成一个令牌,以证明请求确实来自你的应用程序;其次与Apple建立连接,并设置正确的头部信息;最后发送你希望显示在用户设备上的数据。本节的后续内容会详细讲解每个步骤,并说明那些可能会导致默默失败的情况。
首先需要明确一点:APNs仅支持HTTP/2协议,因此不能使用fetch()方法来与之通信。你需要使用Node.js的node:http2模块,这就是为什么这段代码是手动编写的,而不是通过fetch调用来实现的。
步骤1:生成一个用于证明身份的令牌
在你证明自己拥有这个应用程序之前,APNs是不会接受任何推送请求的。你可以使用ES256格式的JSON Web Token来证明自己的身份。这种令牌需要用从Apple下载到的.p8密钥进行签名,具体操作方法可以参考如何建立基于令牌的APNs连接文档。JSON Web Token实际上是由三个用点连接的Base64编码字符串组成的:头部信息、claims对象以及用于验证这些数据的签名。
import { createPrivateKey, sign } from "node:crypto";
const b64url = (buf) => Buffer.from(buf).toString("base64url"); // JWT使用的是base64url,而不是base64
export function mintJWT({ keyPath, keyId, teamId }) {
const header = b64url(JSON.stringify({ alg: "ES256", kid: keyId })); // kid:这个10个字符长的键ID
const claims = b64url(JSON.stringify({ iss: teamId, iat: Math.floor(Date.now() / 1000) }));
const signingInput = `${header}.${claims}`; // JWT的签名部分是由头部信息和声明信息组合而成的
const signature = sign("sha256", Buffer.from(signingInput), {
key: createPrivateKey(readFileSync(keyPath, "utf8')),
dsaEncoding: "ieee-p1363", // 这一行代码非常重要。如果使用默认的DER编码方式,会导致签名失败。请注意这一点。
});
return `${signingInput}.${b64url(signature)}`;
}
那行dsaEncoding代码正是为什么我们必须手动编写这段代码、而不能直接使用互联网上找到的代码片段的原因。
Node.js默认使用DER格式进行签名,这种格式会将签名的两部分包裹在同一个数据结构中;但JWT标准(RFC 7518)要求这两部分必须以原始形式直接拼接在一起。如果使用了错误的编码方式,APNs会返回403 InvalidProviderToken错误响应,这看起来就像是你的密钥有问题,但实际上你的密钥是完全正常的,只是因为编码格式不正确才导致了这个问题。
还有两个小细节需要注意:kid是那个10个字符长的键ID(它存在于.p8文件中),这个键ID告诉APNs应该使用你注册的哪一把密钥来验证签名;而iat时间戳根据苹果公司的规定,不能超过1小时的前置时间,同时苹果也建议每20分钟最多更新一次时间戳,因此最好是将这个令牌缓存起来,而不是每次发送推送消息时都重新生成一个新的令牌。
步骤2:建立连接并发送推送消息
拿到令牌之后,就需要与APNs服务器建立HTTP/2会话,并发送一个POST请求。请求头的信息必须准确无误,其中人们经常犯错的地方是topic字段的填写方式:应该使用你的bundle id加上.push-type.liveactivity后缀,而不能只使用bundle id本身。
// 这些伪请求头是HTTP/2用来编码请求信息的格式
// `client`在这里指的是与APNs服务器建立的HTTP/2会话。
const req = client.request({
":method": "POST",
// 路径中包含的设备令牌实际上是针对特定活动的令牌,并不是来自UNUserNotificationCenter的设备令牌,
// 这两种令牌是完全不同的。
":path": `/3/device/${token}`,
authorization: `bearer ${jwt}`, // APNs期望使用小写的"bearer"作为授权字段
// 对于实时活动推送,需要在topic后面加上后缀。如果只发送不带后缀的bundle id,APNs会直接拒绝请求。
"apns-topic": `${bundleId}.push-type.liveactivity`,
"apns-push-type": "liveactivity", // 这个字段的值必须与topic后面的后缀相匹配
"apns-priority": "10", // 10表示立即发送,5表示择机发送
"apns-expiration": "0", // 0表示不重试失败的推送请求
});
req.end(JSON.stringify({
aps: {
// 时间戳字段的作用是确保推送消息的顺序正确。APNs会丢弃时间戳早于之前已发送的消息,
// 因此如果顺序混乱,重新尝试也不会恢复正确的顺序。
timestamp: Math.floor(Date.now() / 1000),
event: "update", // "update"用于更新信息,"end"用于完成推送操作
// 这些数据会直接被解码并放入小部件的Codable ContentState中。字段名和类型必须与这个结构完全匹配,
// 注意避免出现格式错误导致的问题。
"content-state": contentState,
},
}));
该数据体的体积被刻意设置得较小。timestamp表示以秒为单位的排序信息:APNs会丢弃所有早于最近一次发送的推送消息,因此延迟重试也无法恢复之前的状态。event的值为"update"时表示需要更新数据,值为"end"则表示活动已经结束。这里没有"start"这个选项,因为“通过推送来启动某个操作”这一功能需要iOS 17.2版本才能使用。其他所有信息都存储在content-state中,插件会直接解码这些数据,而第二个隐藏的错误也就隐藏在这个字段里。
如何在没有手机的情况下测试认证功能
这个技巧为我节省了大量时间,所以在真正使用设备进行测试之前,先尝试这个方法吧。使用一个故意伪造的设备令牌向沙箱环境发送推送消息,然后查看APNs返回的响应代码。
-
如果返回
400 BadDeviceToken,说明你的密钥、密钥ID、团队ID以及JWT编码都是正确的,只是APNs在尝试查找该令牌时没有找到它。这意味着你的认证功能是正常的。 -
如果返回
403 InvalidProviderToken,说明令牌本身存在问题,因此问题出在你的密钥或签名机制上,而不是设备本身。
通过这个简单的请求,我们就可以在没有任何手机的情况下判断是“我的认证功能出了问题”还是“我的令牌有问题”。在这个项目中,我在第一次尝试时就收到了400响应码,这让我确定认证功能是正常的,于是我开始在其他地方查找真正的故障原因。而这正是我所需要的确认信息。
那个毫无实际意义的eta值
这是项目中最具教育意义的一个错误案例,它的出现是因为某段代码重复使用了同一个辅助函数。
同样的数据在传递给插件的过程中会经过两条不同的路径,而这两条路径对数据的格式要求不同。使用原生模块传递数据时,数据中会包含etaEpochMillis(以Unix时间毫秒为单位),Swift会在处理这些数据时将其转换为Date类型;而通过推送机制传递数据时,则完全不经过Swift的处理,JSON格式的content-state会直接被插件的Codable结构体解码,而这个结构体期望接收的字段名称就是eta,该字段表示从2001年以来的时间间隔(以秒为单位),它完全不知道etaEpochMillis这个概念。
因此,如果你将原生模块生成的数据直接用作推送消息的内容,APNs会返回200响应码,iOS系统会自动忽略这些数据,因为它们无法被正确解码,而且也不会有任何日志记录。解决办法是在数据转换的环节进行重新处理,只有在生成推送消息时才将数据转换为插件能够识别的格式。
const APPLE_EPOCH_OFFSET = 978_307_200; // 1970年到2001年之间的时间间隔(秒)
// 这是插件解码时所需的数据格式,而不是原生模块处理时的数据格式。
function toContentState({ etaEpochMillis, ...rest }) {
return {
...rest,
eta: Math.floor(etaEpochMillis / 1000) - APPLE_EPOCH_OFFSET, // 将毫秒转换为秒,然后减去起始时间
};
}
有两个细节决定了这一机制能够正常工作。Foundation.Date的参考点是2001年1月1日,而非Unix纪元;而Swift的Codable>协议会将Date对象编码为自该日期起经过的秒数,因此才需要减去相应的偏移量。另外,从rest中提取etaEpochMillis>时如果处理不当,就会导致出现错误:如果保留这个字段,Widget的Codable>初始化过程会遇到一个未知字段,从而无法成功解码数据,进而导致更新操作在200次尝试后失效。
这一规律不仅适用于eta字段。从APNs接收到的200代码仅表示苹果系统已经收到了你的推送数据,并不意味着卡片的状态发生了变化。任何无法成功解码的数据都会被忽略,且不会在任何界面上显示错误提示,因此每次都应在锁屏界面上确认状态,而不要仅仅依赖状态码来判断。
还有两个需要注意的环境问题:开发版本构建时必须使用api.sandbox.push.apple.com作为推送服务器,因为生产环境会用400 BadDeviceToken代码回应沙箱令牌请求,而这种错误响应实际上与格式错误的令牌完全相同,会导致调试工作出现方向性错误。如果你频繁进行推送操作,还需要在Info.plist中添加NSSupportsLiveActivitiesFrequentUpdates选项,否则系统会限制你的推送频率。
如何在真实设备上进行测试
模拟器在处理推送请求时可能会提供错误的信息,因此最终你还是需要使用真实的手机来进行测试。有四点问题在文档中并未提及,但它们确实会影响到测试结果,下面分别说明这些问题及解决方法。
使用发布版本进行构建,而非调试版本
调试版本的应用程序会期望从本地的Metro网络下载JavaScript文件。构建脚本会将Mac的网络地址嵌入到应用程序中,因此当手机处于其他网络环境中,或者没有连接到Mac时,程序就会显示“未提供脚本URL”的错误信息。在iOS系统中,并没有类似于adb reverse的工具可以解决这个问题。
应该使用--configuration Release选项来构建应用程序,这样JavaScript文件就会直接被嵌入到应用程序中。实际上,这种测试方式更为可靠,因为只有使用发布版本才能强制关闭应用程序,从而验证真正起作用的是推送机制本身,而不是Metro网络连接。
使用devicectl读取日志,而非console.log
一旦不再使用Metro网络,console.log就无法正常输出日志信息了。如果需要从设备上获取推送令牌等信息,就必须使用应用程序真正的标准输出接口。在Swift代码中使用NSLog,再结合Mac上的devicectl>工具,才是可靠的日志获取方法。
# --console 将应用程序的标准输出/错误输出重定向到当前终端
# --terminate-existing 先终止正在运行的实例,以便能捕获启动时的日志信息
# --device 指定COREDEVICE的ID(详见下面的注意事项)
xcrun devicectl device process launch --console --terminate-existing \
--device com.fasarticle.droptrack
# → [DropTrack] 推送令牌为3095ACA0-...: 80875cb137590013a4c9...
# ^ 这个十六进制字符串就是调度服务器会提取并发送的推送令牌
了解你需要哪一种设备标识符
同一部手机拥有两种标识符,而不同的工具对于应该使用哪种标识符存在分歧。expo run:ios --device命令需要硬件UDID,而xcrun xctrace list devices命令显示的正是这个信息。而devicectl命令则需要CoreDevice标识符,xcrun devicectl list devices命令可以查询到这一信息。
如果你传入了错误的标识符,系统会返回“没有匹配的设备UDID或名称”,这看起来就像手机已经断开连接一样。但实际上手机并没有断开连接,因此请确保你复制的是正确的标识符类型,然后再检查数据线是否正常。
处理冷启动深度链接,而不仅仅是实时链接
如果你的测试驱动程序是通过深度链接来打开应用程序的,请注意devicectl ... --payload-url命令会触发应用程序的冷启动,因此这些链接实际上是通过Linking.getInitialURL()路径传递的,而不是模拟器中的simctl openurl方法所使用的url事件。那些只监听url事件的驱动程序在真实设备上将无法正常工作,因此你需要同时处理这两种情况。
在确保所有相关问题都得到解决,并且应用程序确实已经完全关闭之后,我再次通过这些深度链接发送了三条消息:状态变更、乘客重新分配以及“正在途中”等信息。这些消息都成功显示在了锁屏界面和动态岛功能中。
还有一个额外的好处:如果你将Mac设备与同一个Apple ID关联起来,Continuity功能就会将相同的实时活动信息同步到macOS的菜单栏、分隔栏等地方。只需要发送一条APNs推送消息,就能在三个不同的平台上显示相同的内容。

为什么Android没有ActivityKit
Android并没有类似于ActivityKit的框架,因此系统也没有专门用于管理这类信息的组件,更没有任何机制来确保这些信息始终保持最新状态。实际上,你所能使用的只是普通的通知功能,而你需要不断地重新发送这些通知。
关键在于通知的ID。如果你使用相同的ID重新发送通知,Android会替换原有的通知内容,从而让用户觉得这是更新信息;而如果你使用不同的ID发送通知,系统就会视为新的通知进行显示。因此,三种与应用程序生命周期相关的操作实际上都可以通过简单的通知功能来实现:
-
开始操作就是使用一个新的ID调用
notify()函数。 -
更新操作也是使用相同的ID再次调用
notify()函数。 -
结束操作则是先调用一次带有
ongoing = false参数的notify()函数,然后再延迟调用一次cancel()函数。
这就是整个机制的原理。不过需要注意的是,iOS系统之前为你处理的所有这些事情——比如保持通知内容的实时性、在重启后恢复通知信息、以及通过推送消息更新通知内容——现在都变成了你需要自己编写的代码了。
决定整个Android发展方向的“陷阱”:Android 16实际上是由两个版本组成的
这种版本划分方式对整个Android生态系统产生了重要影响,因此花三十秒了解这一点是值得的。“Android 16”实际上由两个版本组成,但它们使用相同的API编号:
| 版本 | 对应的SDK_INT值 | 新增功能 |
|---|---|---|
| Android 16(基础版本) | SDK_INT == 36 |
ProgressStyle功能:分段式进度条 |
| Android 16 QPR1 | SDK_INT == 36 |
推广通知功能的实现,包括状态栏提示信息和锁屏界面的特殊设计 |
这两个版本都使用36作为SDK_INT值,因为你的代码是通过Build.VERSION.SDK_INT来检测操作系统版本的,而这个数值是一个整数,并没有小数部分。因此,虽然是两个不同的版本,但它们的SDK_INT值却是一样的。
这种设计方式本身就容易引发问题,但只要你知道这一点,处理这些问题的方法其实并不复杂。
1. 应该使用回溯版本进行编译,而不是使用平台提供的类
setRequestPromotedOngoing、setShortCriticalText以及POST_PROMOTED_NOTIFICATIONS这些方法仅存在于36.1版本的SDK中,而React Native 0.86版本则是基于36基础版本进行编译的。如果直接在平台提供的类上使用这些方法,编译会失败,出现“找不到该方法”的错误。不过,添加一个依赖项就可以解决这个问题:
dependencies {
// NotificationCompat为36.1版本的API提供了回溯实现,因此可以使用它们
// 与36基础版本进行兼容编译。如果没有这个依赖项,
// ProgressStyle、setRequestPromotedOngoing和setShortCriticalText这些方法将无法被使用。
implementation 'androidx.core:core-ktx:1.17.0'
}
应该使用NotificationCompat相关的类进行编译,而不是平台提供的类。这些方法是在36版本上实现的,在真正的36.1设备上才能发挥作用;而在较旧的版本上,则会被忽略。因此,无论在哪个环境下构建应用,这种方式都是安全的。
2>只有当你确实需要知道具体的小版本号时,才应该读取SDK_INT_FULL
普通的SDK_INT >= 36判断方式无法区分36基础版本和36.1版本,因为它们的SDK_INT值都是36。只有当你确实需要知道自己使用的是哪个版本时,Build.VERSION.SDK_INT FULL才能提供具体的小版本号信息。Build.VERSION_CODES FULL中列出了所有版本的详细信息。由于规则1的存在,你在大多数情况下并不需要这个信息来处理推广通知相关的功能;你只需要用它来决定向用户展示什么内容,或者在规则3中确保代码的正确执行即可。
3. 将能力检测逻辑封装起来,以防止应用程序因此崩溃
NotificationManager.canPostPromotedNotifications()用于判断设备是否真的会支持推广通知功能。根据官方文档,这个方法是在API 36版本中添加的,因此从理论上讲,它应该从Android 16开始就存在了;然而,它所依赖的POST_PROMOTED_NOTIFICATIONS权限直到36.1版本才被加入系统。不过,在我测试的预QPR版本中,运行时调用这个方法会导致应用程序崩溃。因此,必须将这个检测逻辑封装起来,并将其默认值设置为false:
Function("canPostPromotedNotifications") {
// 这是一个同步执行的函数,因为UI需要在第一次渲染时就知道是否需要禁用该功能。
val manager = notificationManager ?: return@Function false
// 如果设备的API版本低于36,则不需要进行推广通知的检查。
if (Build.VERSION.SDK_INT < 36) return@Function false
// 虽然官方文档称这个方法是在API 36中添加的,但在预QPR版本中调用它会导致崩溃。因此,当该方法不可用时,应将其默认值设置为false。
return@Function runCatching { manager.canPostPromotedNotifications() }.getOrDefault(false)
}
如何构建Kotlin代码部分
以下是用于创建通知的Kotlin代码。请注意,其中很多代码都是为了满足推广通知功能的要求,而不是为了美观而添加的。
// 进度百分比需要进行限制,以防止数值过大导致显示异常。
val progressPercent = (progress.coerceIn(0.0, 1.0) * 100).toInt()
val builder = NotificationCompat.Builder(ctx, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_delivery)
.setContentTitle(status) // 推广通知功能要求设置标题
..setContentText(courierLine(courierName, riderReassigned, stopsRemaining))
.setSubText("订单编号:$orderId")
.setOngoing(ongoing) // 推广通知功能要求显示进行中状态
.setOnlyAlertOnce(true) // 只提示一次,之后自动更新状态
.setColor(BRAND_ORANGE) // 不设置颜色会导致功能无法正常使用
.setShortCriticalText("$progressPercent%") // 状态栏中的进度文本
.setRequestPromotedOngoing(ongoing) // 发送推广通知的请求本身
// `when`参数用于设置预计到达时间,这个时间必须是在未来。
if (etaEpochMillis > System.currentTimeMillis()) {
builder.setWhen(etaEpochMillis.toLong()).setShowWhen(true)
}
if (Build.VERSION.SDK_INT >= 36) {
val style = NotificationCompat.ProgressStyle()
.setStyledByProgress(true) // 进度条的颜色会随数值变化而改变
.setProgress(progressPercent)
.setProgressTrackerIcon(IconCompat.createWithResource(ctx, R.drawable.ic_delivery))
.setProgressSegments(listOf( // 进度条的分段
NotificationCompat.ProgressStyle.Segment(100).setColor(BRAND_ORANGE)
))
.setProgressPoints(listOf( // 分段上的标记点
NotificationCompat ProgressStyle.Point(35)..setColor(Color.WHITE)
))}
builder.setStyle/style)
} else {
builder.setProgress(100, progressPercent, false) // 在Android 16之前的版本中使用这种简单的进度条实现方式
}
val notification = builder.build()
if (Build.VERSION.SDK_INT >= 36) {
Log.d(TAG, "hasPromotableCharacteristics=${notification.hasPromotableCharacteristics()}")
}
manager.notify(notificationIdFor(activityId), notification) // 使用稳定的通知ID进行发送
其中一些代码行具有重要的意义:
-
setOnlyAlertOnce(true)这个方法可以防止每次推送通知时手机都会发出震动声。只有第一次调用的notify()会触发提醒,其余的推送通知都不会产生任何提示。 -
setColor和setColorized的区别:setColor只是简单地为通知设置颜色,而setColorized(true)会使得该通知无法被纳入推广活动。这两者是不能互换使用的。 -
setWhen必须指定一个未来的时间戳,否则更新操作将会被完全跳过。某些用户界面会将这个时间戳显示为绝对时间,而 Pixel 设备则会将其显示为相对倒计时。 -
ProgressStyle中的 “segments” 和 “points” 的区别:“Segments” 是指条形图中不同的颜色区间;例如,
Segment(100)会占据整个条形图,而多个这样的区间则会形成分段的视觉效果。“Points” 则是在达到指定百分比时在条形图上显示的标记点。 -
else 分支的作用:
在 Android 36 及更低版本系统中,兼容层并不支持
ProgressStyle>,因此系统会回退到使用传统的固定宽度条形图来显示通知。
在运行时,只有两个检测机制能够为你提供调试线索,而它们分别用于解答不同的问题:
-
hasPromotableCharacteristics()这个方法在build()执行后被调用,它会告诉你你是否满足了所有进行推广活动所需的条件。这个方法与设备本身的属性无关。 -
canPostPromotedNotifications()这个方法会判断设备是否能够正常处理这些推广通知。在三星设备上,这两个方法的检测结果可能会不一致,因此你需要同时使用它们来进行调试。
notificationIdFor() 方法会根据活动 ID 生成一个固定的整数,这样使用相同的 ID 进行再次推送时,通知内容就会在原有位置得到更新。如果使用不同的 ID,系统就会发送第二次通知,从而导致 “为什么我有七张推送通知卡片” 这样的问题。

是否能够进行推广活动是一个非此即彼、且完全不可逆的决定。
只有当所有条件都满足时,系统才会允许进行推广:必须获得用户的通知权限,必须调用 setRequestPromotedOngoing(true) 方法来启用推广功能,内容必须有标题,样式必须符合要求,重要性等级必须高于 MIN,并且通知颜色不能被自定义。如果任何一个条件不满足,系统就会发送一条普通的普通通知,而不会给出任何解释。因此,在每次执行 build() 后,都需要调用 hasPromotableCharacteristics() 方法进行检测;同时,也需要在开发工具中查看 canPostPromotedNotifications() 的返回结果。这两个布尔值是平台提供的唯一调试线索。
另外需要注意的是,对于推广通知来说,是不允许使用自定义的 RemoteViews 的。你只能选择使用 ProgressStyle> 模板,否则就无法进行任何推广活动。与 iOS 不同,在 iOS 上你可以随意使用 SwiftUI 来设计通知界面。
当该功能在真正的API 36.1版本中得到实现时,应用会同时具备芯片功能以及位置较高的锁屏界面元素。


同一个APK文件在不同的运行环境中会表现出三种不同的行为:
| 运行环境 | 表现结果 |
|---|---|
| API 36.1 (QPR) | 显示芯片图标、优化后的锁屏界面元素以及分段进度条 |
| API 36 (基础版本) | 仅显示分段进度条,没有其他优化效果 |
| 低于API 36的版本 | 完全不会显示任何进度条元素,除非你手动保留了setProgress相关代码 |
如何使用FCM从服务器端控制Android应用
这就是这两个平台之间最本质的区别。
在iOS系统中,APNs会通过系统自动更新通知界面元素,而你的应用程序代码根本不会被执行。而在Android系统中,并没有由系统管理的远程更新机制。只有数据类型的FCM消息才会触发Firebase MessagingService的运行,然后你的应用程序代码才会重新生成并显示通知内容。即使是在后台,应用程序也是负责进行更新操作的。
// 在应用清单文件中注册了这个服务,因此Android系统可以在不需要你的Activity、React上下文或模块的情况下直接实例化它。
class DroptrackFcmService : FirebaseMessagingService() {
// 在应用安装完成后会调用此方法,每当FCM更新令牌时也会再次调用。千万不要认为启动时看到的令牌仍然有效。
override fun onNewToken(token: String) {
Log.i(DeliveryNotifier TAG, "[DropTrack] fcm token: $token")
}
override fun onMessageReceived(message: RemoteMessage) {
// FCM发送来的数据总是以Map的形式存在。所有的数字和布尔值在传输过程中都会被转换为字符串形式,因此在这里需要重新解析回原始类型。
val d = message.data
// 如果没有activityId字段,就意味着无法确定通知的具体内容。在这种情况下应该直接放弃处理,而不是随意猜测:使用错误的id发送通知会导致重复的通知界面出现。
val activityId = d["activityId"] ?: return
val event = d["event"] ?: "update"
// 这个服务是一个独立的入口点,它无法访问模块中的内存状态,因此所有的数据都必须从接收到的消息中获取。在进行解析时必须格外小心,因为一旦出现错误,整个更新流程就会失败。
DeliveryNotifier.ensureChannel(this) // 当前进程可能是全新的
DeliveryNotifier.post(
ctx = this,
activityId = activityId,
// 下面所有的“?:”操作都起着关键作用。toDoubleOrNull方法会在输入数据格式不正确时返回null,而不会抛出异常,因此即使数据有误,也不会导致服务崩溃。
orderId = d["orderId"] ?: "",
status = d["status"] ?: "",
progress = d["progress"]?.toDoubleOrNull() ?: 0.0,
etaEpochMillis = d["etaEpochMillis"]?..toDoubleOrNull() ?: 0.0,
stopsRemaining = d["stopsRemaining"]?.toIntOrNull() ?: 0,
courierName = d["courierName"] ?: "",
riderReassigned = d["riderReassigned"]?.toBoolean() ?: false,
// 设置“end”状态可以清除“ongoing”状态,这样用户就可以最终删除这个通知界面了。
ongoing = event != "end",
)
}
}
那条关于“独立入口点”的评论其实才是关键所在,因此值得我们仔细研究。Android可以自行启动这项服务来发送推送通知,而此时你的应用程序的其他部分可能并未运行。此时不存在React框架,也没有模块实例,应用程序在内存中保存的任何对象也都已经不存在了。因此,推送处理程序无法从应用程序的状态中获取“当前正在进行哪些推送操作”的信息,因为根本就没有可供查询的应用程序状态数据。它所需的一切信息都必须直接来自推送通知本身。
这种设计要求特定的实现方式:用于生成通知的代码不能位于应用程序内部,也不能读取应用程序的状态数据;这类代码必须是独立的、仅能处理原始数据的代码。
因此,我将所有相关的功能整合到了一个共享的DeliveryNotifier对象中,无论是通过JavaScript驱动更新还是通过推送服务来发送通知,两条路径都会调用这个对象。由于它们使用相同的输入数据来运行相同的生成逻辑,因此最终产生的通知内容是完全一致的。而这种共享的设计机制,正是Android端最重要的结构改进之一。

Android推送通知的四大规则
如果有任何一点不符合这些规则,推送通知就会失败。简而言之:必须发送正确类型的消息、使用正确的验证方式、准确解读错误代码,并且要在预构建阶段完成相关插件的配置。下面会详细说明每一条规则。
规则1:仅发送数据型消息,切勿发送包含notification块的消息
这条规则很容易被大家忽略。当应用程序处于后台状态或被关闭时,如果发送了包含notification块的消息,系统会自动处理这些通知,而你的onMessageReceived方法根本不会被调用,因此通知界面也不会得到更新。只有那些不包含notification块的数据型消息才能在应用程序后台被成功处理。
Firebase在Android应用中接收消息文档中对此有明确说明:应发送数据型消息,并将android.priority设置为high,这样应用程序就能立即被唤醒。
规则2:使用服务账户令牌进行验证,而非静态密钥
与APNs相比,FCM v1的认证流程更为复杂,因为它不支持一次性的.p8密钥文件。你需要从服务账户生成RS256格式的JWT令牌,然后将其兑换成短期有效的OAuth访问令牌,并将该访问令牌作为身份凭证传递给projects.messages.send方法来发送通知。
不过值得欣慰的是,FCM v1使用的是普通的HTTPS协议,因此fetch()这样的HTTP请求函数仍然可以使用。
// `sa` 是从 Firebase获取的已解析的服务账户JSON数据。这个JWT并不是你发送给FCM的凭证;而是你需要用它来换取一个短期有效的访问令牌。
export function buildAuthJWT(sa) {
const now = Math.floor(Date.now() / 1000);
const header = b64url(JSON.stringify({ alg: "RS256", typ: "JWT" })); // 使用RSA密钥进行加密,因此不会遇到ieee-p1363相关的问题
const claims = b64url(JSON.stringify({
iss: sa.client_email,
scope: "https://www.googleapis.com/auth/firebase.messaging", // 必须指定具体的权限范围,否则每次发送消息都会收到403错误
aud: sa.token_uri, // 这个JWT是专门用于向Google的令牌端点请求访问令牌的
iat: now,
exp: now + 3600, // 一小时后失效,这是Google规定的最大有效期
}));
const signingInput = `${header}.${claims}`;
const sig = createSign("RSA-SHA256").update(signingInput).sign(sa.private_key);
return `${signingInput}.${b64url(sig)}`;
}
export async function sendDataMessage({ token, data, sa }) {
const accessToken = await getAccessToken(sa); // 用JWT换取访问令牌,并将结果缓存起来
const res = await fetch(
`https://fcm.googleapis.com/v1/projects/${sa.project_id}/messages:send`,
{
method: "POST",
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify({
message: {
token, // 每个设备都需要一个令牌(APNs则是按活动来分配令牌的)
data, // 注意:这里不需要`notification`键
android: { priority: "high" }, // 优先级设置为“高”
},
}),
}
);
const body = await res.json().catch(() => ({})); // 如果响应数据格式错误,也不会抛出异常
return { status: res.status, reason: body?.error?.status ?? null };
}
这种设计依赖于两个“没有”以及一种间接操作。这里的间接操作就是通过交换JWT来获取访问令牌。与APNs不同,在APNs中JWT本身就是凭证,而在这里,你需要将JWT发送到Google的令牌端点,然后获得一个短期有效的访问令牌,再使用这个令牌进行后续操作,因此需要将结果缓存起来,而不是为每条消息都重新生成访问令牌。
第一个“没有”的地方是响应数据中不存在`notification`键,这是有意为之的,后面会详细说明原因。第二个“没有”的地方是优先级设置为“高”,因为如果优先级设置为“普通”,那么消息可能会等到设备从Doze状态恢复后才会被发送,而到那时消息可能已经送达了。
在响应中,`404 UNREGISTERED`表示令牌无效,而`400 INVALID_ARGUMENT`则表示这个对象根本就不是有效的访问令牌。调用者需要区分这两种情况,因此系统会返回相应的错误原因信息,而不会直接忽略这些错误信息。
FCM中的`data`字段是一个字符串到字符串的映射结构,因此在发送数据时所有内容都会被转换为字符串形式,在接收数据时又会重新被解析为原始结构。与iOS不同,这里不会出现隐式的类型不匹配问题,因为你是自己编写了解析逻辑的。但是你的解析逻辑绝对不能出现异常。
规则3:将`400`和`404`视为不同的错误类型
FCM会将两种情况区分开来,而APNs则会将它们合并处理。格式错误的令牌会返回`400 INVALID_ARGUMENT`,而格式正确但未注册的令牌会返回`404 UNREGISTERED`(完整的错误代码列表可以在FCM错误代码参考文档中找到)。
APNs会将这种情况报告为BadDeviceToken。这对于iOS部分中使用的虚假令牌认证测试来说非常重要:在FCM系统中,使用虚假令牌会导致INVALID_ARGUMENT错误,而不会显示UNREGISTERED。不过这两种情况其实都能证明你的认证机制是有效的,因为错误的服务账户在尝试连接时都会立即收到401或403错误。我最初编写测试代码时预期会得到UN REGISTERED结果,正是通过修正这些错误才明白了这两者之间的区别。
规则4:通过预构建过程配置google-services插件
google-services插件的作用是将你的Firebase配置信息纳入构建过程中。由于“持续原生生成”功能会在每次进行预构建时重新生成android文件夹,因此必须使用配置插件来重新替换google-services.json文件,并重新配置Gradle的相关设置,否则在下次进行预构建时这些设置就会丢失。
// plugins/withAndroidFcm.js
function withGoogleServicesJson(config) {
// withDangerousMod会在预构建过程中执行任意的文件系统操作。这种做法虽然“危险”,但却是唯一一种可以在Gradle插件期望该文件已存在的情况下实际插入该文件的方法。
return withDangerousMod(config, ["android", (cfg) =>> {
// 这个文件被保存在仓库的根目录下,Git会忽略它,但在每次预构建时都会被复制到目标位置。
const src = path.join(cfg.modRequest.projectRoot, "google-services.json");
// 它必须被放置在android/app/目录中,因为google-services插件会在那里查找这个文件。
const dest = path.join.cfg.modRequest.platformProjectRoot, "app", "google-services.json");
// 如果找不到这个文件,就必须在预构建阶段抛出错误。如果不这样做,构建过程会成功完成,应用程序也会启动,但FCM在运行时却会无法正常初始化。
if (!fs.existsSync(src)) {
throw new Error("[withAndroidFcm] 项目根目录下未找到google-services.json文件");
}
fs.copyFileSync(src, dest);
return cfg;
});
}
// 还需要使用withProjectBuildGradle来配置类路径,
// 并使用withAppBuildGradle来应用该插件。
当文件缺失时,让程序抛出错误是更好的处理方式。在预构建阶段就快速发现问题,总比在运行时遇到意外要好得多。
诚实的说明
在设备进入Doze状态,或者在被强制关闭后,数据消息可能会丢失。而对于iOS应用程序来说,这些实时更新功能是由系统自动处理的,因此优先级较高也有助于数据的及时传输。这种限制属于平台本身的特性,我们只能通过文档来说明这一点,而无法通过技术手段来规避它。在测试时,应该确保应用程序处于后台运行状态,而不是被用户主动关闭,同时也要向你的产品团队如实说明这些差异。
天真实现方式所导致的三个用户体验缺陷
由于你是负责编写Android端的代码,因此你需要承担iOS系统本来会自动处理的三项职责。我在测试中发现了这三项问题后,才对代码进行了相应的修改。
点击通知并不会产生任何效果
NotificationCompat能够正常显示通知,但如果没有使用setContentIntent(PendingIntent),那么就无法提供可供用户点击的交互元素,因此Android系统只会简单地显示或隐藏该通知。文档中并没有明确指出这一点。
private fun launchIntent(ctx: Context, activityId: String): PendingIntent? {
// 不要直接使用“MainActivity”这个类名,而是通过包管理器来获取对应的启动意图。
// 在Expo的持续原生生成机制下,这个类名是动态生成的,如果硬编码的话,在下次预构建时会出问题。
val intent = ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)
// SINGLE_TOP:重用现有的任务栈,而不是创建一个新的任务栈。
// CLEAR.TOP:清除当前任务栈中的所有内容,使用户直接进入目标界面。
?.apply { flags = Intent.FLAG_ACTIVITY_SINGLE_Top or IntentFLAG_activityCLEAR Tops }
?: return null
return PendingIntent.getActivity(
ctx,
// 请求代码。每个活动都有唯一的请求代码,这样同时发送的两个通知就会得到不同的PendingIntents,
// 而不会共享同一个PendingIntent。
notificationIdFor(activityId),
intent,
// 在Android 12及更高版本中,必须使用FLAG_IMMUTABLE;如果不使用这个标志,程序会抛出异常。
PendingIntent.FLAG_immUTABLE or PendingIntent FLAG_UPDATE_CURRENT,
)
}
在Android 12及更高版本中,必须使用FLAG_IMMUTABLE。可以使用adb shell dumpsys notification来验证这个设置是否正确,该命令应该会显示contentIntent=PendingIntent{... startActivity}这样的内容。
推送通知只会更新通知内容,而不会更新应用程序的用户界面
由于推送服务与JavaScript状态是相互独立的,因此当应用程序处于打开状态时收到推送通知,应用程序界面仍然会显示之前的信息。为了解决这个问题,需要建立一个静态的桥梁机制:当模块实例存在时建立这个桥梁,当模块实例不存在时则删除它。
companion object {
// 这个变量是静态的,因为FCM服务无法直接访问模块的INSTANCE;同时使用@Volatile注解,是因为该服务运行在与其他线程不同的线程上。
// 这个变量也可能是null,因为在大多数情况下,并没有活跃的模块需要发送通知。
@Volatile private var pushEmitter: ((Map) -> Unit)? = null
// 从任何地方都可以安全地调用这个方法。这里的`?.`实际上就是在检查“应用程序是否还处于活动状态”。
fun emitPush(data: Map) { pushEmitter?.invoke(data) }
}
override fun definition() = ModuleDefinition {
// 宣布JavaScript可以订阅的事件。如果声明了不存在的事件,程序会抛出异常。
Events("onFcmTokenReceived", "onDeliveryPush")
// 当模块实例存在时,建立这个桥梁机制…
OnCreate { pushEmitter = { data -> sendEvent("onDeliveryPush", data) } }
// …当模块实例不存在时,删除这个桥梁机制。如果不删除它,就会导致内存泄漏。
OnDestroy { pushEmitter = null }
// …
}
在调用notify()之后,该服务会执行DroptrackLiveModule.emitPush(d)这一操作。当应用程序已经关闭时,这个方法并不会产生任何效果;在这种情况下,通知仍然会被显示出来,但实际上并没有任何需要同步的数据。这相当于iOS系统中“前台应用重新同步”的功能在Android平台上的实现。
冷启动的应用程序会显示为空白界面
iOS系统是通过Activity.activities来恢复应用程序状态的。而Android系统中并没有这样的机制,因此当用户重新打开一个已经被关闭的应用程序时,系统会显示“未进行跟踪”这一信息。为了解决这个问题,应该在每次调用notify()时,都将相关数据保存到SharedPreferences中,然后再在需要时读取这些数据。
与iOS版本中的实现使用相同的JavaScript函数名,因此App.tsx文件无需根据平台进行任何修改。
AsyncFunction("getRunningActivities") {
val ctx = context ?: return@AsyncFunction emptyList
这里有两条需要注意的细节:首先,需要使用getActiveNotifications()来确保数据在重新加载时是正确的。如果没有进行这个检查,使用过时的数据可能会导致无法取消的通知操作,而点击“取消”按钮时会抛出ActivityNotFoundException异常。其次,endDelivery方法应该被设计得更加宽容一些:即使应用程序在内存映射中不存在,也应该取消通知并清除相关状态,而不是直接抛出异常。系统资源的清理操作绝对不应该因为某些疏忽而失败。
由于App.tsx中的重新同步功能是跨平台的,因此只需在Android版本中实现getRunningActivities()>方法,就可以让现有的冷启动处理流程正常工作,而无需对JavaScript代码进行任何修改。这就是为什么在两种不同的后端环境中使用同一个API会带来如此大的便利。
如何对模拟器和设备进行脚本操作
有两件小工具为开发工作节省了大量时间,它们的作用甚至超过了任何其他功能。
iOS模拟器不支持通过脚本来触发点击操作。也没有类似的
useEffect(() => {
// 在发布版本中,这些代码会被删除:打包工具会移除整个effect块。
if (!__DEV__) return;
// "droptrack://drive/next" -> "next" -> 调用actions.next()方法。
// 应该使用actionsRef而不是actions,因为监听器只注册一次,如果使用普通的闭包,就会永久捕获第一次渲染时的处理函数。
const run = (url: string) => actionsRef.current[url.split("/").pop() ?? ""]?.();
// 情况1:应用程序已经在运行中。simctl openurl会触发这个事件。
const sub = Linking.addEventListener("url", ({ url }) => run(url));
// 情况2:使用devicectl --payload-url命令来冷启动应用程序:此时URL会作为初始地址被传递过来,因此不会触发“url”事件。需要同时处理这两种情况,否则在模拟器自动化运行时,设备自动化功能将无法正常工作。
void Linking.getInitialURL().then((url) => url && run(url));
return () => sub.remove();
}, []);
# 这个命令会让正在运行的应用程序向前执行一步,无需用户点击操作,也不会请求访问权限。
xcrun simctl openurl booted "com.fasarticle.droptrack://drive/next"
对于推送通知的部分,我构建了一个小型Web调度器。由于HTTP/2协议、跨源请求头的问题,以及签名密钥必须始终保存在服务器上,因此浏览器无法直接与APNs或FCM进行通信。所以,这个调度器会与一个不依赖任何外部服务的本地Node.js服务器进行交互,该服务器会从设备控制台中获取推送令牌,然后通过服务器发送的事件将这些令牌传输给浏览器,并最终将它们发送到你选择的任何平台。
iPhone --NSLog--> devicectl --console --
Android --Log.i--> adb logcat -----------+
| 获取推送令牌
浏览器 <- SSE /events --> 调度服务器 (127.0.0.1:8787)
\--POST /push --------> apns.mjs / fcm.mjs --> APNs / FCM --> 设备
只需选择一步操作、选择一个推送方式,然后按下按钮即可。这样,原本需要两分钟的测试过程就可以缩短为十秒钟,也正是这样的设计使得下面的三星调查成为可能。

iOS与Android的对比
| iOS实时更新功能 | Android实时更新功能 | ||
|---|---|---|---|
| 推出时间 | iOS 16.1和16.2版本 | Android 16版本(API 36),在16.1版本中得到推广 | |
| 界面设计 | 通过Widget扩展使用自定义的SwiftUI组件 | 系统提供的ProgressStyle模板,升级版本后不允许使用自定义的RemoteViews |
|
| 适用场景 | 锁屏界面和Dynamic Island功能,在Mac设备上还可通过Continuity功能使用 | 采用Shade效果、状态栏图标显示,升级版本后会在锁屏界面上增加相应的选项 | |
| 应用关闭时的更新方式 | 为每个活动单独生成APNs令牌,系统会自动更新相关Widget组件 | 重新发送相同的通知ID。对于远程更新,会使用FCM数据消息来唤醒你的应用程序服务 | |
| 进度条显示方式 | 手动制作的HStack结构来展示进度条 |
使用ProgressStyle元素和箭头来表示进度 |
|
| 推广规则 | 一旦应用程序启动,就会自动开始推送通知 | 需要用户授权,会请求用户的权限,推送过程中会显示标题,允许使用的样式有限制,重要性等级高于MIN,且通知不会被着色显示 |
|
| 推送认证方式 | 使用ES256格式的.p8密钥进行认证,通过HTTP/2协议传输 |
使用服务账户的RS256密钥或OAuth令牌进行认证,采用普通的HTTPS协议 | |
| 数据包解码方式 | 系统会自动将接收到的数据包解码成你应用程序中定义的Codable>类型 |
如果解码失败,系统会默默地忽略这些数据包 | 你需要在自己的应用程序中自行解析这些数据包 |
| 冷启动后的恢复机制 | 系统会保存Activity.activities>对象,你需要自己负责将这些数据重新读取出来 |
||
| 点击打开通知的方式 | 无需特殊操作,可以直接打开通知 | 必须附加一个包含内容的PendingIntent |
|
| 推送保障机制 | 由系统负责处理推送任务,因此非常可靠 | 即使设备处于Doze模式或被强制关闭,推送通知也会正常发送 |
三星的“现实检验”
版本号往往具有欺骗性,而三星在这方面表现得最为明显。我使用三星远程测试实验室在三款真实的Galaxy设备上进行了测试。该工具的远程调试功能实际上就是一个连接至韩国境内手机的本地adb隧道。整个模拟器自动化流程(包括授权、点击操作、截图以及读取dumpsys数据)在远程硬件环境下也能正常运行,没有任何变化。
以下是同一份未经修改的APK在每款设备上的测试结果:
| 设备 | One UI / SDK版本 | canPostPromotedNotifications() |
实际显示的内容 |
|---|---|---|---|
| Galaxy S25 Ultra | 8.0 / 36.0 | false |
什么都没有显示——既没有促销提示,也没有任何卡片信息。 |
| Galaxy S26 Ultra | 8.5 / 36.1 | true |
通知栏顶部会显示相关图标,状态条也会出现相应标志。 |
| Galaxy A37(中端机型) | 8.5 / 36.1 | true |
与S26 Ultra的表现相同。 |
从上表中可以得出两个结论。
1. 在基于36版本的设备上,这两种检测结果存在矛盾
S25 Ultra运行的是基于36版本的系统,该版本没有促销功能,因此没有任何内容会被显示出来。但hasPromotableCharacteristics()函数的返回值为true(因为三星为该系统添加了一些相关框架组件),而canPostPromotedNotifications()函数的返回值却是false。在基于36版本的Pixel模拟器上,这两个函数的返回结果也都同样是false。因此,你无法通过其中一个函数的检测结果来推断另一个函数的结果。实际上,应该在运行时同时进行这两项检测,并且要相信canPostPromotedNotifications()>函数的判断结果,才能确定是否真的会触发促销功能。
2. 在36.1版本的设备上,促销功能可以正常使用,但效果并不完全
无论是S26 Ultra还是Galaxy A37,它们都会将FLAG_PROMOTED_ONGOING标志设置给未经修改的APK,这一信息可以通过dumpsys命令来确认。确实,通知栏顶部会显示相关图标,状态条也会出现相应标志。然而,尽管促销功能已经成功触发,你仍然无法看到芯片提示图、锁屏界面上的促销卡片,或者Now Bar中的相关内容。

“效果并不完全”这个现象一直是个谜,直到我发现那些缺失的功能其实是被隐藏在了哪里。在设置菜单中,“锁屏与常开显示”选项下有一个名为“实时通知”的页面(可以通过搜索“实时通知”来找到,注意不要搜索“Now Bar”)。这个页面正好包含了那些我之前看不到的功能:锁屏界面、状态条以及通知栏顶部区域,图中还展示了Now Bar中的促销图标。而就在这个页面的下方,列出了六个被允许使用的应用程序:音频广播、紧急分享功能、Google Finance、地图应用、媒体播放器,以及Google提供的体育相关服务。
DropTrack并不在那个列表中。然而,它的推送功能却在那一时刻、就在那台设备上成功运行了,而且页面上“没有看到实时通知?”的提示框中所要求的三个权限也都已经满足。这个功能确实是正常工作的。只不过,三星并没有将其最好的功能提供给这六款之外的其他设备。

根据这些截图,我们可以得出以下结论:One UI 8.5版本允许所有应用使用谷歌提供的推送服务,但会将最重要的功能保留给那些被明确列入允许名单的应用。这个列表是在测试时针对特定设备制定的,三星以后也可能会对其进行调整。
三星将这三种平台放在了完全不同的位置上:苹果在所有设备上都提供了公共API;谷歌则只在自己的硬件上提供公共API;而三星虽然向所有人提供了开发框架,但最核心的功能却只允许特定的应用使用。
演示仓库
这本手册中的所有内容其实都是一个完整的项目,那就是DropTrack,它是在MIT许可协议下发布的。这个项目并不是简单地将一些代码片段拼凑在一起,而是一个真正的React Native应用程序,它的推送跟踪功能贯穿了整个应用的五个层次结构。阅读这个项目的目的在于了解这些层次之间是如何相互连接的,而不是仅仅看看其中某个层次的具体实现方式。
下面是这个项目中各个层次的内容:
| 层次 | 所在位置 | 包含的内容 |
|---|---|---|
| 移动应用层 | App.tsx, delivery.ts |
React Native用户界面以及跨平台的TypeScript API(startDelivery / updateDelivery / endDelivery),这些功能在原生版本和移动应用版本中都被实现了。 |
| iOS原生层 | modules/droptrack-live/ios/, targets/widgets/ |
Swift ActivityKit桥接层以及SwiftUI组件,其中包括分段条、重新分配数据等功能。 |
| Android原生层 | modules/droptrack-live/android/ |
Kotlin语言编写的NotificationCompat构建模块、推送逻辑处理代码,以及用于接收推送通知的FirebaseMessagingService。 |
| Web层 | DispatcherConsole.tsx, src/dispatchClient.ts |
Expo平台的Web调度控制台,用户可以通过这个界面为指定的设备发送推送通知。 |
| 后端层 | scripts/ |
APNs客户端(约100行代码)和FCM客户端(约80行代码),这些代码都是完全从头开始编写的,没有使用任何第三方推送库;此外还包括用于将控制台与真实设备连接起来的本地签名服务器。 |
有几点使得克隆这个项目比仅仅浏览它的代码更有意义:
-
它确实在三种不同的平台上都能正常运行:同一个
DeliveryState对象既可以用于驱动SwiftUI Live Activity,也可以用于生成Android系统的通知信息,同时还能在网页控制台中使用,因此你可以看到同一个API会产生三种完全不同的结果。 -
这些推送功能所需的客户端代码没有任何依赖项:
scripts/apns.mjs和scripts/fcm.mjs仅使用了Node.js内置的功能,因此你可以直接阅读这两段代码而无需解压任何第三方库。这两段代码旁边还附有简单的测试用例文件。 -
本文中提到的所有注意事项都记录在
GOTCHAS.md文件中:原本放在这里的详细检查清单现在被移到了那里,与相关的代码一起存放,同时还有DEVLOG.md(记录构建过程中的详细信息)和ARTICLE_NOTES.md文件。 -
克隆这个项目是安全的:Firebase服务账户、
google-services.json文件以及APNs的.p8密钥都被设置为git忽略项,因此历史版本中不会包含任何敏感信息。README.md文件中也明确列出了哪些内容是需要你自己提供的才能完成推送功能的配置。
克隆这个项目后,在模拟器上运行应用程序,然后打开网页控制台,再将更新内容推送到自己的设备上。通过这样的步骤,你可以最快地验证上述所有功能是否正常工作。
开始之前需要了解的内容
这两个平台上的产品功能是相同的,但相关的协议规定却截然不同:在iOS系统中,系统会为你提供相应的组件并自动更新它们;而在Android系统中,系统只会生成通知信息,你需要自己负责更新这些信息,甚至包括每次收到新消息时都需要通过后台服务重新发送通知。因此,在设计你的模块时需要根据这些差异来进行相应的调整。同一个具有三种功能的API,其内部实现的机制可能会大相径庭,而一个优秀的原生模块正是用来解决这种问题的。
那些“无声无息”的错误其实才是真正需要重视的问题:在这个项目中,几乎每一个严重的缺陷——无论是组件结构不匹配、权限设置错误,还是其他各种问题——在运行时都不会产生任何错误提示。因此,你必须采取积极的措施来检测这些问题。要记录那些能够被系统识别的异常情况,验证权限信息在签名过程中是否没有被破坏,在真正使用设备进行测试之前,也要用伪造的令牌来测试推送功能是否正常工作。永远要在真实的设备上确认各项功能的正确性,而不要仅仅依赖服务器返回的“200”状态码。
版本号往往具有误导性,因此应该通过其他方式来验证功能是否可用:Android 16实际上包含两个不同的版本,而三星发布的Android 16版本又与它们有所不同。canPostPromotedNotifications()和hasPromotableCharacteristics()这两个方法在同一个构建版本上可能会返回不同的结果。因此,在运行时必须同时检查这两个方法的结果,而不能根据其中一个结果来推断另一个的结果。
活动的生命周期是由系统控制的,而不是由你的进程控制的:在iOS系统中,活动的生命周期会超过你的变量、React状态以及整个进程的生命周期;而在Android系统中,通知信息的生命周期也会超过你的进程的生命周期,但是没有任何机制会自动帮你恢复这些活动。无论是哪个平台,如果你认为useState中存储的值就是活动的真实状态,那么你就会遇到问题。
结论
现在,你对这两个平台已经有了全面的了解。你使用两个原生后端开发了一个TypeScript API,创建了一个具有四种展示方式的SwiftUI组件,编写了一个符合Android推送规范要求的Kotlin通知系统,还完全从头开始实现了两个不依赖任何第三方库的推送客户端。同时,你也了解了那些不会产生错误提示的故障类型——而这类故障其实占大多数。
接下来有三件事值得进一步探索:
-
在iOS 17.2及更高版本中,支持“推送即启动”功能。你可以通过推送通知直接启动一个实时活动,而无需先打开整个应用程序。这里的令牌是针对每个应用程序而非每个具体活动来生成的,这一变化改变了之前描述的令牌处理机制。
-
在iOS 18版本中,引入了“广播频道”功能。一条推送通知可以同时更新多个用户的实时数据,这一功能非常适合用于显示实时的比赛比分。不过,在本手册中提到的所有推送操作都是针对同一个令牌来进行的。
-
Android平台的前台服务。这种服务能够缩小应用程序在“Doze模式”下的响应延迟,甚至可以在某些情况下强制结束应用程序的运行。虽然它不能完全消除这些延迟,但确实能让那些需要长时间运行的任务更加稳定地完成数据传输。
其实,真正有趣的部分从来都不是`Activity.request()`这个方法本身,而是那些两个平台在你使用这种方法出错时拒绝告诉你的重要信息。
参考资料与延伸阅读
关于Apple、ActivityKit以及APNs的相关资料:
-
ActivityKit以及
ActivityAttributes
Android与Firebase:
-
Notification.ProgressStyle与NotificationCompat.ProgressStyle -
NotificationCompat.Builder,其中setRequestPromotedOngoing与setShortCriticalText方法已被回溯到早期版本中 -
Build.VERSION.SDK_INT_FULL与Build.VERSION_CODES FULL——版本号36与36.1之间的区别 -
NotificationManager.canPostPromotedNotifications()、Notification.hasPromotableCharacteristics()以及getActiveNotifications() -
PendingIntent.FLAG_IMMUTABLE与SharedPreferences -
Firebase云消息服务、在Android应用中接收消息、
FirebaseMessagingService、发送消息以及FCM错误代码参考
那些会记录自身实时活动信息的应用程序,这些信息在“您之前见过这一功能的应用程序”一节中有提及:
-
Chowdeck、ESPN、MLB、FotMob、CARROT Weather以及Structured,这些信息均可在它们的App Store页面上找到。
-
MacRumors在2023年5月关于Uber Eats的文章以及2023年12月关于DoorDash的文章中提到了这些功能。
-
MacStories在iOS 16.1版本中的实时活动功能汇总文章中也进行了介绍。
React Native、Expo及相关工具:
-
@bacons/apple-targets,这个工具可用于生成所需的插件代码。 -
expo-live-activity,这是另一个可供选择的实现方案。 -
,这个项目现已停止更新,其最后一个版本是
@notifee/react-native@9.1.8。 -
React Native的
AppState组件以及Linking功能 -
Node.js的
crypto模块以及http2模块 -
RFC 7519,JSON Web Token标准以及RFC 7518,JSON Web算法标准,这些规范定义了ES256和RS256签名格式。
-
Samsung的远程测试实验室



