iOS15后台语音播报:用Notification Service Extension实现被杀后仍播报
简介面向 iOS 开发者的推送语音播报完整工程针对 iOS15 及之后系统在后台或进程被杀状态下无法自动播报推送内容的问题采用本地离线拼接音频加通知服务扩展的方式实现。方案不需要依赖在线语音合成成本更低同时规避了 iOS15 本地通知多次弹出并对金额数字转语音时的格式兼容做了专门处理。压缩包共包含八十八个文件大小约十九点九三兆其中有十八个语音素材、十四个头文件、十一个实现文件以及工程配置、界面描述、权限声明、依赖管理文件等覆盖主工程与推送扩展两个目标便于直接打开调试。已有五百二十六人浏览学习。通过该资源可掌握离线语音合成的完整链路理解通知扩展中音频播放与生命周期管理的关键细节还能复用金额转文字和通知去重等实用代码适合中级以上 iOS 开发者将消息推送语音播报快速落地到实际项目中。1. 后台语音播报在 iOS15 上的几个硬约束用户把 App 从后台划掉之后还想让推送消息的语音继续响起来这在 iOS15 上并不是件直接的事。UIKit 应用一旦进程被系统回收任何后台 Mode 都不会启动主程序唯一还能被系统拉起的是 Notification Service Extension。它的执行窗口只有大约 30 秒而且扩展进程与主 App 完全隔离不能依赖主工程 bundle 里的动态数据。很多开发者第一反应是在音频后台模式里播报但那样的设计在锁屏和被杀状态下根本不可靠。这个工程用极光推送加通知服务扩展完成了语音播报核心做法是把播报文案拆成本地音频片段在扩展进程里拼接成完整语音文件再以通知声音的方式交给系统播放。下文按方案选型、核心实现、极光推送配置、真机验证四部分展开。2. 方案选型Service Extension 与本地拼接如何绕过后台限制2.1 三十分钟执行窗口里到底能做什么Notification Service Extension 是 iOS 10 引入的机制iOS15 之后仍然沿用。当一条远程通知携带mutable-content: 1到达设备时系统并不直接展示通知而是先拉起扩展进程调用didReceiveNotificationRequest:withContentHandler:。这个方法的文档要求开发者尽快调用contentHandler虽不同系统版本给的实际执行时间不一样但业界普遍按 30 秒做兜底。超过这个时间扩展会被系统终止通知退回原始内容。在这 30 秒里扩展可以做几类事情修改通知标题和正文、添加附件资源、设置通知声音、修改线程 ID。但它不能启动主 App 的任意代码也不能注册推送设备 token。所以针对语音播报正确的思路是在扩展进程里完成所有音频准备工作再把结果体现在UNNotificationContent对象里。这个工程里就是这么设计的KNNotificationServiceExtension4Voice负责读取自定义字段里的金额Utils负责生成音频文件最终修改sound字段。因为执行时间有限任何网络请求、语音合成、大文件 I/O 都要慎用。极光推送的扩展 target 里没有放入 JPush SDK也是因为扩展不参与推送注册没必要引入额外开销。主 App 负责把预置的语音片段写到 App Group 共享容器扩展进程直接从同一路径读取两者职责非常清楚。2.2 为什么不用 TTS 和 AVSpeechSynthesizer离线语音播报有三个常见选型服务端合成音频 URL、iOS 自带的 AVSpeechSynthesizer、本地拼接音频。三者的差异直接决定 30 秒内能不能完成播报可以看下面这个对比。方案完成后执行耗时依赖条件音质可定制性服务端合成 URL需要下载受网络波动影响服务端必须可访问、推送带 URL高AVSpeechSynthesizer初始化加合成通常 2-5 秒系统声音库无额外资源低无法换录音本地片段拼接百毫秒级预置音频文件无网络高完全可控我在调试早期尝试过 AVSpeechSynthesizer在 iPhone 11 上跑第一次合成十位数的金额要接近 3 秒加上后续写入文件操作稍复杂就会逼近 30 秒边缘。本地拼接最大的好处是可控所有片段都已经放在预先准备好的音频目录里拼接过程只是读文件、写文件几乎不受 CPU 调制影响。唯一的代价是数字和单位的片段数量有限但覆盖金额播报场景已经足够。服务端合成虽然音质更好可一旦推送量上来音频文件的下载会成倍增加推送服务商的带宽成本所以在离线场景里并不划算。2.3 工程里各模块职责划分打开 KNVoiceBroadcast4iOS15-2.0.xcworkspace 可以看到两个 target主 App 的 KNVoiceBroadcast 和扩展的 KNNotificationServiceExtension4Voice。主 App 里AppDelegate负责用极光推送注册设备 tokenViewController只是一个演示页面SceneDelegate处理生命周期真正的业务工具都在Utils目录。Utils主要封装了两类能力一是金额到中文念法的转换二是将念法映射到具体的音频片段文件并串成完整语音。扩展 target 里的NotificationService.m是这个方案的核心它拿到推送里自定义字段amount后调用Utils的方法生成音频文件再修改通知内容。Pods目录里看到的 JPush 和 JCore 是极光推送的 SDK它们只服务于主 App。扩展 target 不依赖任何第三方库避免增加扩展体积和启动时间。KNVoiceBroadcast.entitlements和KNNotificationServiceExtension4Voice.entitlements分别配置了 App Group主要是为了让两个 target 共享预置音频资源和生成缓存目录。如果只把音频放在主 App bundle 里扩展进程是无法直接读取的这一步在实际配置时非常容易漏掉。3. 核心实现音频文件拼接与金额转中文的兼容处理3.1 音频片段命名与拼接流程本地拼接要稳定音频资源必须先做统一化处理。我在工程里把 0-9、十、百、千、万、元、角、分、整全部做成 44.1kHz 单声道 m4a 文件文件名按digit_0.m4a、unit_yuan.m4a这样的规则命名。这样做的好处是金额转成中文念法后可以按字符直接映射到文件名。拼接时使用AVAudioFile按顺序写文件下面这一段是核心写入逻辑。- (BOOL)appendAudioFile:(AVAudioFile *)source toTarget:(AVAudioFile *)target error:(NSError **)error { AVAudioPCMBuffer *buffer [[AVAudioPCMBuffer alloc] initWithPCMFormat:source.processingFormat frameCapacity:(AVAudioFrameCount)source.length]; NSError *readError nil; BOOL ok [source readIntoBuffer:buffer error:readError]; if (!ok || readError) { if (error) *error readError; return NO; } NSError *writeError nil; ok [target writeFromBuffer:buffer error:writeError]; if (error) *error writeError; return ok; }这里的source.processingFormat必须和target的写入格式一致否则writeFromBuffer:会直接抛错。实际工程里我会在生成所有音频片段的时候统一转成AVAudioPCMFormatFloat32、44.1kHz、单声道这样读取后 buffer 的 format 就是固定的。如果你在拼接出来的语音里听到变调或爆音大概率是某一段音频的采样率不是 44.1kHz或者声道数不是 1。处理时可以先删除该片段再用音频工具重新转一次。拼接完整音频文件的入口方法如下它把中文念法映射成语义片段列表然后逐个写入- (NSString *)createVoiceFileForSpeechText:(NSString *)speechText { NSArrayNSString * *fragmentNames [self audioFragmentNamesForText:speechText]; if (fragmentNames.count 0) { return nil; } NSString *dir [NSHomeDirectory() stringByAppendingPathComponent:Library/Sounds]; [[NSFileManager defaultManager] createDirectoryAtPath:dir withIntermediateDirectories:YES attributes:{ NSFileProtectionKey: NSFileProtectionNone } error:nil]; NSString *filePath [dir stringByAppendingPathComponent:voice_alert.m4a]; [[NSFileManager defaultManager] removeItemAtPath:filePath error:nil]; NSURL *targetURL [NSURL fileURLWithPath:filePath]; AVAudioFormat *format [[AVAudioFormat alloc] initWithCommonFormat:AVAudioPCMFormatFloat32 sampleRate:44100 channels:1 interleaved:NO]; AVAudioFile *writer [[AVAudioFile alloc] initForWriting:targetURL settings:format.settings error:nil]; for (NSString *name in fragmentNames) { NSURL *fragmentURL [self urlForAudioFragment:name]; AVAudioFile *source [[AVAudioFile alloc] initForReading:fragmentURL error:nil]; if (source) { [self appendAudioFile:source toTarget:writer error:nil]; } } [writer close]; NSDictionary *attrs { NSFileProtectionKey: NSFileProtectionNone }; [[NSFileManager defaultManager] setAttributes:attrs ofItemAtPath:filePath error:nil]; return filePath; }参数说明speechText是经过金额转换后的中文文本例如“一百二十三元四角五分”audioFragmentNamesForText:负责把文本转换成有序的音频文件名NSFileProtectionNone是为了防止设备锁屏时通知服务进程无法读取该文件。format.settings也可以换成{ AVFormatIDKey: (kAudioFormatMPEG4AAC) }但那样需要在 writer 创建后做一次processingFormat检查更容易出错。所以我更倾向用 Float32 格式输出后再由系统转码实际使用中生成一个几百 KB 的 m4a 文件完全可接受。3.2 NSNumberFormatter 的金额格式化细节把金额字段比如1234.56转成“一千二百三十四元五角六分”并不是简单的数字拼写。iOS 15 的NSNumberFormatter在spellOut模式下对中文数字的处理有变动直接转换会出现“一千二百三十四点五六”这类不适合播报的输出。因此需要拆成整数和小数分别处理- (NSString *)speechTextFromAmount:(NSString *)amountString { NSDecimalNumber *amount [NSDecimalNumber decimalNumberWithString:amountString]; NSDecimalNumberHandler *roundDown [NSDecimalNumberHandler decimalNumberHandlerWithRoundingMode:NSRoundDown scale:0 raiseOnExactness:NO raiseOnOverflow:NO raiseOnUnderflow:NO raiseOnDivideByZero:NO]; NSDecimalNumber *integerPart [amount decimalNumberByRoundingAccordingToBehavior:roundDown]; NSNumberFormatter *formatter [[NSNumberFormatter alloc] init]; formatter.numberStyle NSNumberFormatterSpellOutStyle; formatter.locale [NSLocale localeWithLocaleIdentifier:zh_CN]; NSString *integerText [formatter stringFromNumber:integerPart]; NSDecimalNumber *fraction [amount decimalNumberBySubtracting:integerPart]; NSDecimalNumber *fractionInHundreds [fraction decimalNumberByMultiplyingByPowerOf10:2]; NSInteger hundred [fractionInHundreds integerValue]; NSInteger jiao hundred / 10; NSInteger fen hundred % 10; NSMutableString *speech [NSMutableString string]; [speech appendString:integerText]; [speech appendString:元]; if (jiao 0) { [speech appendString:[self chineseNumber:jiao]]; [speech appendString:角]; } if (fen 0) { [speech appendString:[self chineseNumber:fen]]; [speech appendString:分]; } if (jiao 0 fen 0) { [speech appendString:整]; } return speech; }这个方法的重点是NSRoundDown行为它保证小数部分不会被四舍五入带入整数位。比如金额999.99如果直接用默认的舍入方式整数部分可能输出“一千”和原金额不一致。decimalNumberByMultiplyingByPowerOf10:2把小数部分转成两位整数再分别取出角和分这样1234.56会得到一百之数为 56jiao 为 5fen 为 6。chineseNumber:是简单的 0-9 中文映射这里只处理一位数字因为角和分都不可能超过 9。如果你需要支持亿元等大单位可以在integerText后面继续追加但要注意spellOut对100000000的输出是“一亿”而不是“壹亿”音频片段必须包含这两个变体。3.3 设置绝对路径的通知声音触发播报音频文件生成后扩展里通过修改UNNotificationContent的sound属性来控制播放。为了在 App 被杀后仍然播放必须把声音文件放在扩展能访问的沙盒目录并设置成系统可以读取的格式UNMutableNotificationContent *content [request.content mutableCopy]; NSString *voicePath [self createVoiceFileForSpeechText:speechText]; if (voicePath) { content.sound [UNNotificationSound soundNamed:voicePath]; } self.contentHandler(content);这里的soundNamed:传入的是完整文件路径在 Notification Service Extension 中mainBundle是只读的动态生成的文件不可能先放进 bundle所以必须使用绝对路径。这也是很多资料没有讲清楚的地方网上大量示例直接传文件名真机调试时十有八九没有声音。设置完成后系统会在展示通知时播放该音频即使扩展进程已经结束也不会影响播放。这套机制就是“后台/被杀状态仍可播报”的根基。4. 极光推送接入与扩展配置让通知服务真正跑起来4.1 Podfile 依赖与两个 target 的配置工程使用 CocoaPods 管理依赖Podfile里必须显式区分两个 target。极光推送 SDK 只给主 App 用扩展 target 只需要系统框架。下面是一个可用的 Podfile 示例platform :ios, 15.0 target KNVoiceBroadcast do use_frameworks! pod JPush pod JCore end target KNNotificationServiceExtension4Voice do use_frameworks! endplatform :ios, 15.0可以保证两个 target 都只依赖 iOS 15 以上的 API比如UNNotificationSound的绝对路径行为。极光推送 SDK 在 pod install 时会把 JPush 和 JCore 编译进主 App target 的产物中扩展 target 如果不显式忽略CocoaPods 也会自动把依赖链接进去但那会明显增大扩展的体积而且极光 SDK 在扩展环境里无法完成注册属于多余依赖。真机调试时如果发现扩展启动很慢可以先检查 Pods 目录里是否有多余的三方库。4.2 entitlements 与 App Group 配置两个 target 的 entitlements 文件都要配置 App Group。KNVoiceBroadcast.entitlements和KNNotificationServiceExtension4Voice.entitlements里均包含如下键值keycom.apple.security.application-groups/key array stringgroup.cn.example.cashvoice/string /arrayApp Group 的 ID 必须是开发账号下已经注册过的不能随意改。工程里把音频资源放到 App Group 共享容器扩展进程从同一个 group 路径读取这样可以避免把同一段音频复制两份。如果两个 target 的 App Group 不一致扩展运行时会抛NSExtensionErrorMissingConstraints这类错误日志里能看到extension request contains no app group。另外主 App 首次启动时要负责把预置音频写入 group container扩展每次拼接前要检查文件是否存在不存在就从主 App 的资源目录复制过来。4.3 推送 payload 中 mutable-content 与自定义字段使用极光推送控制台发送推送时payload 必须包含mutable-content: 1否则系统不会调用扩展。除了标准aps字段业务字段可以直接放在userInfo顶层。下面是控制台自定义 JSON 示例{ aps: { alert: 您有一笔新的到账, sound: default, mutable-content: 1, interruption-level: active }, amount: 1234.56 }这里的amount就是扩展读取的播报内容interruption-level是 iOS15 加入的字段可选passive、active、timeSensitive、critical。critical需要额外的系统授权这里用active就能保证通知横幅展示并触发声音。mutable-content必须写成 1不能是 true否则系统 JSON 解析会失败。调试时可以在极光推送控制台的“推送通知”页面填写也可以使用 REST API 发送。如果扩展一直没有执行优先检查这个字段是否被后台正确转换成整型。5. 从杀进程到真机验证修复通知栏多次弹出的问题5.1 用 xcrun simctl 和 Charles 验证推送链路真机测试前可以在模拟器上先用xcrun simctl push验证扩展逻辑是否工作。构造一个 payload.json 文件里面写上面的 JSON然后执行xcrun simctl push booted com.example.KNVoiceBroadcast payload.json注意这里的 bundle ID 要改成你的主 App bundle ID并且模拟器需要 iOS 15。这个命令会直接模拟远程通知到系统通知服务能触发 Service Extension 的代码路径方便快速调试金额转换和音频拼接逻辑。验证完扩展逻辑后再用真机加 Charles 抓取 iOS 的包确认极光推送的请求确实带着mutable-content: 1到达设备。抓包时要关注aps.mutable-content和顶层自定义字段是否存在因为极光后台如果对部分字段做了类型转换可能把 1 转成字符串那样扩展仍不会触发。我实际遇到过控制台把mutable-content处理成字符串导致静默失败的情况换成 REST API 发送就好了。5.2 iOS15 重复出示通知的规避方案iOS15 之后本地通知和远程通知如果都指定了threadIdentifier可能会在通知中心以相同线程折叠如果没有指定就可能在横幅和通知中心分别出现。这个工程里遇到的现象是“通知栏弹出多次”主要原因不是系统重复推送而是扩展内既调用了contentHandler又保留了默认声音default导致系统一边播放默认提示音一边又播放自定义语音听起来像响了两次。解决方案是在修改通知内容时把原始 sound 清零只使用自定义语音content.sound nil; if (voicePath) { content.sound [UNNotificationSound soundNamed:voicePath]; }另外为每类业务通知设置同一个threadIdentifier能让 iOS15 把它们折叠成一组减少通知栏的视觉重复。5.3 不同生命周期状态下的行为差异对比在真机上可以从三种状态下发送推送观察行为是否一致。状态Service Extension语音播放通知栏展示前台运行会执行播放自定义声音横幅/通知中心按代理决定后台挂起会执行播放自定义声音通知中心进程被杀会执行播放自定义声音通知中心需要说明的是进程被杀后系统会单纯为了处理通知把扩展进程拉起来这个拉起过程是独立于主 App 的所以不需要主进程存活。验证时最直接的方式是在 App 内写一段日志输出到系统日志然后用 Xcode 的 Device 日志窗口查看。如果日志显示didReceiveNotificationRequest:被调用就说明链路是通的。最后还有一个检查项Library/Sounds下的音频文件必须设置NSFileProtectionNone否则设备锁屏状态下系统读取文件会被文件保护策略拦截表现为锁屏时有横幅但没声音。这一步在真机锁屏测试时往往是最后的隐藏坑。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →