基于声网 Agora RTM + RTC SDK 实现 iOS 语音聊天室 —— 常见问题汇总 & 解决方案手册

11 阅读9分钟

目录

  1. 【无声/单边无声】—— 占问题的 60%+
  2. 【Token 相关报错】—— 进房失败、中途断连
  3. 【RTC 错误码速查】—— -102 / -121 / -17 / -7 / -3
  4. 【RTM 登录/发消息报错】—— NOT_LOGIN / TOKEN_EXPIRED / TIMEOUT / REJECTED
  5. 【RTM 断线重连状态机】—— 你到底该不该手动 re-login
  6. 【麦克风权限 & iOS 隐私弹窗】—— 真机静默失败的高危坑
  7. 【音频路由错乱】—— 插耳机/连蓝牙后声音跑到听筒
  8. 【后台/锁屏后没声音】—— iOS 系统限制
  9. 【iOS 14 本地网络弹窗】—— RTM SDK 经典惊喜
  10. 【内存/生命周期】—— sharedEngine重复初始化、没 leaveChannel 就 rejoin
  11. 【调试利器】—— 开日志 + Agora Analytics

一、无声 / 单边无声(最常见)

症状分类

表现典型指向
本地能说话,远端听不到本地采集没起来(权限/路由/mute)
远端能说话,本地听不到远端没发流 或 本地没 sub(role/options 配错)
互相都听不到channel 不一致 / Token 不对 / App ID 不匹配
刚进房有声音,几秒后没了Token 过期 / 被 mute / AVAudioSession 被别的模块改了

排查清单(按顺序做)

✅ Step 1:先确认两人真的在同一个 channel

swift
swift
// 你收到的回调
func rtcEngine(_ engine: AgoraRtcEngineKit,
               didJoinChannel channel: String,
               withUid uid: UInt,
               elapsed: Int) {
    print("✅ joined channel = (channel), uid = (uid)")
}

很多"无声"本质是:两个人进了不同 channel(前后带空格、大小写不一致、拼接参数写错)。channel name 必须完全一致。


✅ Step 2:确认没被 mute / 音量设 0

语聊房最常见的"手滑式无声":

swift
swift
// ⚠️ 这些调用都会直接导致没声音
engine.muteLocalAudioStream(true)           // 本地不发流
engine.muteAllRemoteAudioStreams(true)       // 不听任何人
engine.adjustRecordingSignalVolume(0)       // 采集音量 0
engine.adjustPlaybackSignalVolume(0)        // 播放音量 0

自检代码(调试时打出来):

swift
swift
print("recording vol =", engine.recordingSignalVolume())
print("playback vol  =", engine.playbackSignalVolume())

✅ Step 3:确认 AgoraRtcChannelMediaOptions配对了

这是 4.x 最容易配错的一步

swift
swift
let opts = AgoraRtcChannelMediaOptions()

// 主播(上麦)
opts.clientRoleType         = .broadcaster
opts.publishMicrophoneTrack  = true    // ← 必须是 true,否则远端听不到你
opts.publishCameraTrack      = false
opts.autoSubscribeAudio      = true

// 观众(听别人)
opts.clientRoleType         = .audience
opts.publishMicrophoneTrack = false
opts.autoSubscribeAudio     = true     // ← 必须是 true,否则你听不到别人
opts.autoSubscribeVideo     = false

如果你用老 API(joinChannelByToken不带 mediaOptions),或者 publishMicrophoneTrack忘了设,didJoinedOfUid会触发但音频 tracks 没发布​ → 表现为"能看到人进来但没声音"。


✅ Step 4:Info.plist麦克风权限(真机必查)

xml
xml
<key>NSMicrophoneUsageDescription</key>
<string>语音聊天室需要访问麦克风</string>

iOS 10+ 如果这行缺失,系统会 直接拒绝授权且不弹窗,回调立刻返回 denied,表现为"进房成功但始终无声"。

另外在代码中主动检查:

