Broadcast Extension 里的 Live Activity:一次跨进程妥协

一、一个"半天能做完"的需求

产品的需求很朴素:录屏进行中,灵动岛上显示一个红点和计时;点一下能回到 App;最好还能直接在岛上停止录制。

你打开 ActivityKit 的文档,看到 Activity.requestupdateend 三个方法,心想半天够了。录制逻辑在 Broadcast Upload Extension 里,帧从那里来,状态在那里变,理所当然在那里调 Activity.request

编译通过。真机上运行,什么都没出现。没有崩溃,没有日志。你加上 do/catch,拿到一个错误,大意是"这个 target 不支持"。

于是你去查,发现一件比错误本身更麻烦的事:能启动 Live Activity 的只有你的主 App,而录屏进行的时候,你的主 App 大概率根本不在运行。 用户从控制中心开始录制,然后去玩游戏、看视频------你的 App 早就被挂起或者干脆没启动过。

这就是这个需求的真实形状:状态在一个进程里产生,权限在另一个进程里,而有权限的那个进程可能不活着。 半天做不完。这篇把整个妥协过程讲清楚,最后那一节的原则可以直接搬到任何"Extension 想做只有 App 能做的事"的场景里。


二、先把进程关系画出来

这个功能一共牵扯你自己的三个进程,外加系统:

进程 干什么 能不能碰 ActivityKit
Broadcast Upload Extension 收帧、编码、落盘。50MB 内存红线 不能 request / update / end。运行时报错,不是编译期
主 App 一切需要完整 App 环境的事 能。request 还要求在前台
Widget Extension 渲染 Live Activity 的 UI(ActivityConfiguration 只渲染,不驱动
系统 真正持有 Activity 的生命周期、限制时长、在锁屏和灵动岛上显示 ---

几个容易被忽略的事实:

  • Widget Extension 和 Broadcast Extension 是两个不同的 Extension。 前者是 Live Activity 的 UI 宿主,后者是录制引擎的宿主。它们之间也不能直接通信。
  • "能不能调"是运行时决定的。 ActivityKit 在 Broadcast Extension 里能 import、能编译、能链接。这意味着你的 CI 拦不住它,只有真机能告诉你。
  • 主 App 的 request 要在前台。 就算主 App 活着,在后台也启动不了 Live Activity(iOS 17.2 起有 push-to-start 例外,后面讲)。

我在第一篇里把这件事压成了一句话:"Extension 只做它必须做的事,需要完整 App 环境的能力全部让主 App 代劳。"原则没错,但它跳过了最难的部分:主 App 怎么在自己可能不活着的情况下代劳。 下面从这里开始。


三、坑一:谁来 request------时机决定了一切

先解决"启动"。主 App 能 request,但要在前台。录屏有两种启动路径:

路径 A:从 App 内启动。 用户在你的 App 里点"开始录制",弹出系统的 RPSystemBroadcastPickerView,用户确认,三秒倒计时,Extension 启动。这整个过程中主 App 都在前台。

路径 B:从控制中心启动。 用户长按控制中心的录屏按钮,选你的 Extension。主 App 可能压根没运行。

路径 A 有一个反直觉的正确时机:在用户确认 picker 之后、Extension 真正开始之前,就 request 不要等 Extension 通过 App Group 告诉你"我开始了"再启动------那时用户可能已经切走了,你的 App 已经进后台,request 会失败。

swift 复制代码
// 主 App:用户在系统 picker 里确认后(Extension 还没开始)
func broadcastWillStart() {
    guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }   // 用户可以在设置里关掉
    let attrs = RecordingAttributes(sessionID: UUID().uuidString)
    let state = RecordingAttributes.ContentState(phase: .starting, startedAt: .now)
    do {
        activity = try Activity.request(
            attributes: attrs,
            content: .init(state: state, staleDate: nil),
            pushType: nil)
        SharedState.write(.init(activityID: activity?.id, phase: .starting))   // 写进 App Group
    } catch {
        // ActivityAuthorizationError:可能是被用户关闭、数量超限、或前台条件不满足
    }
}

