iOS 接语聊房,有两件 Android 没有的必要配置:Background Modes 里要开启 Audio,App 切后台后音频才不会中断;还要注意 AVAudioSession 的时序问题。声网 SDK 进频道时自动配置 AVAudioSession,如果项目里有其他库(播放器、录音模块)也在操作它,轻则麦克风不出声,重则蓝牙耳机切换失效。
从 CocoaPods 接入(pod 'ShengwangRtcEngine_iOS')、Info.plist 权限声明、Background Modes 开启,到 SDK 初始化、Token 鉴权、上下麦切换,本文逐步说明,附 AVAudioSession 冲突处理和常见问题排查。
一. 添加 SDK 依赖
CocoaPods 接入
在 Podfile 里添加:
pod 'ShengwangRtcEngine_iOS'
执行:
pod install
安装完成后,打开 .xcworkspace 文件而不是 .xcodeproj,这是 CocoaPods 项目的标准打开方式。如果项目目录里还没有 Podfile,先在项目根目录执行 pod init 生成一个。
Swift Package Manager 接入
Xcode 14 以上可以用 SPM。在 Xcode 菜单里选 File → Add Package Dependencies,填入声网 iOS SDK 的 GitHub 仓库地址,选择需要的版本规则。SPM 不需要安装额外工具,适合不想引入 CocoaPods 的项目。
两种方式功能一样,选哪种主要看项目已有的依赖管理习惯。
二. 配置麦克风权限
iOS 要求在 Info.plist 里声明麦克风用途,没有这个条目,应用会在请求麦克风时被系统直接拒绝:
<key>NSMicrophoneUsageDescription</key>
<string>加入语聊房需要使用麦克风</string>
字符串内容是系统权限弹窗里显示给用户的说明,写清楚实际用途,App Store 审核时会检查这里的描述是否和功能对应。
语聊房是纯音频场景,不需要 NSCameraUsageDescription。
运行时权限检查
系统会在第一次调用麦克风相关 API 时自动弹权限请求,但在进入频道前主动检查权限状态,可以更清晰地处理用户拒绝的情况:
import AVFoundation
func checkMicrophonePermission(completion: @escaping (Bool) -> Void) {
switch AVAudioSession.sharedInstance().recordPermission {
case .granted:
completion(true)
case .denied:
// 用户已拒绝,只能引导去设置里手动开启
completion(false)
case .undetermined:
AVAudioSession.sharedInstance().requestRecordPermission { granted in
DispatchQueue.main.async {
completion(granted)
}
}
@unknown default:
completion(false)
}
}
用户拒绝权限后,引导他们去系统设置:
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
三. 开启后台音频
语聊房用户切到其他 App 时,音频应该继续播放。iOS 默认会在 App 进入后台后暂停音频,需要开启 Background Modes:
- 在 Xcode 中打开项目的 Target
- 选择 Signing & Capabilities 标签页
- 点击左上角 + Capability,添加 Background Modes
- 勾选 Audio, AirPlay, and Picture in Picture
开启后,Info.plist 里会自动添加:
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
不开启这个能力,用户按下 Home 键后几秒音频就会中断。语聊房场景里用户经常在听房的同时使用其他 App,这个配置是基础。
四. 初始化 SDK
SDK 入口是 AgoraRtcEngineKit,通过 sharedEngine(with:delegate:) 创建:
import AgoraRtcKit
class ChatRoomViewController: UIViewController {
var agoraKit: AgoraRtcEngineKit!
override func viewDidLoad() {
super.viewDidLoad()
setupAgoraKit()
}
private func setupAgoraKit() {
let config = AgoraRtcEngineConfig()
// 从配置文件或环境变量读取,不要硬编码在代码里
config.appId = AppConfig.agoraAppId
agoraKit = AgoraRtcEngineKit.sharedEngine(with: config, delegate: self)
// 语聊房必须使用直播模式
// 直播模式支持 broadcaster/audience 角色区分
// 通话模式下所有用户默认都是发布者,不适合语聊房
agoraKit.setChannelProfile(.liveBroadcasting)
// 默认以观众身份加入,上麦时再切换
agoraKit.setClientRole(.audience)
// 语聊房不调用 enableVideo(),降低功耗,也不会触发摄像头权限请求
}
}
setChannelProfile(.liveBroadcasting) 是语聊房区别于普通通话最关键的一步。通话模式下所有人都默认发布音频,语聊房需要明确区分哪些用户在麦位上(broadcaster)、哪些只是收听(audience)。
sharedEngine 是耗时操作,不要在主线程直接调用,放到后台线程或用 async/await 处理。
五. 加入频道
加入频道前需要从服务端获取 Token(详见:《语聊房 Token 怎么生成》)。通过 AgoraRtcChannelMediaOptions 指定进入频道时的初始状态:
var currentChannelName: String = ""
var currentUid: UInt = 0
func joinChannel(token: String, channelName: String, uid: UInt) {
self.currentChannelName = channelName
self.currentUid = uid
let options = AgoraRtcChannelMediaOptions()
options.clientRoleType = .audience // 默认观众
options.publishMicrophoneTrack = false // 观众不发布音频
options.autoSubscribeAudio = true // 自动订阅频道内所有音频
let result = agoraKit.joinChannel(
byToken: token,
channelId: channelName,
uid: uid,
mediaOptions: options
)
if result != 0 {
print("joinChannel 失败,错误码: \(result)")
}
}
autoSubscribeAudio = true 让观众自动收听频道内所有音频,不需要手动订阅每个发布者。publishMicrophoneTrack = false 确保以观众身份进入时不会意外发布音频。
六. 处理加入频道回调
实现 AgoraRtcEngineDelegate:
extension ChatRoomViewController: AgoraRtcEngineDelegate {
func rtcEngine(_ engine: AgoraRtcEngineKit,
didJoinChannel channel: String,
withUid uid: UInt,
elapsed: Int) {
print("加入频道成功: \(channel), uid: \(uid), 耗时: \(elapsed)ms")
DispatchQueue.main.async {
// 更新 UI,显示麦位初始状态
}
}
func rtcEngine(_ engine: AgoraRtcEngineKit,
didOfflineOfUid uid: UInt,
reason: AgoraUserOfflineReason) {
// 其他用户离线(主动离开或断线超时)
print("用户离线: uid=\(uid), reason=\(reason.rawValue)")
DispatchQueue.main.async {
// 清除对应麦位 UI
}
}
func rtcEngine(_ engine: AgoraRtcEngineKit,
didLeaveChannelWith stats: AgoraChannelStats) {
print("已离开频道, 通话时长: \(stats.duration)s")
}
}
所有 UI 更新都要切回主线程执行,SDK 回调在内部线程触发。
七. 上麦与下麦
iOS SDK 通过 updateChannel(with:) 切换角色,切换立即生效,不需要重新加入频道:
// 上麦:切换为 broadcaster,开始发布麦克风音频
func goOnMic() {
let options = AgoraRtcChannelMediaOptions()
options.clientRoleType = .broadcaster
options.publishMicrophoneTrack = true
agoraKit.updateChannel(with: options)
}
// 下麦:切回 audience,停止发布
func goOffMic() {
let options = AgoraRtcChannelMediaOptions()
options.clientRoleType = .audience
options.publishMicrophoneTrack = false
agoraKit.updateChannel(with: options)
}
切换结果通过 didClientRoleChanged 回调确认:
func rtcEngine(_ engine: AgoraRtcEngineKit,
didClientRoleChanged oldRole: AgoraClientRole,
newRole: AgoraClientRole,
newRoleOptions: AgoraClientRoleOptions?) {
DispatchQueue.main.async {
switch newRole {
case .broadcaster:
// 上麦成功,激活麦位 UI,显示发言状态
break
case .audience:
// 下麦成功,恢复麦位为空位 UI
break
@unknown default:
break
}
}
}
已在麦位上的 broadcaster 可以临时静音,不需要切换角色:
// 静音(仍是 broadcaster,只是暂停发送音频)
agoraKit.muteLocalAudioStream(true)
// 取消静音
agoraKit.muteLocalAudioStream(false)
房主强制静音他人,SDK 层面没有直接 API,需要通过信令通知目标用户的客户端执行 muteLocalAudioStream(true)。这个交互逻辑需要在信令层设计,不是 RTC 的责任范围。
八. Token 过期处理
extension ChatRoomViewController: AgoraRtcEngineDelegate {
// Token 即将过期(过期前 30 秒触发)
func rtcEngine(_ engine: AgoraRtcEngineKit,
tokenPrivilegeWillExpire token: String) {
fetchTokenFromServer(channelName: currentChannelName, uid: currentUid) { [weak self] newToken in
self?.agoraKit.renewToken(newToken)
// renewToken 成功后用户不会掉线,无感续期
}
}
// Token 已过期(tokenPrivilegeWillExpire 没有及时处理时触发)
func rtcEngineRequestToken(_ engine: AgoraRtcEngineKit) {
fetchTokenFromServer(channelName: currentChannelName, uid: currentUid) { [weak self] newToken in
guard let self = self else { return }
let options = AgoraRtcChannelMediaOptions()
options.clientRoleType = .audience
self.agoraKit.joinChannel(
byToken: newToken,
channelId: self.currentChannelName,
uid: self.currentUid,
mediaOptions: options
)
}
}
}
两个回调都要实现。tokenPrivilegeWillExpire 是提前换,用 renewToken 续期,用户无感知;rtcEngineRequestToken 是 Token 已经过期的兜底,需要重新加入频道。网络差的情况下 tokenPrivilegeWillExpire 可能没来得及处理,rtcEngineRequestToken 确保用户最终能恢复连接。
九. 离开频道和资源释放
func leaveChannel() {
agoraKit.leaveChannel(nil)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
leaveChannel()
}
deinit {
// destroy() 释放所有 SDK 资源,下次需要重新 sharedEngine(with:delegate:)
AgoraRtcEngineKit.destroy()
}
leaveChannel 和 destroy 分开调用。如果 ViewController 销毁时没有主动调用 leaveChannel,SDK 会在 destroy 时自动处理,但主动调用能确保回调正常触发,便于做离开频道后的 UI 清理。
十. AVAudioSession 注意事项
声网 SDK 在加入频道时会自动配置 AVAudioSession,设置合适的 category 和 options 来支持录音、播放和蓝牙耳机切换。多数情况下不需要手动配置。
有其他代码操作 AVAudioSession 时需要注意时序:
- 在调用
joinChannel之前不要修改AVAudioSession.category,SDK 加入频道时会覆盖配置 - 退出频道后,SDK 会将 AVAudioSession 恢复到接入前的状态
- 如果某个第三方库在 SDK 工作期间修改了 AVAudioSession,可能导致 SDK 行为异常,需要排查是哪一方修改了 session
处理电话打断
系统电话进来时,AVAudioSession 会被中断,SDK 会暂停音频。通话结束后多数情况 SDK 会自动恢复,但某些机型需要手动处理:
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(
self,
selector: #selector(handleAudioInterruption),
name: AVAudioSession.interruptionNotification,
object: nil
)
}
@objc func handleAudioInterruption(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return }
if type == .ended {
let options = (userInfo[AVAudioSessionInterruptionOptionKey] as? UInt)
.flatMap { AVAudioSession.InterruptionOptions(rawValue: $0) }
if options?.contains(.shouldResume) == true {
try? AVAudioSession.sharedInstance().setActive(true)
}
}
}
十一. 音频质量配置
在 setChannelProfile 之后添加:
// 标准语音质量,普通语聊场景(默认值)
agoraKit.setAudioProfile(.default)
// 高质量音乐,KTV / 合唱场景
agoraKit.setAudioProfile(.musicHighQuality)
普通社交语聊用默认配置足够。KTV 场景对音质保真度和延迟有更高要求,用 .musicHighQuality,同时需要配合耳返功能。
十二. 常见问题
模拟器里没有音频
iOS 模拟器不支持麦克风,joinChannel 可以成功,但无法发布音频。测试麦克风相关功能必须用真机。
进频道后收不到其他人的声音
先确认发送方已设为 .broadcaster 且 publishMicrophoneTrack = true。通过 reportAudioVolumeIndication 可以实时监控频道内各用户的音量,用来区分是发送端还是接收端的问题:
// 每 200ms 上报一次音量数据
agoraKit.enableAudioVolumeIndication(200, smooth: 3, reportVad: false)
func rtcEngine(_ engine: AgoraRtcEngineKit,
reportAudioVolumeIndicationOfSpeakers speakers: [AgoraRtcAudioVolumeInfo],
totalVolume: Int) {
for speaker in speakers {
print("uid: \(speaker.uid), volume: \(speaker.volume)")
}
}
uid 为 0 代表本地用户。如果本地有音量但远端收不到,问题在网络或发布配置;如果本地也没有音量,先检查麦克风权限和 AVAudioSession 状态。
蓝牙耳机连接后没有声音
iOS 蓝牙音频需要 AVAudioSession 开启 .allowBluetooth 选项。SDK 默认会配置,如果遇到问题,检查是否有其他代码在 SDK 运行期间覆盖了 AVAudioSession 的 options,把 .allowBluetooth 去掉了。
进入后台后音频停了
确认 Xcode 里 Background Modes 已勾选 Audio 选项,并且 Info.plist 里 UIBackgroundModes 包含 audio 字符串。如果是 Xcode 自动生成的旧项目,有时候要手动检查 Info.plist 里的值是否实际写入。
特定机型有明显回声
SDK 的 AEC(回声消除)自动处理大多数情况。如果特定机型外放时仍有回声,通常是扬声器和麦克风之间距离近导致 AEC 消不干净,让用户使用耳机是最直接的解决方式。也可以检查是否有其他 App 占用了麦克风,导致 SDK 拿到的音频已经被处理过。