swift
swift
override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    AVAudioSession.sharedInstance().requestRecordPermission { granted in
        if !granted {
            // 弹引导去设置的 alert
        }
    }
}

✅ Step 5:检查是否被其他 App 抢占麦克风

官方枚举明确列出了 iOS 特有错误:

错误含义解法
AgoraAudioLocalErrorDeviceNoPermission (2)没麦克风权限引导去 设置→隐私→麦克风
AgoraAudioLocalErrorDeviceBusy (3)麦克风被其他 App 占用(微信通话/Siri/录音中等)提示用户关闭其它录音 App;空闲约 5s 后自动恢复,或 rejoin
AgoraAudioLocalErrorInterrupted (8)被来电 / Siri / 闹钟中断中止干扰源后可恢复

你还可以通过回调监听:

swift
swift
func rtcEngine(_ engine: AgoraRtcEngineKit,
               localAudioStateChanged state: AgoraAudioLocalState,
               error: AgoraAudioLocalError) {
    print("audio state=(state.rawValue), error=(error.rawValue)")
}

二、Token 相关报错(进不去 / 中途掉了)

典型报错码

错误码常在哪里看到含义
AgoraErrorCodeInvalidAppId (101/-3)join 失败App ID 填错 / 项目没启用对应服务
AgoraErrorCodeInvalidToken (110)join 返回非 0Token 跟 App ID / channel / uid 不匹配
AgoraErrorCodeTokenExpired (109)中途突然掉Token 生存期到了

✅ 正确姿势:监听两个回调 + renew

swift
swift
// 1) Token 即将过期 —— 提前 30s 通知你换新
func rtcEngine(_ engine: AgoraRtcEngineKit,
               tokenPrivilegeWillExpire token: String) {
    print("⚠️ Token will expire, renewing...")
    fetchNewTokenFromServer { newToken in
        engine.renewToken(newToken)
    }
}

// 2) Token 已过期(极端:网络抖动导致来不及 renew)
func rtcEngineRequestToken(_ engine: AgoraRtcEngineKit) {
    print("❌ Token expired, rejoin required")
    fetchNewTokenFromServer { newToken in
        engine.leaveChannel(nil)
        // 重新 joinChannel(byToken:newToken:...)
    }
}

官方建议两种更新路径:

  • 优先:调 renewToken(_:)(不断连热更新)
  • 兜底leaveChannel→ 拿新 Token → joinChannel重新加入

⚠️ 常见错误:Token 算错了 uid 不匹配(服务端你把 uid 当 "123",SDK 里传 123/0混用)→ 表现为 INVALID_TOKEN


三、RTC 经典错误码速查

错误码符号触发场景怎么修
-102AgoraErrorCodeJoinChannelRejected / invalid channel namechannelId 含非法字符 / nil / 超长限制 channelId 正则 [a-zA-Z0-9_-]{1,64}
-121invalid uiduid=0 混用没问题,但你如果手动指定 uid,不能是 0 或负数用 0(SDK 分配)或服务端分配 1~UINT32_MAX-1
-17AgoraErrorCodeJoinChannelRejected已经在频道里又调了一次 joinChannel先 leaveChannel或判断 connectionChangedToState:reason:的状态
-7not initializedsharedEngine还没走完就用它保证 setupEngine 在 join 之前
-8invalid state典型:调 startEchoTest后没 stopEchoTest就 join清理测试流程

四、RTM 登录/发消息报错速查

错误码含义修法
-10001 NOT_INITIALIZEDSDK 没 init 就调 API先 AgoraRtmClientKit(config:)
-10002 NOT_LOGIN没 login 就发消息/进频道等 login 成功回调再 joinChannel
-10003 INVALID_APP_IDApp ID 错 或 没开通 RTM 服务控制台确认项目启用 RTM
-10005 INVALID_TOKENToken 格式错 / 跟 userId 不匹配确认 RTM Token 的服务端生成参数
-10009 TOKEN_EXPIREDRTM Token 过期换新 RTM Token → loginByToken:
-10011 LOGIN_TIMEOUT12s 内没连上查网络 / 代理 / 防火墙白名单
-10012 LOGIN_REJECTEDuserId 被封禁 / App ID 没开 RTM控制台查封禁
-10013 LOGIN_ABORTED同 userId 在其他端登录挤掉做"互踢"策略或允许多端共存(不同 uid)

