iOS平台VoIP应用音频冲突的分析及解决

1. 问题

最近发现公司的 VoIP 应用在进行通话时,若接收到微信来电并接听,微信通话结束后返回原 VoIP 通话,通话双方均无声音输出

2. 分析

这是一个典型的 iOS 音频会话冲突问题。当微信或普通电话激活音频会话后,它们可能没有正确释放音频资源,导致后续 VoIP 应用无法正常使用音频设备

3. 解决方案

1. CallKit(CXCallObserver)

CallKit 是苹果在 iOS 10 中推出的一个重要框架,其设计初衷是为了让第三方 VoIP 应用的通话体验能与系统原生电话相媲美,CXCallObserver 作为 CallKit 的一部分,是一个关键的API,它允许应用注册一个观察者来监听系统级的通话状态变化,例如电话呼入、呼出、接通、挂断等

通过CXCallObserverDelegate

1
2
3
4
5
6
7
8
9
- (void)callObserver:(CXCallObserver *)callObserver callChanged:(CXCall *)call {
if (call.hasConnected) {
// 电话接通
[self pauseAudioCall];
} else if (call.hasEnded) {
// 电话结束
[self resumeAudioCall];
}
}

微信曾在早期版本( 如2018年的6.6版 )中短暂支持过 CallKit,为用户提供了原生级的通话体验 。然而,这一功能很快便在国内下线,根本原因在于工信部的监管政策。该政策要求在国内App Store 上架的所有应用均不得集成 CallKit 功能 。这一规定导致微信在2018年5月后,不得不为国内用户移除了 CallKit 的支持,因此,对于目标市场包含国内的应用来说,使用 CXCallObserver 不仅无法监听到微信通话,更会导致应用无法通过 App Store 的审核

2. AVAudioSession

在 iOS 中,所有应用的音频功能都必须通过一个名为 AVAudioSession 的单例对象来协调 。它就像一个音频硬件的 “总管”,决定了当前哪个应用可以使用麦克风、扬声器,以及音频该如何播放( 例如:是否与其他应用混音、是否响应静音键等 )。由于是单例,AVAudioSession 在整个系统中是独占性的资源,任何时刻只有一个应用可以完全激活并主导它。当一个高优先级的音频事件发生时( 例如:系统来电、闹钟响起,或者用户接听了微信电话 ),iOS 系统会 “中断” 当前正在使用音频的应用

过程

  1. 中断开始: 系统会向所有正在使用音频的应用发送一个 AVAudioSession.interruptionNotification 通知,并在通知信息中将类型标记为 AVAudioSession.InterruptionType.began

  2. 应用响应: 收到 “中断开始” 通知的应用,应该立即暂停其音频播放和录制,并保存当前状态。此时,应用的 AVAudioSession 会话会被系统置为非激活( inactive )状态

  3. 高优先级事件占用: 微信电话作为 VoIP 通话,会立即请求激活一个配置为AVAudioSessionCategoryPlayAndRecord( 同时支持播放和录制 )和AVAudioSessionModeVoiceChat( 为语音聊天优化 )的音频会话 。这个配置具有很高的优先级,会抢占音频硬件的控制权

  4. 中断结束: 当微信电话挂断后,系统会再次发送 AVAudioSession.interruptionNotification 通知,类型标记为 AVAudioSession.InterruptionType.ended

我一开始错误地认为,当中断结束后,系统会自动将音频控制权 “还给” 之前的应用,或者自己的应用能自动恢复。事实并非如此

实际情况

当中断结束后,系统只是通知你 “高优先级事件结束了”,但它并不会主动为你恢复应用的音频会话。此时,你应用的 AVAudioSession 虽然不再被强制中断,但它仍处于非激活( inactive )状态。音频硬件的控制权可能处于一个无人认领的 “空档期”

结果

我的 VoIP 应用在这种 “僵尸” 状态下继续运行,尝试发送和接收音频数据。但由于 AVAudioSession 没有被重新激活,应用层无法访问麦克风进行录音,也无法通过扬声器/听筒进行播放,最终导致了双方都听不到声音的现象

解决

解决问题的关键,在于让我的 VoIP 应用变得 “主动”,在音频中断结束后,能够果断、强制地夺回音频硬件的控制权

1. 监听并精确处理音频中断通知

进入VoIP功能模块时( 或者在AppDelegate中 ),注册对 AVAudioSession.interruptionNotification 的监听

OC 版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
- (void)startObservingAudioInterruptions {
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(didReceivedInterruptionNotification:)
name:AVAudioSessionInterruptionNotification
object:nil];
}

- (void)didReceivedInterruptionNotification:(NSNotification*)notification {
AVAudioSessionInterruptionType interrputionType = [notification.userInfo[AVAudioSessionInterruptionTypeKey] unsignedIntegerValue];
if (AVAudioSessionInterruptionTypeBegan == interrputionType) {
// 音频被中断开始,此时音频已经被中断
[self pauseAudioCall];
}
if (AVAudioSessionInterruptionTypeEnded == interrputionType) {
AVAudioSessionInterruptionOptions options = [notification.userInfo[AVAudioSessionInterruptionOptionKey] unsignedIntegerValue];
// 检查是否需要恢复
if (options & AVAudioSessionInterruptionOptionShouldResume) {
// 音频被中断结束,此时音频可以进行恢复了
[self tryResumeAudioWithDelay];
}
}
}

