☰
iOS15消息推送语音播报:后台与杀进程状态下的实现方案
2026/10/1 13:33:33 网站建设 项目流程

简介:这是一份面向 iOS 开发者的消息推送语音播报实战资源,聚焦 iOS15 及之后系统在 App 处于后台甚至被杀死状态下仍能完成语音播报的完整实现方案。资源以本地离线音频拼接替代成本较高的在线合成,并针对 iOS15 本地通知通知栏重复展示、数字金额转语音的 numFormatter 兼容处理等细节给出可复用思路,适合已掌握推送基础、希望深入 Service Extension 与音频处理的中高级开发者。压缩包共 88 个文件,约 19.93MB,包含 18 个 mp3 音频素材、14 个 h 与 11 个 m 源码文件,以及 plist、xcconfig、xcscheme、storyboard、entitlements、Podfile 等工程配置,另有 png 图示与 markdown 说明,目录结构完整可直接编译运行。目前已有 526 人学习下载,读者可据此掌握离线音频拼接、通知去重与数字格式化播报的落地方法,并对照工程配置快速排查推送扩展接入中的常见问题。

1. iOS15 消息推送语音播报:后台与杀进程状态下也能出声的工程做法

iOS15 之后,系统对后台任务和进程生命周期的收紧让很多做消息推送语音播报的同学踩了坑:App 在前台时播报一切正常,切到后台声音就断,进程被用户上滑杀死后更是彻底哑火。这个标题要解决的就是这件事——在 iOS15 环境下,让收到远程推送时无论 App 处于前台、后台还是已被杀死,都能触发一段语音播报。它适合做即时通讯、订单提醒、告警通知、外卖接单这类需要「听到就知道有事」的从业者,也适合正在被「后台保活」玄学折磨的 iOS 开发。核心难点不在播放本身,而在于进程被系统回收后,谁来唤醒你的代码、谁来持有播放器、谁来申请那几秒的音频执行时间。下面按选型、落地、排错、进阶四段推进,把能抄的配置和参数都摊开。

2. 先搞清楚 iOS15 推送唤醒的三条路径:前台、后台、被杀

2.1 三种进程状态对应的系统行为差异

要落地语音播报,先得接受一个事实:iOS 不会因为你想要播报就给你常驻后台。进程状态决定了你能拿到多少执行时间,也决定了用哪套 API。

前台状态最简单,UNUserNotificationCenterDelegate的willPresent回调会触发,你直接调播放器即可,没有任何时间限制。后台状态(App 在后台但进程还活着)走的是静默推送或普通推送,系统会唤醒进程执行didReceiveRemoteNotification,但给的时间窗口很短,通常只有几十秒,且受低电量模式、后台刷新开关影响。被杀状态(进程被系统或用户终止)最麻烦,普通推送只会由系统直接展示横幅,不会执行你的任何代码,唯一能唤醒进程的是静默推送配合content-available: 1,而且唤醒后你只有极短的执行时间,必须在这段时间内启动音频会话并出声。

进程状态触发回调可用执行时间能否直接播报
前台willPresent无限制可以
后台存活didReceiveRemoteNotification约 30 秒可以,需音频会话
被杀didReceiveRemoteNotification(静默推送唤醒)约 30 秒,且不保证可以,但需抢时间

这张表是后面所有配置的依据。很多人翻车就翻在把被杀状态当成后台状态处理,以为普通推送也能触发代码,结果测试时永远没声音。

2.2 为什么必须用静默推送 + 本地通知的组合

普通远程推送(带alert)在 App 被杀时由系统直接渲染,你的代码根本没机会跑。所以语音播报的链路必须拆成两段:第一段用静默推送把进程唤醒,第二段在唤醒后的代码里播放语音,同时用UNNotificationRequest补一条本地通知,保证用户锁屏时也能看到横幅。

这里有个反直觉的点:静默推送本身不保证送达。系统会根据设备电量、网络、App 使用频率决定是否投递,content-available: 1的推送在低电量模式下可能被直接丢弃。所以工程上不能只依赖静默推送,常见做法是服务端同时下发一条普通推送和一条静默推送,普通推送保证用户能看到,静默推送争取唤醒进程播报。两条推送用同一个collapse-id关联,避免重复展示。

选型上,播放器建议用AVSpeechSynthesizer做 TTS 播报,或者用AVAudioPlayer播放预置音频。前者适合内容动态变化的场景(比如订单金额、客户姓名),后者适合固定提示音。两者都需要在唤醒后立刻激活AVAudioSession,否则声音会被系统静音或路由到错误通道。

3. 从推送配置到语音播报的最小可运行链路

3.1 服务端推送 payload 的正确写法

