1. 问题
最近发现公司的 VoIP 应用在进行通话时,若接收到微信来电并接听,微信通话结束后返回原 VoIP 通话,通话双方均无声音输出
2. 分析
这是一个典型的 iOS 音频会话冲突问题。当微信或普通电话激活音频会话后,它们可能没有正确释放音频资源,导致后续 VoIP 应用无法正常使用音频设备
3. 解决方案
1. CallKit(CXCallObserver)
CallKit 是苹果在 iOS 10 中推出的一个重要框架,其设计初衷是为了让第三方 VoIP 应用的通话体验能与系统原生电话相媲美,CXCallObserver 作为 CallKit 的一部分,是一个关键的API,它允许应用注册一个观察者来监听系统级的通话状态变化,例如电话呼入、呼出、接通、挂断等
通过CXCallObserverDelegate
- (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 系统会 “中断” 当前正在使用音频的应用
过程
中断开始: 系统会向所有正在使用音频的应用发送一个 AVAudioSession.interruptionNotification 通知,并在通知信息中将类型标记为 AVAudioSession.InterruptionType.began
应用响应: 收到 “中断开始” 通知的应用,应该立即暂停其音频播放和录制,并保存当前状态。此时,应用的 AVAudioSession 会话会被系统置为非激活( inactive )状态
高优先级事件占用: 微信电话作为 VoIP 通话,会立即请求激活一个配置为AVAudioSessionCategoryPlayAndRecord( 同时支持播放和录制 )和AVAudioSessionModeVoiceChat( 为语音聊天优化 )的音频会话 。这个配置具有很高的优先级,会抢占音频硬件的控制权
中断结束: 当微信电话挂断后,系统会再次发送 AVAudioSession.interruptionNotification 通知,类型标记为 AVAudioSession.InterruptionType.ended
我一开始错误地认为,当中断结束后,系统会自动将音频控制权 “还给” 之前的应用,或者自己的应用能自动恢复。事实并非如此
实际情况
当中断结束后,系统只是通知你 “高优先级事件结束了”,但它并不会主动为你恢复应用的音频会话。此时,你应用的 AVAudioSession 虽然不再被强制中断,但它仍处于非激活( inactive )状态。音频硬件的控制权可能处于一个无人认领的 “空档期”
结果
我的 VoIP 应用在这种 “僵尸” 状态下继续运行,尝试发送和接收音频数据。但由于 AVAudioSession 没有被重新激活,应用层无法访问麦克风进行录音,也无法通过扬声器/听筒进行播放,最终导致了双方都听不到声音的现象
解决
解决问题的关键,在于让我的 VoIP 应用变得 “主动”,在音频中断结束后,能够果断、强制地夺回音频硬件的控制权
1. 监听并精确处理音频中断通知
进入VoIP功能模块时( 或者在AppDelegate中 ),注册对 AVAudioSession.interruptionNotification 的监听
OC 版
- (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 版
// 在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")
}
}
无法保证每次音频中断开始后,必有对应的中断结束通知,应用切换到前台运行状态或用户手动按下播放按钮,均需自行判断是否应重新激活音频会话
// 中断结束(可能收不到此事件!)
// 即使收到,也需检查其他App是否仍在占用
if AVAudioSession.sharedInstance().isOtherAudioPlaying {
// 延迟重试
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
reactivateAudioSession()
}
} else {
reactivateAudioSession()
}
返回app检查:
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(applicationWillEnterForeground:)
name:UIApplicationWillEnterForegroundNotification
object:nil];
- (void)applicationWillEnterForeground:(NSNotification *)notification {
if ([self isCallPaused]) { // 检查通话是否被中断暂停
[self tryResumeAudioWithDelay];
}
}
2. 强制、主动地恢复音频会话
iOS 的沙盒机制不允许任何一个应用 “强制回收” 或干预另一个应用的内部资源。我们这里所谓的 “强制恢复”,并非操作微信,而是通过合法的 AVAudioSession API,在系统规则允许的框架内,为自己的应用重新申请和激活共享的音频硬件资源。这是一个遵守规则下的 “主动夺回”,而非越权操作
关键点
主动setActive: 即使系统在中断结束时提供了 .shouldResume 选项,也不能完全依赖它。最可靠的方法是再次调用 setActive(true) 。这相当于向系统明确声明:“现在轮到我使用音频设备了”
错误处理: setActive 可能会失败( 例如:在另一个中断紧接着发生时 ),因此必须将其包裹在 do-catch 块中进行错误处理
延迟执行: 添加一个微小的延迟( 如0.5秒 )有时可以增加成功率,因为这给了系统足够的时间来完全结束前一个音频会话( 如微信通话 )的清理工作
// 强制重新激活音频会话的函数
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. 权限与硬件状态校验
在恢复音频前,需排除权限变更或硬件故障
- 麦克风权限动态检查
func checkMicrophonePermission() {
switch AVAudioSession.sharedInstance().recordPermission {
case .granted: // 权限正常
break
case .denied: // 用户拒绝,需引导开启
showSettingsAlert()
case .undetermined: // 首次使用,主动请求
AVAudioSession.sharedInstance().requestRecordPermission { _ in }
@unknown default:
break
}
}
- 蓝牙设备占用处理
若微信通话使用了蓝牙耳机,结束后可能未释放设备。通过 AVAudioSession 的 currentRoute 遍历输出端口,若发现蓝牙设备则手动切换
if let output = AVAudioSession.sharedInstance().currentRoute.outputs.first {
if output.portType == .bluetoothLE || output.portType == .bluetoothHFP {
try? AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker)
}
}
通过实施上述的完整技术方案,VoIP应用 能够应对来自微信或任何其他应用的音频通话中断,并在中断结束后可靠地恢复双向音频,从而显著提升应用的稳定性和用户体验