怎么知道"用户确认了 picker"?RPSystemBroadcastPickerView 不给回调,但 Extension 一启动,broadcastStarted(withSetupInfo:) 会跑。这中间有三秒倒计时。实践上更可靠的信号是主 App 自己的 UIScreen.capturedDidChangeNotification 或者监听 UIScreen.main.isCaptured------它在录制真正开始的瞬间变 true,而此时主 App 通常还在前台(倒计时刚结束,用户还没来得及切走)。这是一个窗口,不是保证;所以还要有兜底:如果 request 失败,把"待启动"写进 App Group,等主 App 下次回前台时补。

路径 B 在没有服务器的情况下无解。 主 App 不在运行,没人能 request。iOS 17.2 起的 push-to-start 能解:主 App 事先拿到 push-to-start token 上报给你的服务器,Extension 启动时通知服务器,服务器通过 APNs 让系统直接创建 Live Activity------整个链路不需要主 App 活着。代价是一套服务端。如果你没有服务端,诚实的答案是:从控制中心启动的录制没有灵动岛,在产品层面接受它,而不是在客户端硬绕。


四、坑二:主 App 不活着,谁来 updateend

启动只是开头。录制期间的状态变化------暂停、恢复、结束------都发生在 Extension 里,而能改 Live Activity 的是主 App。主 App 这时候在哪?

用户开始录制后去了别的 App。你的主 App 进入后台,有一小段后台执行时间(beginBackgroundTask 能争取一点),然后被挂起------不是杀掉,是冻结。挂起的进程不执行任何代码。再往后,内存紧张时被系统杀掉。

所以"Extension 通知主 App"有三种情况,对应三条通道:

主 App 状态 能用的通道 延迟 可靠性
活着(前台或后台未挂起) Darwin 通知 毫秒
挂起 什么都收不到,直到恢复 直到用户回来 ---
被杀 什么都收不到,直到下次启动 直到用户回来 ---
任何状态 APNs 推送更新 Live Activity(需服务器) 秒级 高,但不由你的进程执行

Darwin 通知是快速路径,不是可靠路径。 CFNotificationCenterGetDarwinNotifyCenter 跨进程、零配置、没有 payload、不排队。主 App 挂起时发的通知直接丢,不会在恢复时补发。这是它最容易被误解的地方:很多人把它当成一条消息队列在用。

swift 复制代码
// Extension 侧:状态变了,先落盘,再敲一下
func phaseDidChange(_ phase: RecordingPhase) {
    SharedState.write(.init(phase: phase, updatedAt: .now))          // ① 真相落在 App Group 文件里
    CFNotificationCenterPostNotification(
        CFNotificationCenterGetDarwinNotifyCenter(),
        CFNotificationName("com.yourapp.recording.changed" as CFString),
        nil, nil, true)                                                // ② 通知只是"去读一下"
}
swift 复制代码
// 主 App 侧:收到就读文件;回前台也读文件。两条路走同一个函数
CFNotificationCenterAddObserver(CFNotificationCenterGetDarwinNotifyCenter(), nil,
    { _, _, _, _, _ in Task { await LiveActivityDriver.shared.reconcile() } },
    "com.yourapp.recording.changed" as CFString, nil, .deliverImmediately)

NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, ...) { _ in
    Task { await LiveActivityDriver.shared.reconcile() }
}

关键在 reconcile 这个词。通知里不带状态,状态只在 App Group 的文件里,主 App 每次醒来做的事都是"对账":读文件,和系统里当前的 Activity 比,差什么补什么。