先看服务端要下发的 JSON。这是整条链路的起点,字段写错后面全白搭。

{ "aps": { "alert": { "title": "新订单", "body": "您有一笔新的外卖订单,请及时处理" }, "sound": "default", "badge": 1, "content-available": 1, "mutable-content": 1, "category": "ORDER_ALERT" }, "orderId": "20240521001", "speakText": "您有一笔新的外卖订单,金额三十八元" }

content-available: 1是唤醒被杀进程的关键,缺了它系统不会调用didReceiveRemoteNotification。mutable-content: 1允许你在通知展示前用 Notification Service Extension 修改内容,如果播报文案需要动态拼接就加上。speakText是自定义字段,服务端把要播报的文本直接传下来,客户端不用再请求接口,省掉一次网络往返,这在只有几十秒执行窗口的场景里很关键。

注意alert和content-available同时存在时,系统可能优先展示通知而不唤醒进程。稳妥做法是拆成两条推送:一条带alert负责展示,一条只带content-available负责唤醒,用apns-collapse-id关联。

3.2 AppDelegate 里注册与回调的完整代码

iOS15 推荐用UNUserNotificationCenter,AppDelegate负责注册和接收静默推送回调。

import UIKit import UserNotifications import AVFoundation @main class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate { let synthesizer = AVSpeechSynthesizer() func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { let center = UNUserNotificationCenter.current() center.delegate = self center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in print("授权结果: \(granted), 错误: \(String(describing: error))") } application.registerForRemoteNotifications() configureAudioSession() return true } func configureAudioSession() { do { let session = AVAudioSession.sharedInstance() try session.setCategory(.playback, mode: .spokenAudio, options: [.duckOthers, .interruptSpokenAudioAndMixWithOthers]) try session.setActive(true, options: .notifyOthersOnDeactivation) } catch { print("音频会话配置失败: \(error)") } } // 前台收到通知 func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { handleSpeak(from: notification.request.content.userInfo) completionHandler([.banner, .sound, .badge]) } // 后台/被杀状态收到静默推送 func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { handleSpeak(from: userInfo) completionHandler(.newData) } func handleSpeak(from userInfo: [AnyHashable: Any]) { guard let text = userInfo["speakText"] as? String, !text.isEmpty else { return } configureAudioSession() let utterance = AVSpeechUtterance(string: text) utterance.voice = AVSpeechSynthesisVoice(language: "zh-CN") utterance.rate = 0.5 utterance.volume = 1.0 synthesizer.speak(utterance) } }

configureAudioSession里.playback分类保证声音从扬声器出,.spokenAudio模式针对语音优化,.duckOthers让播报时其他音频自动降低音量。utterance.rate设 0.5 是中文播报比较自然的语速,太快听不清,太慢用户着急。handleSpeak里每次都重新激活音频会话,因为进程被杀后会话状态会丢失,不重新激活可能没声音。

3.3 被杀状态下抢时间的三个参数

被杀状态唤醒后,系统给的时间非常有限,下面三个参数直接决定播报能不能完成。

第一个是completionHandler(.newData)的调用时机。它告诉系统你处理完了,调用太早系统可能立刻挂起进程导致播报中断,调用太晚会被系统判定超时。常见做法是在synthesizer.speak之后延迟 1 到 2 秒再调用,给音频启动留时间。

第二个是AVAudioSession的setActive超时。默认激活可能耗时几百毫秒,在冷启动场景下更久。可以在setActive前先判断session.isOtherAudioPlaying,避免不必要的等待。

第三个是推送的apns-priority。静默推送必须设为 5,设为 10 会被系统当作普通推送处理,可能不唤醒进程。服务端下发时这个头字段别漏。

# APNs 推送头字段示例(HTTP/2 接口) apns-priority: 5 apns-push-type: background apns-topic: com.yourcompany.yourapp apns-collapse-id: order-20240521001

apns-push-type: background配合apns-priority: 5才是合法的静默推送组合,缺一个系统都可能拒收或降级处理。

4. 语音播报在真机上的避坑与排查清单

4.1 后台没声音,先查音频会话分类

现象:App 切后台后收到推送,通知横幅正常,但没有任何声音。

原因:AVAudioSession分类设成了.ambient或没设置,系统默认遵循静音开关,静音键一拨就没声。或者会话在进入后台时被系统自动停用,唤醒后没有重新激活。

解决:分类必须用.playback,它不受静音开关影响。每次播报前调用setActive(true),不要只在启动时激活一次。可以在handleSpeak里加日志打印session.category和session.isActive,确认状态。

4.2 被杀进程收不到静默推送

现象:App 在前台和后台都能播报,一旦上滑杀死就彻底没反应。