五、RTM 断线重连 —— 你到底要不要手动 re-login?

官方状态机逻辑(重要)

RTM 2.x 的连接状态迁移:

纯文本
纯文本
IDLE
 ↓ login
CONNECTING → CONNECTED  ✅
       ↑
    断网 4s+
       ↓
  RECONNECTING  ← SDK 自动重试(你别管)
       ↓
  ┌─ 30s 内恢复 → CONNECTED(在线状态不变)
  └─ 超过 30s 仍未恢复 → 你被从在线列表移除 → 之后 recovery 成功也要重新 sync 状态
       ↓ 极端:2min 都无法恢复
   FAILED(不会自动重试,你要手动 login)

✅ 正确写法

swift
swift
func rtmClient(_ client: AgoraRtmClientKit,
               didReceiveLinkStateEvent event: AgoraRtmLinkStateEvent) {

    let cur  = event.currentState
    let code = event.reasonCode

    switch cur {
    case .connected:
        print("✅ RTM connected")
        // 重新 join 消息频道(如果需要)
    case .reconnecting:
        print("🔄 RTM reconnecting, reason=(code)")
    case .disconnected:
        print("⚠️ RTM disconnected")
    case .failed:
        // ⚠️ SDK 不会自动重连了
        print("❌ RTM FAILED reason=(code)")
        // 你的业务决定是否 retry login
        // 但要防雪崩:加退避(exponential backoff)
    default: break
    }
}

关键原则:RECONNECTING阶段不要主动 logout+login,让 SDK 自己跑;只有到 FAILED才介入。


六、麦克风权限 & 真机"静默失败"坑

这个坑的特征:模拟器好像能跑、真机进去不弹授权框、也不崩、就是没声音

根因:iOS 10+ 强制检查 Info.plist的 NSMicrophoneUsageDescription

完整合规检查清单

xml
xml
<!-- Info.plist -->
<key>NSMicrophoneUsageDescription</key>
<string>语音聊天室需要通过麦克风进行实时语音交流</string>

并在首次进房前触发一次:

swift
swift
AVAudioSession.sharedInstance().requestRecordPermission { ok in
    DispatchQueue.main.async {
        if ok { self.joinChannel() }
        else { /* 弹设置引导 */ }
    }
}

Apple 审核层面要注意:

  • 描述字符串不能空着也不能写废话("用于App功能"会被拒)
  • 多语言包要对应上

七、音频路由错乱(插耳机/连蓝牙后声音跑听筒)

语聊房默认路由策略:

SDK 场景默认路由
Audio SDK + 通信场景听筒(earpiece) ​ ← 很多人以为这是 bug
Live Broadcasting扬声器(speaker)

✅ 语音聊天室推荐配置

swift
swift
// 1) 进房前:设默认走扬声器(绝大多数语聊房期望的行为)
engine.setDefaultAudioRouteToSpeakerphone(true)

// 2) 进房后:动态切换
engine.setEnableSpeakerphone(true)   // 扬声器
engine.setEnableSpeakerphone(false)  // 回落听筒(少见)

⚠️ setDefaultAudioRouteToSpeakerphone必须在 join 之前调,setEnableSpeakerphone在 join 之后调,调反了不生效。

如果用户的蓝牙耳机优先级更高,iOS 的音频路由优先级是:用户物理行为(插拔耳机/蓝牙)> 你的 setEnableSpeakerphone。这是系统设计,不要跟它对抗——能做的是监听路由变化后同步 UI 状态。


八、后台/锁屏后没声音(iOS 系统限制)

iOS 12.4+ 系统限制:App 切后台,系统自动停采集