swift 版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 在AppDelegate或相关ViewController中设置监听
func registerForAudioInterruptions() {
NotificationCenter.default.addObserver(
self,
selector: #selector(handleAudioInterruption(_:)),
name: AVAudioSession.interruptionNotification,
object: AVAudioSession.sharedInstance()
)
}

@objc func handleAudioInterruption(notification: Notification) {
guard let userInfo = notification.userInfo,
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
return
}

switch type {
case .began:
// 中断开始:微信电话或其他高优先级事件开始了
// 在这里暂停你的VoIP音频引擎,并更新UI告知用户通话已保持
myVoIP.pause()
updateUIForCallOnHold()

case .ended:
// 中断结束:微信电话挂断了
// 检查是否应该恢复
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
if options.contains(.shouldResume) {
print("System suggests we should resume audio.")
// **关键步骤:无论系统是否建议,都主动恢复**
reactivateAudioSession()
} else {
print("System does not suggest resuming.")
// 即使系统不建议,如果你的业务逻辑需要,也应该尝试恢复
reactivateAudioSession()
}
} else {
// 兼容旧版或无选项信息的情况,直接尝试恢复
reactivateAudioSession()
}

@unknown default:
fatalError("Unknown interruption type received")
}
}

根据:Apple的音频会话编程指南

Note

无法保证每次音频中断开始后,必有对应的中断结束通知,应用切换到前台运行状态或用户手动按下播放按钮,均需自行判断是否应重新激活音频会话

1
2
3
4
5
6
7
8
9
10
// 中断结束(可能收不到此事件!) 
// 即使收到,也需检查其他App是否仍在占用
if AVAudioSession.sharedInstance().isOtherAudioPlaying {
// 延迟重试
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
reactivateAudioSession()
}
} else {
reactivateAudioSession()
}

返回app检查:

1
2
3
4
5
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(applicationWillEnterForeground:)
name:UIApplicationWillEnterForegroundNotification
object:nil];

1
2
3
4
5
- (void)applicationWillEnterForeground:(NSNotification *)notification {
if ([self isCallPaused]) { // 检查通话是否被中断暂停
[self tryResumeAudioWithDelay];
}
}

2. 强制、主动地恢复音频会话

iOS 的沙盒机制不允许任何一个应用 “强制回收” 或干预另一个应用的内部资源。我们这里所谓的 “强制恢复”,并非操作微信,而是通过合法的 AVAudioSession API,在系统规则允许的框架内,为自己的应用重新申请和激活共享的音频硬件资源。这是一个遵守规则下的 “主动夺回”,而非越权操作

关键点

  1. 主动setActive: 即使系统在中断结束时提供了 .shouldResume 选项,也不能完全依赖它。最可靠的方法是再次调用 setActive(true) 。这相当于向系统明确声明:“现在轮到我使用音频设备了”

  2. 错误处理: setActive 可能会失败( 例如:在另一个中断紧接着发生时 ),因此必须将其包裹在 do-catch 块中进行错误处理

  3. 延迟执行: 添加一个微小的延迟( 如0.5秒 )有时可以增加成功率,因为这给了系统足够的时间来完全结束前一个音频会话( 如微信通话 )的清理工作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 强制重新激活音频会话的函数
func reactivateAudioSession() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { // 稍微延迟,给系统一点反应时间
do {
// 重新调用 setActive(true) 是从中断中恢复的关键
// 这会告诉系统,你的应用现在要重新拿回音频硬件的控制权
try AVAudioSession.sharedInstance().setActive(true, options: .notifyOthersOnDeactivation)

// 在这里恢复你的VoIP音频引擎,并更新UI
myVoIPEngine.resume()
updateUIForCallOnHold()

} catch {
print("Failed to reactivate audio session after interruption: \(error)")
// 如果激活失败,可以考虑提示用户手动重试或进行其他错误处理(如:挂断)
}
}
}

3. 权限与硬件状态校验

在恢复音频前,需排除权限变更或硬件故障

  • 麦克风权限动态检查
1
2
3
4
5
6
7
8
9
10
11
12
func checkMicrophonePermission() {
switch AVAudioSession.sharedInstance().recordPermission {
case .granted: // 权限正常
break
case .denied: // 用户拒绝,需引导开启
showSettingsAlert()
case .undetermined: // 首次使用,主动请求
AVAudioSession.sharedInstance().requestRecordPermission { _ in }
@unknown default:
break
}
}
  • 蓝牙设备占用处理

若微信通话使用了蓝牙耳机,结束后可能未释放设备。通过 AVAudioSession 的 currentRoute 遍历输出端口,若发现蓝牙设备则手动切换

1
2
3
4
5
if let output = AVAudioSession.sharedInstance().currentRoute.outputs.first {
if output.portType == .bluetoothLE || output.portType == .bluetoothHFP {
try? AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker)
}
}

通过实施上述的完整技术方案,VoIP应用 能够应对来自微信或任何其他应用的音频通话中断,并在中断结束后可靠地恢复双向音频,从而显著提升应用的稳定性和用户体验

打赏
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2015-2025 kindyourself@163.com
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

支付宝
微信