iOS平臺(tái)VoIP應(yīng)用音頻沖突的分析及解決

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)用

過程

  1. 中斷開始: 系統(tǒng)會(huì)向所有正在使用音頻的應(yīng)用發(fā)送一個(gè) AVAudioSession.interruptionNotification 通知,并在通知信息中將類型標(biāo)記為 AVAudioSession.InterruptionType.began

  2. 應(yīng)用響應(yīng): 收到 “中斷開始” 通知的應(yīng)用,應(yīng)該立即暫停其音頻播放和錄制,并保存當(dāng)前狀態(tài)。此時(shí),應(yīng)用的 AVAudioSession 會(huì)話會(huì)被系統(tǒng)置為非激活( inactive )狀態(tài)

  3. 高優(yōu)先級(jí)事件占用: 微信電話作為 VoIP 通話,會(huì)立即請求激活一個(gè)配置為AVAudioSessionCategoryPlayAndRecord( 同時(shí)支持播放和錄制 )和AVAudioSessionModeVoiceChat( 為語音聊天優(yōu)化 )的音頻會(huì)話 。這個(gè)配置具有很高的優(yōu)先級(jí),會(huì)搶占音頻硬件的控制權(quán)

  4. 中斷結(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ì)話編程指南

Note

無法保證每次音頻中斷開始后,必有對應(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)

  1. 主動(dòng)setActive: 即使系統(tǒng)在中斷結(jié)束時(shí)提供了 .shouldResume 選項(xiàng),也不能完全依賴它。最可靠的方法是再次調(diào)用 setActive(true) 。這相當(dāng)于向系統(tǒng)明確聲明:“現(xiàn)在輪到我使用音頻設(shè)備了”

  2. 錯(cuò)誤處理: setActive 可能會(huì)失敗( 例如:在另一個(gè)中斷緊接著發(fā)生時(shí) ),因此必須將其包裹在 do-catch 塊中進(jìn)行錯(cuò)誤處理

  3. 延遲執(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)

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • AVAudioSession 概述 最近在做 webrtc 采集與播放音頻,使用AVAudioSession進(jìn)行播...
    langzi閱讀 15,090評(píng)論 3 16
  • 音頻輸出作為硬件資源,對于iOS系統(tǒng)來說是唯一的,需要通過“AVAudioSession”這個(gè)系統(tǒng)級(jí)全局對象對各個(gè)...
    Leoeoo閱讀 6,035評(píng)論 0 23
  • 最近在處理錄音方面的問題,做個(gè)轉(zhuǎn)載參考鏈接參考鏈接 AVAudioSession就是用來管理多個(gè)APP對音頻硬件設(shè)...
    iOSTbag閱讀 3,161評(píng)論 1 6
  • AVAudioSession 簡要說說AVAudioSession,AVAudioSession是蘋果用來管理Ap...
    新生代農(nóng)民工No1閱讀 12,306評(píng)論 3 20
  • 1. AVAudioSession 概述 最近一年一直在做IPC Camera的iOS客戶端開發(fā)。和音頻打交道,必...
    安東_Ace閱讀 52,829評(píng)論 20 169

友情鏈接更多精彩內(nèi)容