swift 复制代码
actor LiveActivityDriver {
    func reconcile() async {
        let shared = SharedState.read()                                  // Extension 写的真相
        let live = Activity<RecordingAttributes>.activities.first        // 系统里实际存在的

        switch (shared.phase, live) {
        case (.recording, nil), (.paused, nil):
            // Extension 在录,但岛上没东西:补启动(只有前台能成功,失败就等下次)
            try? Activity.request(...)
        case (.recording, let a?), (.paused, let a?):
            await a.update(.init(state: .init(phase: shared.phase, startedAt: shared.startedAt),
                                 staleDate: nil))
        case (.ended, let a?):
            await a.end(.init(state: .init(phase: .ended, startedAt: shared.startedAt), staleDate: nil),
                        dismissalPolicy: .immediate)
        case (.ended, nil), (.idle, _):
            break
        }
    }
}

对账式的写法有一个好处:它不关心自己是被哪条通道叫醒的,也不关心中间丢了几次通知。Darwin 通知来了对一次账,用户回前台对一次账,App 冷启动对一次账。丢通知只影响延迟,不影响最终一致。

它也有一个代价,必须诚实说出来:主 App 挂起期间,灵动岛上的状态是错的。 用户在别的 App 里从控制中心停止了录制,Extension 的 broadcastFinished 跑完了,写了"ended",敲了 Darwin 通知------主 App 挂着,什么都没发生。灵动岛继续显示"录制中",直到用户回到你的 App。

这个窗口有多大取决于用户行为,没法在客户端消掉。能做的是把它的伤害降到最低,这是下一节。


五、坑三:让 Live Activity 尽量不需要 update

既然更新不可靠,最好的策略是让正常录制期间根本不需要更新

第一直觉是每秒 update 一次计时器。这个方案在主 App 挂起后立刻失效,而且就算主 App 活着,高频 update 也会被系统限流。正确的做法是把"计时"交给系统:

swift 复制代码
// Widget Extension 里的 Live Activity UI
struct RecordingIslandView: View {
    let context: ActivityViewContext<RecordingAttributes>
    var body: some View {
        HStack {
            Circle().fill(.red).frame(width: 8)
            // 系统自己走表,不需要任何 update
            Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
                .monospacedDigit()
        }
    }
}

Text(timerInterval:) 由系统渲染进程自己推进,startedAt 只在启动时给一次。录制中这个最常见、持续时间最长的状态,零更新。 主 App 挂没挂起都无所谓。

需要 update 的只剩状态跳变:暂停、恢复、结束。暂停时要把计时器停住------把 startedAt...pausedAt 作为区间传进去,系统会显示一个静止的值。这些跳变一次录制里发生个位数次,每一次都用第四节的对账机制处理。

再看"结束"这个最痛的场景。用户从控制中心停了录制,主 App 挂起,岛上还显示"录制中"。两个缓解手段:

staleDate 每次 update 时给内容设一个过期时间。过了这个时间系统会把 context.isStale 置 true,UI 可以把红点变灰、把文字换成"状态可能已过期"。它解决不了"结束了还显示",但让错误状态看起来像不确定状态,而不是像一个确凿的谎言。录屏的场景可以设得宽一点,比如几小时------它防的不是暂停恢复,是"主 App 早就不在了"。

系统的硬上限。 Live Activity 最多活跃 8 小时,之后系统自动结束;结束后最多再在锁屏停留 4 小时。这意味着最坏情况有兜底:一个没人 end 的 Activity 不会永远挂在那里。

再加上第四节的对账------用户下次打开 App 的瞬间,.ended 分支跑,end(dismissalPolicy: .immediate) 把它清掉。三层合起来:正常情况零延迟(Darwin 通知),异常情况看起来不确定(staleDate),最坏情况有上限(8 小时)。

顺带两条 ActivityKit 的硬约束,不知道会莫名其妙失败:

  • ContentState 编码后不能超过 4KB。 别往里塞缩略图、别塞整个配置对象。录屏状态只需要 phase 和几个时间戳。
  • 用户可以在设置里关掉你的 Live Activity。 ActivityAuthorizationInfo().areActivitiesEnabled 要检查,关掉时 request 会抛错,而不是静默不显示。

六、坑四:岛上的"停止"按钮------方向反过来了

前面三节都是 Extension → 主 App → 系统。产品还要一个反方向的:用户在灵动岛上点"停止",录制要真的停。

