1. 問題
最近發(fā)現(xiàn)公司的 VoIP 應(yīng)用在進(jìn)行通話時(shí),若接收到微信來電并接聽,微信通話結(jié)束后返回原 VoIP 通話,通話雙方均無聲音輸出
2. 分析
這是一個(gè)典型的 iOS 音頻會(huì)話沖突問題。當(dāng)微信或普通電話激活音頻會(huì)話后,它們可能沒有正確釋放音頻資源,導(dǎo)致后續(xù) VoIP 應(yīng)用無法正常使用音頻設(shè)備
3. 解決方案
1. CallKit(CXCallObserver)
CallKit 是蘋果在 iOS 10 中推出的一個(gè)重要框架,其設(shè)計(jì)初衷是為了讓第三方 VoIP 應(yīng)用的通話體驗(yàn)?zāi)芘c系統(tǒng)原生電話相媲美,CXCallObserver 作為 CallKit 的一部分,是一個(gè)關(guān)鍵的API,它允許應(yīng)用注冊一個(gè)觀察者來監(jiān)聽系統(tǒng)級(jí)的通話狀態(tài)變化,例如電話呼入、呼出、接通、掛斷等
通過CXCallObserverDelegate
- (void)callObserver:(CXCallObserver *)callObserver callChanged:(CXCall *)call {
if (call.hasConnected) {
// 電話接通
[self pauseAudioCall];
} else if (call.hasEnded) {
// 電話結(jié)束
[self resumeAudioCall];
}
}
微信曾在早期版本( 如2018年的6.6版 )中短暫支持過 CallKit,為用戶提供了原生級(jí)的通話體驗(yàn) 。然而,這一功能很快便在國內(nèi)下線,根本原因在于工信部的監(jiān)管政策。該政策要求在國內(nèi)App Store 上架的所有應(yīng)用均不得集成 CallKit 功能 。這一規(guī)定導(dǎo)致微信在2018年5月后,不得不為國內(nèi)用戶移除了 CallKit 的支持,因此,對于目標(biāo)市場包含國內(nèi)的應(yīng)用來說,使用 CXCallObserver 不僅無法監(jiān)聽到微信通話,更會(huì)導(dǎo)致應(yīng)用無法通過 App Store 的審核
2. AVAudioSession
在 iOS 中,所有應(yīng)用的音頻功能都必須通過一個(gè)名為 AVAudioSession 的單例對象來協(xié)調(diào) 。它就像一個(gè)音頻硬件的 “總管”,決定了當(dāng)前哪個(gè)應(yīng)用可以使用麥克風(fēng)、揚(yáng)聲器,以及音頻該如何播放( 例如:是否與其他應(yīng)用混音、是否響應(yīng)靜音鍵等 )。由于是單例,AVAudioSession 在整個(gè)系統(tǒng)中是獨(dú)占性的資源,任何時(shí)刻只有一個(gè)應(yīng)用可以完全激活并主導(dǎo)它。當(dāng)一個(gè)高優(yōu)先級(jí)的音頻事件發(fā)生時(shí)( 例如:系統(tǒng)來電、鬧鐘響起,或者用戶接聽了微信電話 ),iOS 系統(tǒng)會(huì) “中斷” 當(dāng)前正在使用音頻的應(yīng)用
過程
中斷開始: 系統(tǒng)會(huì)向所有正在使用音頻的應(yīng)用發(fā)送一個(gè) AVAudioSession.interruptionNotification 通知,并在通知信息中將類型標(biāo)記為 AVAudioSession.InterruptionType.began
應(yīng)用響應(yīng): 收到 “中斷開始” 通知的應(yīng)用,應(yīng)該立即暫停其音頻播放和錄制,并保存當(dāng)前狀態(tài)。此時(shí),應(yīng)用的 AVAudioSession 會(huì)話會(huì)被系統(tǒng)置為非激活( inactive )狀態(tài)
高優(yōu)先級(jí)事件占用: 微信電話作為 VoIP 通話,會(huì)立即請求激活一個(gè)配置為AVAudioSessionCategoryPlayAndRecord( 同時(shí)支持播放和錄制 )和AVAudioSessionModeVoiceChat( 為語音聊天優(yōu)化 )的音頻會(huì)話 。這個(gè)配置具有很高的優(yōu)先級(jí),會(huì)搶占音頻硬件的控制權(quán)
中斷結(jié)束: 當(dāng)微信電話掛斷后,系統(tǒng)會(huì)再次發(fā)送 AVAudioSession.interruptionNotification 通知,類型標(biāo)記為 AVAudioSession.InterruptionType.ended
我一開始錯(cuò)誤地認(rèn)為,當(dāng)中斷結(jié)束后,系統(tǒng)會(huì)自動(dòng)將音頻控制權(quán) “還給” 之前的應(yīng)用,或者自己的應(yīng)用能自動(dòng)恢復(fù)。事實(shí)并非如此
實(shí)際情況
當(dāng)中斷結(jié)束后,系統(tǒng)只是通知你 “高優(yōu)先級(jí)事件結(jié)束了”,但它并不會(huì)主動(dòng)為你恢復(fù)應(yīng)用的音頻會(huì)話。此時(shí),你應(yīng)用的 AVAudioSession 雖然不再被強(qiáng)制中斷,但它仍處于非激活( inactive )狀態(tài)。音頻硬件的控制權(quán)可能處于一個(gè)無人認(rèn)領(lǐng)的 “空檔期”
結(jié)果
我的 VoIP 應(yīng)用在這種 “僵尸” 狀態(tài)下繼續(xù)運(yùn)行,嘗試發(fā)送和接收音頻數(shù)據(jù)。但由于 AVAudioSession 沒有被重新激活,應(yīng)用層無法訪問麥克風(fēng)進(jìn)行錄音,也無法通過揚(yáng)聲器/聽筒進(jìn)行播放,最終導(dǎo)致了雙方都聽不到聲音的現(xiàn)象
解決
解決問題的關(guān)鍵,在于讓我的 VoIP 應(yīng)用變得 “主動(dòng)”,在音頻中斷結(jié)束后,能夠果斷、強(qiáng)制地奪回音頻硬件的控制權(quán)
1. 監(jiān)聽并精確處理音頻中斷通知
進(jìn)入VoIP功能模塊時(shí)( 或者在AppDelegate中 ),注冊對 AVAudioSession.interruptionNotification 的監(jiān)聽
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) {
// 音頻被中斷開始,此時(shí)音頻已經(jīng)被中斷
[self pauseAudioCall];
}
if (AVAudioSessionInterruptionTypeEnded == interrputionType) {
AVAudioSessionInterruptionOptions options = [notification.userInfo[AVAudioSessionInterruptionOptionKey] unsignedIntegerValue];
// 檢查是否需要恢復(fù)
if (options & AVAudioSessionInterruptionOptionShouldResume) {
// 音頻被中斷結(jié)束,此時(shí)音頻可以進(jìn)行恢復(fù)了
[self tryResumeAudioWithDelay];
}
}
}
swift 版
// 在AppDelegate或相關(guān)ViewController中設(shè)置監(jiān)聽
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:
// 中斷開始:微信電話或其他高優(yōu)先級(jí)事件開始了
// 在這里暫停你的VoIP音頻引擎,并更新UI告知用戶通話已保持
myVoIP.pause()
updateUIForCallOnHold()
case .ended:
// 中斷結(jié)束:微信電話掛斷了
// 檢查是否應(yīng)該恢復(fù)
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
if options.contains(.shouldResume) {
print("System suggests we should resume audio.")
// **關(guān)鍵步驟:無論系統(tǒng)是否建議,都主動(dòng)恢復(fù)**
reactivateAudioSession()
} else {
print("System does not suggest resuming.")
// 即使系統(tǒng)不建議,如果你的業(yè)務(wù)邏輯需要,也應(yīng)該嘗試恢復(fù)
reactivateAudioSession()
}
} else {
// 兼容舊版或無選項(xiàng)信息的情況,直接嘗試恢復(fù)
reactivateAudioSession()
}
@unknown default:
fatalError("Unknown interruption type received")
}
}
根據(jù):Apple的音頻會(huì)話編程指南