原因:静默推送被系统限流。iOS 会根据 App 的使用频率、电量、网络状况决定是否投递content-available推送,长期不打开的 App 优先级极低。另外低电量模式下静默推送基本不投递。

解决:服务端同时下发普通推送和静默推送,普通推送保证用户看到,静默推送争取唤醒。测试时关闭低电量模式,且保证 App 在最近几天被主动打开过。真机上可以用 Xcode 的 Devices 窗口手动触发推送验证代码路径,排除服务端问题。

4.3 播报被截断或只播一半

现象:语音播报开头正常,播到一半突然停。

原因:completionHandler调用太早,系统认为任务完成就挂起了进程。或者播报文本太长,超过了系统给的后台执行时间。

解决:把completionHandler延迟到播报开始后 1 到 2 秒再调,不要一进回调就调。播报文本控制在 30 字以内,长内容拆成多条短播报。如果确实需要长播报,考虑用 Notification Service Extension 在通知展示阶段处理,但 Extension 的时间窗口同样有限。

4.4 中文播报发音奇怪或没声音

现象:英文播报正常,中文播报要么没声,要么读成英文发音。

原因:AVSpeechUtterance的voice没指定中文,系统用了默认语音。或者设备没下载中文语音包。

解决:显式设置utterance.voice = AVSpeechSynthesisVoice(language: "zh-CN")。如果返回 nil,说明设备缺少中文语音,可以在设置里引导用户下载,或者降级用预置音频文件播放。测试时换几台不同系统版本的设备,iOS15 各小版本对语音包的处理有差异。

4.5 多条推送同时到达时播报重叠

现象:短时间内收到多条推送,语音叠在一起听不清。

原因:AVSpeechSynthesizer默认会排队播报,但如果每次回调都新建实例,就会并行播放。

解决:synthesizer用单例,全局只创建一个。收到新播报前先判断synthesizer.isSpeaking,如果正在播报,可以选择stopSpeaking(at: .immediate)打断后播新的,或者把新文本加入队列。业务上建议用队列,避免漏掉重要通知。

5. 进阶:用 Notification Service Extension 提升播报成功率

前面讲的方案在大部分场景够用,但如果你对播报成功率要求极高,比如外卖接单、告警系统,可以再加一层 Notification Service Extension。它的价值在于:普通推送带mutable-content: 1时,系统会先启动 Extension 让你修改通知内容,这个启动过程比静默推送唤醒主进程更可靠,且 Extension 有独立的执行时间。

具体做法是在 Extension 的didReceive回调里解析speakText,用AVSpeechSynthesizer播报,同时修改通知内容。Extension 和主 App 不共享进程,音频会话要单独配置。注意 Extension 的内存限制比主 App 严格,大约 24MB,播放器不要做太重的事情。

// NotificationService.swift import UserNotifications import AVFoundation class NotificationService: UNNotificationServiceExtension { var contentHandler: ((UNNotificationContent) -> Void)? var bestAttemptContent: UNMutableNotificationContent? let synthesizer = AVSpeechSynthesizer() override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { self.contentHandler = contentHandler bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent if let text = request.content.userInfo["speakText"] as? String { do { let session = AVAudioSession.sharedInstance() try session.setCategory(.playback, mode: .spokenAudio, options: [.duckOthers]) try session.setActive(true) let utterance = AVSpeechUtterance(string: text) utterance.voice = AVSpeechSynthesisVoice(language: "zh-CN") utterance.rate = 0.5 synthesizer.speak(utterance) } catch { print("Extension 音频配置失败: \(error)") } } if let content = bestAttemptContent { contentHandler(content) } } override func serviceExtensionTimeWillExpire() { if let contentHandler = contentHandler, let content = bestAttemptContent { contentHandler(content) } } }

Extension 的serviceExtensionTimeWillExpire是系统给的最后期限,大约 30 秒,超时前必须调用contentHandler,否则通知会被丢弃。播报和回调之间要留足时间,建议播报启动后立刻回调,不要等播报结束。

验证方法很简单:在 Xcode 里选中 Extension scheme,用真机跑,然后从服务端发一条带mutable-content: 1的推送,看 Extension 的日志有没有打印。如果 Extension 没启动,检查推送头里的apns-push-type是不是alert,mutable-content是不是 1。

我自己的习惯是:主 App 的静默推送链路和 Extension 链路同时保留,服务端根据业务优先级决定发哪条。高优先级走 Extension,普通优先级走静默推送。两条链路都加日志上报,上线后看哪条成功率高,再调整策略。这个方案我踩过最深的坑是 Extension 里播放器实例没释放,连续几条推送后内存爆掉,后来改成播报完手动置空才稳定。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询