iOS开发基础151-后台语音提示实现
iOS 后台语音提示实现指南:从审核要求到普通办公App的最小代价落地方案
前言
在做办公类 App 时,我们经常遇到这样的需求:用户收到审批、会议、公告等推送时,希望能播放一段语音提示,而不只是默认的"叮"一声。很多开发者第一反应是"我要搞后台播放",然后去声明 UIBackgroundModes = audio,结果要么审核被拒,要么实现复杂还不稳定。
这篇文章会系统梳理三个问题:
- iOS 实现后台播放语音到底需要哪些条件?
- 苹果审核对后台音频和推送声音有哪些硬性要求?
- 一个普通办公 App(无即时通信),用最小代价实现"收到推送时播放短语音",具体怎么做?
一、iOS 后台播放语音的实现条件
1.1 三个核心必要条件
要让 App 在进入后台后继续播放音频,必须同时满足以下三点:
(1)Info.plist 声明后台模式
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
这是最关键的前提。没有这个声明,App 一退到后台,音频会被系统立即暂停。
(2)配置 AVAudioSession
AVAudioSession *session = [AVAudioSession sharedInstance];
NSError *error = nil;
if (@available(iOS 17.0, *)) {
[session setCategory:AVAudioSessionCategoryPlayback
mode:AVAudioSessionModeVoicePrompt
options:AVAudioSessionCategoryOptionMixWithOthers
error:&error];
} else {
[session setCategory:AVAudioSessionCategoryPlayback
mode:AVAudioSessionModeSpokenAudio
options:AVAudioSessionCategoryOptionMixWithOthers
error:&error];
}
[session setActive:YES error:&error];
| 字段 | 说明 |
|---|---|
category |
必须用 AVAudioSessionCategoryPlayback(纯播放)或 AVAudioSessionCategoryPlayAndRecord(同时需要麦克风) |
mode |
iOS 17+ 推荐 AVAudioSessionModeVoicePrompt,专门为语音提示优化;低版本用 AVAudioSessionModeSpokenAudio |
options |
MixWithOthers 允许与其他 App 音频共存;导航类可加 DuckOthers 压低其他音量 |
(3)使用正确的播放引擎
- AVAudioPlayer:播放本地音频文件,最稳定,后台支持最好
- AVPlayer:支持流式和本地,适合较长音频
- AVSpeechSynthesizer:TTS 文字转语音。iOS 17+ 配合
VoicePrompt模式可后台播报;低版本后台经常被打断,建议先转成音频文件再用 AVAudioPlayer 播放
1.2 触发时机的限制(最容易踩坑)
后台播放不是"想播就能播",必须满足触发时机合法:
| 触发方式 | 是否可行 | 说明 |
|---|---|---|
| 前台开始播放,退后台继续播 | ✅ 可以 | 最标准的场景 |
| 后台通过 Timer 定时触发新播放 | ❌ 不行 | 音频停止后 Timer 会被系统挂起,无法唤醒播放(仅在音频持续播放期间可短暂用 Timer 切歌) |
| 远程控制事件(锁屏/控制中心) | ✅ 可以 | 需实现 MPRemoteCommandCenter |
| 推送通知触发 | ⚠️ 有限制 | 需用 Notification Service Extension,有约 30 秒处理时间 |
| 位置更新触发(导航类) | ✅ 可以 | 同时声明 location 后台模式 |
| 蓝牙设备事件触发 | ✅ 可以 | 如蓝牙耳机按键 |
核心原则:App 在后台不能主动唤醒自己去播新音频,只能在已有音频会话持续期间连续播放,或通过系统合法事件(推送、位置、蓝牙、远程控制)触发。
1.3 iOS 17+ 的语音提示专项优化
iOS 17 引入了 AVAudioSessionModeVoicePrompt,专门针对导航、运动、提醒类 App:
- 系统自动优化语音清晰度
- 与 Siri、导航等语音提示共存时行为更合理
- 降低系统判定为"不必要后台音频"的风险
二、苹果审核的硬性要求
2.1 后台音频模式的审核门槛
App Store Review Guidelines 2.5.4 明确规定:
Multitasking apps may only use background services for their intended purposes: VoIP, audio playback, location, task completion, local notifications, etc.
这意味着:
- 声明了
audio后台模式的 App,音频播放必须是 App 的核心功能之一 - 普通办公 App(文档、审批、日程等)如果声明
audio后台模式,大概率会被拒 - 常见被拒理由:
2.5.4 - We found that your app declares support for audio in the UIBackgroundModes key in your Info.plist, but the audio features are not the primary functionality of the app.
音乐、播客、导航、运动教练这类 App 声明 audio 模式没问题,但一个审批办公 App 去声明,审核员会直接质疑。
2.2 推送通知的审核要求(Guideline 4.5.4)
- 推送必须通过
UNUserNotificationCenter请求用户授权,用户同意后才能发送 - 推送不能纯用于广告/营销(除非用户明确勾选同意)
- 推送内容必须与 App 功能相关
- 推送自带声音是完全合规的,这是系统标准能力,不需要额外后台权限
2.3 Notification Service Extension 的审核边界
- 扩展只能用于修改推送内容(标题、正文、附件、声音),不能做与推送无关的事
- 扩展运行时间有限(约 30 秒),超时后系统直接展示原推送
- 扩展中不要用 AVAudioPlayer 主动播放音频——扩展生命周期极短,音频会话可能在通知展示前就被终止,标准做法是修改
UNNotificationSound让系统通知引擎播放 - 扩展能做的是:修改
UNNotificationSound,让系统通知机制去播放声音
2.4 Critical Alerts(紧急提醒)
- 属于特殊权限,需要单独向苹果申请(entitlement:
com.apple.developer.usernotifications.critical-alerts) - 仅限医疗、安全、公共服务等场景,普通办公 App 几乎不可能通过
- 可以绕过静音模式和勿扰模式播放声音
三、普通办公 App 的最小代价方案
3.1 核心思路:用推送通知自带声音,不声明任何后台模式
对于普通办公 App,完全不需要声明 audio 后台模式。推送通知本身就支持播放自定义声音,这是系统标准能力,走的是系统通知通道,即使 App 被完全杀掉也能播放。
具体有两种实现方案:
- 方案 A:预置多声音 + 服务端选择——所有语音文件打包进 App,推送时指定播哪个
- 方案 B:Notification Service Extension 下载声音——推送到达时扩展临时下载语音文件再播放
3.2 推送声音的通用约束(两种方案都适用)
| 约束项 | 说明 |
|---|---|
| 支持编码 | Linear PCM、IMA4 (ADPCM)、µLaw、aLaw |
| 支持容器 | .aiff、.wav、.caf |
| 最大时长 | 30 秒,超过会被系统静默替换为默认三全音 |
| 用户侧限制 | 用户在系统设置中关闭该 App 通知声音、或开启静音/专注模式时,自定义声音不会响(Critical Alerts 除外) |
| 文件查找顺序 | 系统依次查找:App bundle 根目录 → bundle/Library/Sounds → App Group/Library/Sounds |
macOS 终端转换命令:
afconvert input.mp3 output.caf -d ima4 -f caff -v
四、方案 A:预置多声音 + 服务端选择
4.1 原理
所有可能用到的语音文件提前打包进 App bundle,服务端发推送时在 sound 字段指定文件名,系统收到推送后直接读取 bundle 中的文件播放。
App bundle:
├── leave_apply.caf ← 服务端推送 sound 字段写 "leave_apply.caf"
├── reimburse.caf ← 服务端推送 sound 字段写 "reimburse.caf"
├── overtime.caf
└── meeting.caf
收到推送 → 系统读 bundle 对应文件 → 播放
4.2 客户端实现(Objective-C)
第 1 步:把语音文件加入工程
- 把
.caf/.wav/.aiff文件拖进 Xcode - 勾选 Copy items if needed
- 确认 target 的 Build Phases → Copy Bundle Resources 里包含这些文件
建议直接放在 bundle 根目录,这样 sound 字段直接写文件名即可。
第 2 步:AppDelegate.m 推送授权
#import <UserNotifications/UserNotifications.h>
@interface AppDelegate () <UNUserNotificationCenterDelegate>
@end
@implementation AppDelegate
- (BOOL)application:(UIApplication *)application
didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
center.delegate = self;
[center requestAuthorizationWithOptions:(UNAuthorizationOptionAlert |
UNAuthorizationOptionSound |
UNAuthorizationOptionBadge)
completionHandler:^(BOOL granted, NSError * _Nullable error) {
if (granted) {
dispatch_async(dispatch_get_main_queue(), ^{
[application registerForRemoteNotifications];
});
}
}];
return YES;
}
// 注册成功,拿到 deviceToken
- (void)application:(UIApplication *)application
didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken {
NSString *token = [self convertDeviceTokenToString:deviceToken];
NSLog(@"deviceToken: %@", token);
// TODO: 调用自己的接口上报 token 给业务服务器
// [self uploadDeviceToken:token];
}
- (void)application:(UIApplication *)application
didFailToRegisterForRemoteNotificationsWithError:(NSError *)error {
NSLog(@"推送注册失败: %@", error);
}
// 前台收到推送时也播放声音(默认前台不播通知声音)
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
willPresentNotification:(UNNotification *)notification
withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler {
completionHandler(UNNotificationPresentationOptionAlert |
UNNotificationPresentationOptionSound |
UNNotificationPresentationOptionBadge);
}
// deviceToken 转十六进制字符串
- (NSString *)convertDeviceTokenToString:(NSData *)deviceToken {
const unsigned char *bytes = (const unsigned char *)deviceToken.bytes;
NSMutableString *token = [NSMutableString string];
for (NSInteger i = 0; i < deviceToken.length; i++) {
[token appendFormat:@"%02x", bytes[i]];
}
return token;
}
@end
第 3 步:服务端推送 payload
{
"aps": {
"alert": {
"title": "请假审批",
"body": "张三提交了一条请假申请待您审批"
},
"sound": "leave_apply.caf",
"badge": 1
}
}
不同业务场景发不同的 sound 值即可。sound 字段可以不带扩展名(系统会自动尝试),但建议带上更明确。
五、方案 B:Notification Service Extension 下载声音
5.1 原理
推送到达时,系统先启动 Notification Service Extension 扩展,扩展从推送的自定义字段中获取语音文件 URL,下载到 App Group 共享目录的 Library/Sounds/ 下,然后修改通知的 sound 字段,最后交还给系统播放。
推送到达
↓
系统启动 Notification Service Extension
↓
扩展读取自定义字段 sound_url
↓
下载文件 → 存到 App Group 的 Library/Sounds/
↓
修改通知 sound = 下载的文件名
↓
交还给系统 → 系统播放
为什么需要 App Group:扩展和主 App 是两个独立进程,有各自的沙盒。扩展下载的文件必须放在两者共享的 App Group 目录下,且必须在 Library/Sounds/ 子目录,系统通知引擎才能找到并播放。
5.2 客户端实现(Objective-C)
第 1 步:创建 Notification Service Extension
- Xcode → File → New → Target
- 选择 Notification Service Extension
- 命名(如
VoiceNotificationService),语言选 Objective-C - 激活这个 target
创建后工程里会多出一个文件夹,包含 NotificationService.h、NotificationService.m、Info.plist。
第 2 步:配置 App Group
- 主 App target → Signing & Capabilities → + Capability → 添加 App Groups
- 新增一个 group,如
group.com.yourcompany.yourapp.voice - Extension target 也做同样操作,勾选同一个 group
- 两个 target 必须用相同的 App Group identifier,且都要能写入该 group
第 3 步:配置 ATS(如使用 HTTP 下载)
如果语音文件 URL 是 HTTPS,可跳过此步。如果是 HTTP,需要在 Extension 的 Info.plist(不是主 App 的)中添加:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
或更精确地配置 NSExceptionDomains 白名单。
第 4 步:主 App 推送授权代码
和方案 A 完全相同,见上面的 AppDelegate.m。
第 5 步:Extension 核心代码(NotificationService.m)
#import "NotificationService.h"
#import <UserNotifications/UserNotifications.h>
@interface NotificationService ()
@property (nonatomic, strong) void (^contentHandler)(UNNotificationContent *contentToDeliver);
@property (nonatomic, strong) UNMutableNotificationContent *bestAttemptContent;
@property (nonatomic, strong) NSURLSessionDownloadTask *downloadTask;
@end
@implementation NotificationService
- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request
withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler {
self.contentHandler = contentHandler;
self.bestAttemptContent = [request.content mutableCopy];
// 从推送自定义字段取语音文件 URL(sound_url 放在 aps 外面)
NSString *soundUrlString = request.content.userInfo[@"sound_url"];
if (!soundUrlString || soundUrlString.length == 0) {
[self deliverNotification];
return;
}
NSURL *soundUrl = [NSURL URLWithString:soundUrlString];
if (!soundUrl) {
[self deliverNotification];
return;
}
// 获取 App Group 共享目录
NSURL *groupContainer = [[NSFileManager defaultManager]
containerURLForSecurityApplicationGroupIdentifier:@"group.com.yourcompany.yourapp.voice"];
if (!groupContainer) {
NSLog(@"App Group 未配置,请检查 Capabilities");
[self deliverNotification];
return;
}
// 声音文件必须放在 Library/Sounds/ 目录下,系统才能识别
NSURL *soundsDir = [groupContainer URLByAppendingPathComponent:@"Library/Sounds" isDirectory:YES];
NSFileManager *fm = [NSFileManager defaultManager];
[fm createDirectoryAtURL:soundsDir withIntermediateDirectories:YES attributes:nil error:nil];
// 用时间戳做文件名,避免缓存旧文件
NSString *fileName = [NSString stringWithFormat:@"voice_%ld.caf",
(long)[[NSDate date] timeIntervalSince1970]];
NSURL *localFileUrl = [soundsDir URLByAppendingPathComponent:fileName];
// 清理旧文件(可选,防止目录越来越大)
[self cleanupOldSoundsInDir:soundsDir];
// 下载语音文件
NSURLSession *session = [NSURLSession sessionWithConfiguration:
[NSURLSessionConfiguration defaultSessionConfiguration]];
self.downloadTask = [session downloadTaskWithURL:soundUrl
completionHandler:^(NSURL *location, NSURLResponse *response, NSError *error) {
if (!error && location) {
[fm removeItemAtURL:localFileUrl error:nil];
NSError *moveError = nil;
BOOL success = [fm moveItemAtURL:location toURL:localFileUrl error:&moveError];
if (success) {
// 设置通知声音为刚下载的文件
// 系统会在 App Group 的 Library/Sounds/ 下查找该文件名
self.bestAttemptContent.sound = [UNNotificationSound soundNamed:fileName];
} else {
NSLog(@"文件移动失败: %@", moveError);
}
} else {
NSLog(@"语音下载失败: %@", error);
}
// 无论成功失败,都要在超时前调用 contentHandler
[self deliverNotification];
}];
[self.downloadTask resume];
}
// 系统超时兜底(约 30 秒),必须调用 contentHandler,否则通知可能不展示
- (void)serviceExtensionTimeWillExpire {
[self.downloadTask cancel];
[self deliverNotification];
}
- (void)deliverNotification {
if (self.contentHandler && self.bestAttemptContent) {
self.contentHandler(self.bestAttemptContent);
self.contentHandler = nil;
}
}
// 清理超过 1 天的旧语音文件
- (void)cleanupOldSoundsInDir:(NSURL *)dir {
NSFileManager *fm = [NSFileManager defaultManager];
NSArray *files = [fm contentsOfDirectoryAtURL:dir
includingPropertiesForKeys:@[NSURLContentModificationDateKey]
options:0 error:nil];
NSTimeInterval now = [[NSDate date] timeIntervalSince1970];
for (NSURL *file in files) {
NSDate *modDate = nil;
[file getResourceValue:&modDate forKey:NSURLContentModificationDateKey error:nil];
if (modDate && (now - [modDate timeIntervalSince1970] > 86400)) {
[fm removeItemAtURL:file error:nil];
}
}
}
@end
第 6 步:服务端推送 payload
{
"aps": {
"alert": {
"title": "审批提醒",
"body": "张三提交了一条请假申请"
},
"sound": "default"
},
"sound_url": "https://your-cdn.com/voice/leave_apply_zhangsan.caf"
}
注意:
sound_url是自定义字段,放在aps外面,通过request.content.userInfo读取aps.sound写"default"作为 fallback(下载失败时播默认声音)- 扩展下载成功后会覆盖
sound为下载的文件名 - 推送 payload 总大小限制为 4KB(HTTP/2 APNS),
sound_url通常很短,不影响
六、方案对比与选型建议
| 维度 | 方案 A(预置多声音) | 方案 B(Extension 下载) |
|---|---|---|
| 语音内容是否可变 | ❌ 固定,发版后不能改 | ✅ 完全动态,服务端随时换 |
| 语音数量 | 受包体积限制,建议几十个以内 | 无限制 |
| 播放延迟 | 无延迟,本地直接播 | 有下载延迟(取决于网络和文件大小) |
| 实现复杂度 | 极低,只需主 App 代码 | 中,需要 Extension + App Group |
| 审核风险 | 无 | 低(标准用法) |
| 离线可用 | ✅ 无网络也能播 | ❌ 推送到达时需要网络下载 |
| 包体积影响 | 每个语音文件都占包体积 | 不占包体积 |
| 系统版本 | iOS 10+ 通用 | iOS 10+(Extension 本身要求) |
选型建议
普通办公 App 优先用方案 A,原因:
- 办公场景的语音提示通常是固定的几种(审批、会议、公告等),预置完全够用
- 零延迟、离线可用、实现简单
- 不需要维护 Extension target 和 App Group,出问题概率低
只有满足以下条件时才考虑方案 B:
- 语音内容需要包含动态信息(如播报具体的人名、金额、时间)
- 语音种类非常多(上百种),打包进 App 不现实
- 需要服务端随时更新语音内容而不发版
如果确实需要动态内容但又不想用 Extension,还有一个折中方案:服务端 TTS 生成语音 → 推 CDN → 客户端用方案 B 下载,这也是很多 App 的实际做法。
七、常见坑与注意事项
- 不要为了推送语音声明
audio后台模式——普通办公 App 审核必拒,推送声音机制完全够用 - 声音文件超过 30 秒会被系统静默替换为默认三全音,短语音控制在几秒内最佳
- 用户把手机调静音、或在系统设置中关闭该 App 通知声音时,推送声音也不会响——这是系统行为,普通 App 无法绕过(除非申请 Critical Alerts)
- 推送声音走的是系统通知通道,即使 App 被完全杀掉也能播放,这是比后台播放更可靠的机制
- 方案 B 中声音文件必须放在 App Group 的
Library/Sounds/目录,放在其他位置系统找不到 - Extension 必须处理超时兜底,
serviceExtensionTimeWillExpire里一定要调用contentHandler,否则通知可能不展示 - 方案 B 下载建议用 HTTPS,HTTP 需要在 Extension 的 Info.plist 中配置 ATS 例外
- AVSpeechSynthesizer 在 iOS 16 及以下后台不稳定,建议用 AVAudioPlayer 播放预生成的音频文件
- 被系统中断后要监听恢复:监听
AVAudioSessionInterruptionNotification,中断结束后手动恢复播放(仅针对真正的后台播放场景) soundNamed:文件名建议带扩展名,虽然系统会自动尝试,但带上更明确,避免歧义
总结
对于普通办公 App 来说,实现"收到推送播放短语音"的最佳路径是:
不声明任何后台模式,利用推送通知自带的声音机制,通过预置语音文件 + 服务端指定 sound 字段来实现。
这个方案零审核风险、零延迟、离线可用、实现简单,完全能满足绝大多数办公场景的需求。只有当语音内容需要高度动态化时,才引入 Notification Service Extension 做下载播放。
后台音频模式(UIBackgroundModes = audio)是给音乐、导航、播客这类以音频为核心功能的 App 准备的,办公 App 不要碰,否则审核被拒是大概率事件。

浙公网安备 33010602011771号