iOS 17 起 Live Activity 支持交互按钮,走 App Intents。有一个关键事实决定了整个设计:LiveActivityIntentperform() 在你的主 App 进程里执行(不是 Widget Extension,也不是 Broadcast Extension)。系统会为此把主 App 拉起来或从挂起中唤醒。

这解决了第四节的一半问题------用户在岛上操作时,主 App 一定活着。但停止录制的动作在 Broadcast Extension 里,主 App 得把命令送过去:

swift 复制代码
// Widget Extension 里声明,主 App 进程里执行
struct StopRecordingIntent: LiveActivityIntent {
    static let title: LocalizedStringResource = "停止录制"
    func perform() async throws -> some IntentResult {
        SharedState.write(command: .stop)                                  // 命令落盘
        CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(),
            CFNotificationName("com.yourapp.recording.command" as CFString), nil, nil, true)
        return .result()
    }
}

Extension 收到后要自己结束自己。Broadcast Extension 结束录制的正规方式只有一个:

swift 复制代码
// Broadcast Extension
func handleStopCommand() {
    finalizeFile()                                                          // 先把 fMP4 收尾
    finishBroadcastWithError(RecordingStopped())                            // 唯一能主动结束的 API
}

struct RecordingStopped: LocalizedError {
    var errorDescription: String? { "录制已保存" }     // 系统会把它以弹窗展示给用户
}

finishBroadcastWithError 这个名字很误导------它是 Extension 唯一能主动结束广播的方法,没有"正常结束"的版本。系统会把这个 error 的描述以提示形式展示给用户,所以要给一个像"完成"而不像"出错"的文案。这是 ReplayKit 的老坑,但在"岛上停止"这个链路里它变成必经之路。

Extension 这边收 Darwin 通知没有主 App 那种挂起问题------录制进行中它必然活着。所以反方向的链路反而比正方向可靠:主 App 被系统保证唤醒,Extension 被录制本身保证活着。

结束后 Extension 写 .ended、敲通知,主 App 此时刚被 Intent 唤醒还活着,对账、end。整条链路闭环。


七、那一刀切在哪:职责表

把上面四节合起来,每个进程的职责变成一张不会互相踩的表:

Broadcast Extension 主 App Widget Extension
持有 录制状态的真相 Activity 句柄 无状态
写 App Group 状态(phase、时间戳) 命令(stop、pause) 不写
读 App Group 命令 状态 不读
Darwin 通知 发"状态变了"、收"有命令" 发"有命令"、收"状态变了" 不参与
ActivityKit 不 import request / update / end 只有 UI 和 Intent 声明
醒来时第一件事 --- 对账 ---

三条不可违反的规则:

  1. 单一真相。 录制状态只有 Extension 能写,主 App 只读;命令只有主 App 能写,Extension 只读。两个方向各一个文件,永远不会出现两个进程写同一个文件。第一篇里说过 UserDefaults(suiteName:) 跨进程不保证及时同步,这里也一样:文件 + 原子写。
  2. 通知不带状态。 Darwin 通知只有名字没有 payload,这不是限制,是设计------它逼你把真相放在文件里,让通知丢了也没关系。
  3. 主 App 的每个入口都对账。 收到通知对账、回前台对账、冷启动对账、Intent 执行后对账。对账函数是幂等的,多跑无害。

Activity<RecordingAttributes>.activities 这个属性是对账能成立的前提------主 App 被杀再启动后,内存里的 activity 引用没了,但系统还记得,从这里能找回来。


八、诚实对比:什么时候不该这么做

如果你有服务端,第三节和第四节的一大半可以用推送替代:主 App 上报 Activity 的 push token,Extension 直接调你的服务器,服务器通过 APNs 更新或结束 Live Activity。主 App 挂没挂起完全无所谓,控制中心启动的录制也能有灵动岛(push-to-start,iOS 17.2+)。这是更干净的架构,代价是服务端、证书、token 生命周期管理,以及网络不可用时的回退------回退方案就是本文这一套。所以即使走推送,本文的对账逻辑也不能省。

