CallKit扬声器按钮异常求助:自动切换且切换无效
问题分析与解决方案
问题概况
- CallKit扬声器按钮异常:自动反复开启/关闭,点击关闭后声音仍通过外放输出
- 需求目标:通话初始为外放模式,支持用户通过CallKit UI切换听筒/外放,但目前切换至听筒的操作完全无效
- 音频日志显示
categoryChange和routeConfigurationChange触发后,音频路由被强制从扬声器切换到听筒:- AUDIO: builtInSpeaker
- AUDIO: categoryChange
- AUDIO: builtInReceiver
- AUDIO: routeConfigurationChange
- AUDIO: builtInReceiver
用户现有音频配置代码
在func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession)中激活会话,核心配置代码如下:
func configureAudioSession() { let rtcAudioSession = RTCAudioSession.sharedInstance() rtcAudioSession.lockForConfiguration() do { var categoryOptions: AVAudioSession.CategoryOptions = [.defaultToSpeaker, .allowBluetoothA2DP, .allowAirPlay, .allowBluetooth] try rtcAudioSession.setCategory(.playAndRecord, with: categoryOptions) try rtcAudioSession.setMode(.voiceChat) } catch { //Error } rtcAudioSession.unlockForConfiguration() NotificationCenter.default.addObserver(self, selector: #selector(routeChange), name: AVAudioSession.routeChangeNotification, object: nil) } @objc private func routeChange(_ n: Foundation.Notification) { let session = AVAudioSession.sharedInstance() let currentRoute = session.currentRoute if session.currentRoute.outputs.contains(where: { $0.portType == .headphones || $0.portType == .bluetoothHFP || $0.portType == .bluetoothA2DP }) { try? session.overrideOutputAudioPort(.none) } else { try? session.overrideOutputAudioPort(.speaker) } }
问题根源
- CallKit与音频会话的自动冲突:CallKit会主动干预音频会话配置,用户点击扬声器按钮时,它可能私自修改会话category或路由,直接覆盖你的自定义逻辑,这就是日志中
categoryChange的触发原因。 - 路由监听逻辑的强制覆盖:当前
routeChange方法中,只要没有耳机/蓝牙设备就强制设为外放,完全忽略用户通过CallKit UI选择听筒的操作,导致关闭扬声器后仍维持外放状态。 - WebRTC与系统音频会话的交互冲突:直接操作系统
AVAudioSession会和WebRTC的RTCAudioSession管理逻辑冲突,引发音频路由的异常切换。
解决方案
1. 接管CallKit的音频切换事件
通过CXProviderDelegate监听用户切换扬声器的操作,手动控制音频输出,彻底绕过CallKit的自动干预:
func provider(_ provider: CXProvider, didChange audioSession: AVAudioSession) { let isSpeakerEnabled = provider.isSpeakerphoneEnabled let rtcSession = RTCAudioSession.sharedInstance() rtcSession.lockForConfiguration() do { try rtcSession.overrideOutputAudioPort(isSpeakerEnabled ? .speaker : .none) } catch { print("音频端口切换失败: \(error)") } rtcSession.unlockForConfiguration() }
2. 修改路由监听逻辑,保留用户选择
仅在硬件路由发生变化(如插入/拔出耳机、蓝牙连接)时调整音频输出,其他情况保留用户手动设置的偏好:
@objc private func routeChange(_ n: Foundation.Notification) { guard let reasonRaw = n.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt, let routeReason = AVAudioSession.RouteChangeReason(rawValue: reasonRaw) else { return } // 仅处理硬件路由变化事件 if routeReason == .newDeviceAvailable || routeReason == .oldDeviceUnavailable { let rtcSession = RTCAudioSession.sharedInstance() rtcSession.lockForConfiguration() do { let hasExternalOutput = rtcSession.currentRoute.outputs.contains(where: { $0.portType == .headphones || $0.portType == .bluetoothHFP || $0.portType == .bluetoothA2DP }) if hasExternalOutput { try rtcSession.overrideOutputAudioPort(.none) } else { // 读取用户之前保存的偏好设置 let preferSpeaker = UserDefaults.standard.bool(forKey: "Audio_PreferSpeaker") try rtcSession.overrideOutputAudioPort(preferSpeaker ? .speaker : .none) } } catch { print("路由切换错误: \(error)") } rtcSession.unlockForConfiguration() } }
3. 调整初始音频配置,避免默认冲突
移除.defaultToSpeaker选项,改为手动设置初始外放,并保存用户初始偏好:
func configureAudioSession() { let rtcSession = RTCAudioSession.sharedInstance() rtcSession.lockForConfiguration() do { var categoryOptions: AVAudioSession.CategoryOptions = [.allowBluetoothA2DP, .allowAirPlay, .allowBluetooth] try rtcSession.setCategory(.playAndRecord, with: categoryOptions) try rtcSession.setMode(.voiceChat) // 手动设置初始外放状态 try rtcSession.overrideOutputAudioPort(.speaker) // 保存初始偏好到本地 UserDefaults.standard.set(true, forKey: "Audio_PreferSpeaker") } catch { print("音频会话配置失败: \(error)") } rtcSession.unlockForConfiguration() NotificationCenter.default.addObserver(self, selector: #selector(routeChange), name: AVAudioSession.routeChangeNotification, object: nil) }
4. 同步UI状态与实际音频输出
在处理用户切换操作时,同步更新本地保存的偏好,确保路由变化时能维持用户选择:
func provider(_ provider: CXProvider, didChange audioSession: AVAudioSession) { let isSpeakerEnabled = provider.isSpeakerphoneEnabled // 更新用户偏好 UserDefaults.standard.set(isSpeakerEnabled, forKey: "Audio_PreferSpeaker") let rtcSession = RTCAudioSession.sharedInstance() rtcSession.lockForConfiguration() do { try rtcSession.overrideOutputAudioPort(isSpeakerEnabled ? .speaker : .none) } catch { print("音频端口切换失败: \(error)") } rtcSession.unlockForConfiguration() }
核心注意事项
- 全程使用WebRTC的
RTCAudioSession操作音频配置,禁止直接调用系统AVAudioSession,避免逻辑冲突。 - 彻底放弃依赖CallKit自动处理音频路由,手动接管所有用户切换事件,确保音频输出与用户选择完全一致。
- 持久化保存用户的音频偏好,在任何路由变化场景下优先使用用户设置。
内容的提问来源于stack exchange,提问作者Zeona
相关产品推荐
相关产品推荐