✅ 要在 Xcode 里加 Background Modes:

纯文本
纯文本
Signing & Capabilities → + Capability → Background Modes
☑️ Audio, AirPlay, and Picture in Picture
☑️ Background processing

同时确保:

  • 用户在前台已 join 成功
  • 没调过 disableAudio()disableLocalAudio()
  • SDK 的 localAudioStateChanged回调报告过 AgoraAudioLocalStateRecording

语聊房的现实取舍:加了 Audio Background Mode 后 App 可以在后台维持音频采集,但 Apple 审核可能追问你的后台必要性(如果只是"听"不需要采集,观众角色可以不申请)。


九、iOS 14 的"查找本地网络设备"弹窗

RTM SDK 早期版本在 iOS 14 触发本地网络权限弹窗。

解决方案(二选一)

  1. 升 RTM SDK ≥ 1.4.1(官方修了,弹窗不再出现,服务不受影响)
  2. 或在 Info.plist加描述占位(不推荐但可应急)
xml
xml
<key>NSLocalNetworkUsageDescription</key>
<string>语音聊天室需要本地网络以连接服务</string>

十、生命周期坑:重复 init / 没 leaveChannel 就 rejoin

❌ 典型翻车代码

swift
swift
// 用户快速点两次"进入房间"
func onTapEnter() {
    setupEngine()        // 又创了一次 sharedEngine
    joinChannel(...)     // 上一次还在频道里 → -17 或被覆盖
}

✅ 正确模式

swift
swift
final class VoiceRTCService {

    private(set) var engine: AgoraRtcEngineKit!
    private var hasJoined = false

    func ensureEngine(appId: String) {
        if engine == nil {
            let cfg = AgoraRtcEngineConfig()
            cfg.appId = appId
            cfg.channelProfile = .liveBroadcasting
            engine = .sharedEngine(with: cfg, delegate: self)
            engine.disableVideo()
            engine.enableAudio()
        }
    }

    func joinChannel(...) {
        guard !hasJoined else {
            print("⚠️ already in channel, skip or leave first")
            return
        }
        hasJoined = true
        engine.joinChannel(...)
    }

    func leaveChannel() {
        guard hasJoined else { return }
        engine.leaveChannel { _ in
            self.hasJoined = false
        }
    }

    deinit { /* 页面销毁时:leaveChannel 后 destroy */ }
}

十一、调试利器:开日志 + Console 控制台

1) 开 RTC 日志

swift
swift
let cfg = AgoraRtcEngineConfig()
cfg.appId = appId
cfg.channelProfile = .liveBroadcasting
cfg.logConfig.level = .info   // .debug 更详细
cfg.logConfig.filePath = NSTemporaryDirectory() + "agora_rtc.log"

2) RTM 2.x 开日志

swift
swift
let logCfg = AgoraRtmLogConfig()
logCfg.level = .info
let cfg = AgoraRtmClientConfig(appId: appId, userId: uid)
cfg.logConfig = logCfg

3) Agora Console(analytics)

官方无声排查流程建议你把 频道名 + 出问题的 uid + 时间段​ 记下来,用控制台里的 Agora Analytics​ 看每个用户的进房/发流/收流状态,比盲猜效率高 10 倍。


🧰 速查急救表(贴在你工位上那种)

现象先看哪一句定位
进房成功但没声音mediaOptions.publishMicrophoneTrack是不是 false / 有没有 mute
两人像在不同房间channelId 字符串前后空格、大小写、拼接 bug
join 返回 -102channelId 合法性非法字符 / nil
join 返回 -121uid你传了 0 但服务端又期望固定 uid
突然掉线tokenPrivilegeWillExpireToken 到期没 renew
RTM 发消息报 -10002login 状态机没等 login 成功就 joinChannel
锁屏后没声音Background Modes没勾 Audio/AirPlay
真机无声模拟器好使Info.plist缺 NSMicrophoneUsageDescription
iOS 14 本地网络弹窗RTM 版本升 RTM ≥ 1.4.1