如果录制只能从 App 内启动(比如产品决定不支持控制中心入口),第三节的路径 B 不存在,事情简单很多。这个产品决定值得争取。

如果只是想显示"正在录制"而不需要控制,第六节整节不需要,iOS 16.1 就能跑。

如果你的 Extension 不是 Broadcast Upload Extension------比如 Notification Service Extension 想启动 Live Activity------限制是一样的(不支持的 target),但那种场景通常本来就该走推送,别照搬本文。


九、收尾:三条可以带走的

第一,"哪个进程有权限"和"哪个进程有状态"分开的时候,先接受它们不会合在一起。 不要找 workaround 让 Extension 拿到权限,也不要把状态搬进主 App 让它变成真相。让每个进程只做它被允许做的事,中间用一份共享的、单一 owner 的状态连起来。这条对所有 Extension 场景都成立:Share Extension 想发推送、Widget 想改数据库、Notification Extension 想启动 Live Activity------答案的形状都一样。

第二,跨进程通知只负责"叫醒",不负责"传话"。 Darwin 通知没有 payload 和队列,很多人觉得是缺陷。恰恰相反:它逼你把真相放在一个两边都能读的地方,让接收方用"对账"而不是"响应消息"来工作。对账是幂等的、不依赖顺序的、丢几次也不影响最终一致的。任何跨进程、跨设备、跨服务的状态同步,对账都比消息更抗折腾。

第三,更新不可靠的时候,把"需要更新"的时刻压到最少。 计时器交给系统(timerInterval),过期交给系统(staleDate),上限交给系统(8 小时)。你自己只处理真正的状态跳变。这个思路在任何"我控制不了更新时机"的 UI 上都成立------推送驱动的界面、Widget 的时间线、后台刷新的内容------先问"哪些能让系统自己推进",再设计更新。

回到开头那个"半天":真正的工作量不在 ActivityKit 的三个方法上,在于承认有权限的进程可能不活着,然后围绕这个事实设计每一条链路的降级。承认得越早,绕的弯越少。


如果你的 Live Activity 在某些用户那里"结束了还挂着",先别查 end 有没有调用------先查调用 end 的那个进程在那一刻是否在运行。十有八九,它被挂起了。


关于本文代码

文中 Swift 示例用于说明进程分工与对账思路,未逐行编译验证Activity.request 在 Extension 中的具体错误类型、Text(timerInterval:) 的可用参数、LiveActivityIntent 的执行进程、Live Activity 的时长上限(8 小时活跃 / 之后最多 4 小时停留)与 ContentState 的 4KB 限制,均转述自 Apple 公开文档与 WWDC 内容,具体行为以你的目标系统版本为准。RPSystemBroadcastPickerViewisCaptured 的时序是经验性描述,不是系统保证。文中没有任何产品内部数据。

相关推荐
终端安全笔记8 小时前
iOS 27 给了租赁一个新工具,但它只认受监督的设备
android·网络·安全·ios
大熊猫侯佩8 小时前
独立 App 首发完成 iPhone Duo 展开态适配
ios·swiftui·swift
光影少年9 小时前
setImmediate 和 setTimeout(0) 的区别
android·前端·react.js·ios·前端框架
2501_9160088910 小时前
SSE 和 gRPC 流式接口如何抓包,调试 SSE 与 gRPC 流式接口
网络协议·计算机网络·网络安全·ios·adb·https·udp
HouWan1 天前
Flutter: MediaQuery.of(context) 为什么可能拖慢页面?
android·flutter·ios
匠测AI说1 天前
XCUITest 自动化全解:同进程白盒、XCTest 架构、自动同步机制与 iOS 端选型指南
测试工具·ios·自动化
silianpan1 天前
CAD 文档预览 UTS 插件
android·ios·harmonyos
世纪末の魔术师1 天前
从 Healthory 到 Nature Aimanic:两款 iOS App 审核实战后,我总结了这份过审清单
ios·ios过审·苹果过审·ios应用过审