無法保證每次音頻中斷開始后,必有對應(yīng)的中斷結(jié)束通知,應(yīng)用切換到前臺(tái)運(yùn)行狀態(tài)或用戶手動(dòng)按下播放按鈕,均需自行判斷是否應(yīng)重新激活音頻會(huì)話
// 中斷結(jié)束(可能收不到此事件?。?
// 即使收到,也需檢查其他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. 強(qiáng)制、主動(dòng)地恢復(fù)音頻會(huì)話
iOS 的沙盒機(jī)制不允許任何一個(gè)應(yīng)用 “強(qiáng)制回收” 或干預(yù)另一個(gè)應(yīng)用的內(nèi)部資源。我們這里所謂的 “強(qiáng)制恢復(fù)”,并非操作微信,而是通過合法的 AVAudioSession API,在系統(tǒng)規(guī)則允許的框架內(nèi),為自己的應(yīng)用重新申請和激活共享的音頻硬件資源。這是一個(gè)遵守規(guī)則下的 “主動(dòng)奪回”,而非越權(quán)操作
關(guān)鍵點(diǎn)
主動(dòng)setActive: 即使系統(tǒng)在中斷結(jié)束時(shí)提供了 .shouldResume 選項(xiàng),也不能完全依賴它。最可靠的方法是再次調(diào)用 setActive(true) 。這相當(dāng)于向系統(tǒng)明確聲明:“現(xiàn)在輪到我使用音頻設(shè)備了”
錯(cuò)誤處理: setActive 可能會(huì)失敗( 例如:在另一個(gè)中斷緊接著發(fā)生時(shí) ),因此必須將其包裹在 do-catch 塊中進(jìn)行錯(cuò)誤處理
延遲執(zhí)行: 添加一個(gè)微小的延遲( 如0.5秒 )有時(shí)可以增加成功率,因?yàn)檫@給了系統(tǒng)足夠的時(shí)間來完全結(jié)束前一個(gè)音頻會(huì)話( 如微信通話 )的清理工作
// 強(qiáng)制重新激活音頻會(huì)話的函數(shù)
func reactivateAudioSession() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { // 稍微延遲,給系統(tǒng)一點(diǎn)反應(yīng)時(shí)間
do {
// 重新調(diào)用 setActive(true) 是從中斷中恢復(fù)的關(guān)鍵
// 這會(huì)告訴系統(tǒng),你的應(yīng)用現(xiàn)在要重新拿回音頻硬件的控制權(quán)
try AVAudioSession.sharedInstance().setActive(true, options: .notifyOthersOnDeactivation)
// 在這里恢復(fù)你的VoIP音頻引擎,并更新UI
myVoIPEngine.resume()
updateUIForCallOnHold()
} catch {
print("Failed to reactivate audio session after interruption: \(error)")
// 如果激活失敗,可以考慮提示用戶手動(dòng)重試或進(jìn)行其他錯(cuò)誤處理(如:掛斷)
}
}
}
3. 權(quán)限與硬件狀態(tài)校驗(yàn)
在恢復(fù)音頻前,需排除權(quán)限變更或硬件故障
- 麥克風(fēng)權(quán)限動(dòng)態(tài)檢查
func checkMicrophonePermission() {
switch AVAudioSession.sharedInstance().recordPermission {
case .granted: // 權(quán)限正常
break
case .denied: // 用戶拒絕,需引導(dǎo)開啟
showSettingsAlert()
case .undetermined: // 首次使用,主動(dòng)請求
AVAudioSession.sharedInstance().requestRecordPermission { _ in }
@unknown default:
break
}
}
- 藍(lán)牙設(shè)備占用處理
若微信通話使用了藍(lán)牙耳機(jī),結(jié)束后可能未釋放設(shè)備。通過 AVAudioSession 的 currentRoute 遍歷輸出端口,若發(fā)現(xiàn)藍(lán)牙設(shè)備則手動(dòng)切換
if let output = AVAudioSession.sharedInstance().currentRoute.outputs.first {
if output.portType == .bluetoothLE || output.portType == .bluetoothHFP {
try? AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker)
}
}
通過實(shí)施上述的完整技術(shù)方案,VoIP應(yīng)用 能夠應(yīng)對來自微信或任何其他應(yīng)用的音頻通話中斷,并在中斷結(jié)束后可靠地恢復(fù)雙向音頻,從而顯著提升應(yīng)用的穩(wěn)定性和用戶體驗(yàn)