基于声网 Agora RTM + RTC SDK 实现 iOS 语音聊天室——从零到可跑的指南

30 阅读7分钟

一、为什么语音聊天室需要 RTM + RTC 两套 SDK

完整的语音聊天室至少需要解决两类问题:

负责什么用哪个 SDK
音频流传输层麦克风采集 → 编码 → 网络传输 → 远端播放RTC SDKAgoraRtcKit
信令/消息层用户上下麦通知、聊天室文字消息、在线人数同步、房主踢人、送礼等RTM SDKAgoraRtmKitShengwangRtm

RTC 管声音,RTM 管"谁在说话/谁上了麦/大家聊了什么"。 ​ 两者配合,才是完整方案。

二、前置准备

1. 声网控制台操作

  1. 前往 声网控制台创建项目,拿到 App ID
  2. 如果需要正式环境 Token,还需获取 App 证书,在服务端签发 Token
  3. 测试阶段可以直接在控制台生成 临时 Token(有效期 24 小时)
  4. 如果使用 RTM 的高级特性(如 Storage / Lock),需在控制台为项目启用 RTM 功能

2. 环境要求

项目最低版本
iOS Deployment TargetiOS 11.0+ (建议 13.0+)
Xcode26.0+(苹果提审需要)
语言Swift(本文示例用 Swift)
真机必须有,模拟器无法采集麦克风

别忘了在 Info.plist中添加麦克风权限描述:

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

三、SDK 集成(CocoaPods)

Podfile

platform :ios, '13.0'
target 'VoiceChatRoom' do
  use_frameworks!

  # RTC —— 纯语音场景用 AgoraAudio_iOS 体积更小
  pod 'AgoraAudio_iOS', '~> 4.3.0'

  # RTM —— 2.2.7+ 新包名
  pod 'ShengwangRtm', '~> 2.2.8'
end

注意:RTC 有多个子 pod。如果你的语聊房不带视频,选 AgoraAudio_iOS即可,包体积比全量 AgoraRtcEngineKit小很多。如果同时集成了 2.2.0+ 的 RTM 和 4.3.0+ 的 RTC,注意看官方 FAQ 里的链接冲突处理。

终端执行:

pod install --repo-update
open VoiceChatRoom.xcworkspace

四、架构设计与角色模型

一个标准语音聊天室的角色划分:

┌──────────┐
│   Room   │  channelId = 房间ID
│ Owner    │← 房主(固定 0 号麦位 / 第一个主播)
│ Mic #1   │← 主播(clientRole = broadcaster, publishMicrophone = YES)
│ Mic #2   │
│ ...      │
│ Audience │← 观众(clientRole = audience,   autoSubscribeAudio = YES)
└──────────┘

关键设计原则

  • RTC Channel​ = 音频房间,所有人 join 同一个 channelId,靠 clientRoleType区分能不能发流
  • RTM Channel​ = 消息频道(聊天室消息 + 信令广播),用于文字聊天、上麦申请/通知
  • 房主和主播 → broadcaster;观众 → audience;观众想说话时必须先切角色 → 上麦

五、RTC 层:音频引擎初始化 & 加入频道

1. 创建引擎

import AgoraRtcKit

class VoiceChatManager: NSObject {

    private(set) var engine: AgoraRtcEngineKit!
    private let appId = "YOUR_APP_ID"

    func setupEngine() {
        let config = AgoraRtcEngineConfig()
        config.appId = appId
        // 语聊房推荐 LIVE_BROADCASTING
        config.channelProfile = .liveBroadcasting

        engine = AgoraRtcEngineKit.sharedEngine(with: config, delegate: self)

        // ✅ 只启用音频,禁用视频(省资源)
        engine.disableVideo()
        engine.enableAudio()

        // 推荐:开启耳返时需要的话
        // engine.enableInEarMonitoring(true)

        // 音频场景调为语聊(AGORA_AUDIO_SCENARIO_CHATROOM 的封装)
        engine.setAudioProfile(.default, scenario: .chatroom)
    }
}

2. 加入频道(区分角色)

extension VoiceChatManager {

    /// 加入语音房
    /// - Parameters:
    ///   - channel: 房间ID / 频道名
    ///   - token:  临时Token 或 服务端签发Token
    ///   - asBroadcaster: 是否以主播身份(YES=上麦 / NO=观众)
    func joinChannel(
        channel: String,
        token: String?,
        asBroadcaster: Bool
    ) {
        let options = AgoraRtcChannelMediaOptions()

        options.clientRoleType = asBroadcaster ? .broadcaster : .audience
        options.publishMicrophoneTrack = asBroadcaster
        options.publishCameraTrack = false          // 纯语音
        options.autoSubscribeAudio = true           // 自动拉取远端音频
        options.autoSubscribeVideo = false

        engine.joinChannel(
            byToken: token,
            channelId: channel,
            uid: 0,           // 0 = SDK 随机分配
            mediaOptions: options
        ) { channel, uid, elapsed in
            print("✅ joinChannel success: (channel), uid=(uid)")
        }
    }

    /// 离开频道 & 销毁
    func leaveChannel() {
        engine.leaveChannel { stats in
            print("left channel, duration=(stats.duration)")
        }
        // 如果整个会话结束:
        // AgoraRtcEngineKit.destroy()
    }
}

3. RTC 回调监听(谁上了麦 / 谁下了麦)

swift
swift
extension VoiceChatManager: AgoraRtcEngineDelegate {

    // 远端用户加入(开始发流时也会触发)
    func rtcEngine(_ engine: AgoraRtcEngineKit,
                   didJoinedOfUid uid: UInt,
                   elapsed: Int) {
        print("🎙️ remote user joined: (uid)")
        NotificationCenter.default.post(
            name: .voiceChatUserDidJoin,
            object: nil,
            userInfo: ["uid": uid]
        )
    }

    // 远端用户离开
    func rtcEngine(_ engine: AgoraRtcEngineKit,
                   didOfflineOfUid uid: UInt,
                   reason: AgoraUserOfflineReason) {
        print("🎙️ remote user offline: (uid), reason=(reason.rawValue)")
        NotificationCenter.default.post(
            name: .voiceChatUserDidLeave,
            object: nil,
            userInfo: ["uid": uid]
        )
    }

    // 错误 & 警告
    func rtcEngine(_ engine: AgoraRtcEngineKit, didOccurError errorCode: AgoraErrorCode) {
        print("❌ RTC error: (errorCode.rawValue)")
    }
}

// MARK: - Notifications
extension Notification.Name {
    static let voiceChatUserDidJoin  = Notification.Name("voiceChatUserDidJoin")
    static let voiceChatUserDidLeave = Notification.Name("voiceChatUserDidLeave")
}

这里 didJoinedOfUiddidOfflineOfUid就是你维护"麦位列表"的数据来源。观众端靠这些回调知道"现在谁在说话"。


六、RTM 层:实时消息(聊天文字 + 信令)

语聊房的 RTM 通常做两件事:

  1. 聊天室文字消息(广播给频道内所有人)
  2. 自定义信令——上麦申请、房主批准、踢人通知等(可用 JSON 透传)

1. 初始化 RTM & 登录

import ShengwangRtm   // 或 import AgoraRtmKit,看你 pod 用的哪个名字

class RTMManager: NSObject {

    private(set) var rtmClient: AgoraRtmClientKit?
    private let appId = "YOUR_APP_ID"
    private var currentUserId: String = ""

    // 消息频道引用(用于发广播消息)
    private var messageChannel: AgoraRtmChannel?

    func login(userId: String, token: String? = nil, completion: @escaping (Error?) -> Void) {
        self.currentUserId = userId

        let cfg = AgoraRtmClientConfig(appId: appId, userId: userId)
        // 日志级别按需开
        cfg.logLevel = .info

        var err: NSError?
        rtmClient = AgoraRtmClientKit(config: cfg, error: &err)
        if let e = err {
            completion(e)
            return
        }

        // 设置消息回调
        rtmClient?.addDelegate(self)

        // 登录 RTM(测试阶段 token 可为 nil 或用临时 token)
        rtmClient?.login(byToken: token) { [weak self] resp, errInfo in
            if let errInfo = errInfo, errInfo.errorCode != .ok {
                completion(NSError(domain: "RTM", code: Int(errInfo.errorCode.rawValue),
                                    userInfo: [NSLocalizedDescriptionKey: errInfo.reason ?? ""]))
                return
            }
            print("✅ RTM login success")
            completion(nil)
        }
    }

    func logout() {
        messageChannel?.release()
        messageChannel = nil
        rtmClient?.logout(nil)
    }
}

新版 RTM 2.x(ShengwangRtm)的入口类是 AgoraRtmClientKit,通过 AgoraRtmClientConfig(appId:userId:)初始化,再调 loginByToken:登录。

2. 加入 RTM 消息频道 & 收发消息

extension RTMManager {

    /// 加入 RTM 频道(通常与 RTC channelId 同名)
    func joinMessageChannel(_ channelId: String) {
        let chanCfg = AgoraRtmChannelConfig()
        messageChannel = rtmClient?.createChannel(channelId, config: chanCfg)

        messageChannel?.join { resp, errInfo in
            if errInfo?.errorCode == .ok {
                print("✅ RTM channel joined: (channelId)")
            }
        }
    }

    /// 发送聊天文字消息
    func sendChat(text: String) {
        let msg = AgoraRtmMessage(text)
        messageChannel?.send(msg) { resp, errInfo in
            if errInfo?.errorCode == .ok {
                print("📨 chat sent")
            }
        }
    }

    /// 发送自定义信令(JSON)
    func sendSignal(type: String, payload: [String: Any]) {
        var dict: [String: Any] = ["type": type]
        dict["data"] = payload
        guard let data = try? JSONSerialization.data(withJSONObject: dict),
              let text = String(data: data, encoding: .utf8) else { return }
        let msg = AgoraRtmMessage(text)
        msg.messageType = .custom  // 标记为非普通聊天
        messageChannel?.send(msg, completion: nil)
    }
}

3. 监听 RTM 消息回调

extension RTMManager: AgoraRtmClientDelegate {

    // RTM 频道消息回调
    func rtmChannel(_ channel: AgoraRtmChannel,
                    messageReceived message: AgoraRtmMessage,
                    from sender: String) {
        DispatchQueue.main.async {
            print("💬 [(sender)]: (message.stringData ?? "")")

            // 尝试解析信令 JSON
            if let data = message.stringData?.data(using: .utf8),
               let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
               let type = json["type"] as? String {

                NotificationCenter.default.post(
                    name: .voiceChatSignalReceived,
                    object: nil,
                    userInfo: ["type": type, "from": sender, "payload": json["data"] ?? [:]]
                )
            } else {
                // 纯聊天文字
                NotificationCenter.default.post(
                    name: .voiceChatTextMessageReceived,
                    object: nil,
                    userInfo: ["from": sender, "text": message.stringData ?? ""]
                )
            }
        }
    }

    // RTM 连接状态变化
    func rtmClient(_ client: AgoraRtmClientKit,
                   connectionStateChanged state: AgoraRtmClientConnectionState,
                   reason: AgoraRtmClientConnectionChangeReason) {
        print("RTM state: (state.rawValue), reason: (reason.rawValue)")
    }
}

extension Notification.Name {
    static let voiceChatTextMessageReceived = Notification.Name("voiceChatTextMessageReceived")
    static let voiceChatSignalReceived       = Notification.Name("voiceChatSignalReceived")
}

七、核心交互:上麦 / 下麦(角色切换)

这是语聊房最关键的 UX 动作——观众申请上麦 → 切角色 → 开始发流

extension VoiceChatManager {

    /// 观众 → 上麦(切换为 broadcaster,打开麦克风发布)
    func requestMicOn(completion: ((Bool) -> Void)? = nil) {
        // Step 1: 切角色
        engine.setClientRole(.broadcaster)

        // Step 2: 确保麦克风发布打开
        engine.muteLocalAudioStream(false)

        // 通知远端(通过 RTM 信令)
        // rtm.sendSignal(type: "mic_on", payload: ["uid": myUid])
        completion?(true)
    }

    /// 主播 → 下麦(切回 audience,停止发流)
    func micOff() {
        engine.muteLocalAudioStream(true)
        engine.setClientRole(.audience)

        // rtm.sendSignal(type: "mic_off", payload: ["uid": myUid])
    }

    /// 本地静音(不发流但不下麦)
    func toggleMuteLocal(_ muted: Bool) {
        engine.muteLocalAudioStream(muted)
    }
}

⚠️ setClientRole(.audience)vs muteLocalAudioStream(true)的区别:前者是角色切换(不再作为"发言者"出现在远端列表),后者只是静音但仍在发空流/保连接。语聊房一般用角色切换更干净。


八、一个简单的 ViewController 串起来

swift
swift
class VoiceRoomVC: UIViewController {

    private let rtc = VoiceChatManager()
    private let rtm = RTMManager()
    private let roomId = "room_1001"
    private let token: String? = nil   // 临时 token

    override func viewDidLoad() {
        super.viewDidLoad()
        rtc.setupEngine()

        // 1. RTM 登录
        let userId = "user_(Int.random(in: 1000...9999))"
        rtm.login(userId: userId) { err in
            guard err == nil else { print("RTM login fail"); return }
            // 2. 加入 RTM 消息频道
            self.rtm.joinMessageChannel(self.roomId)
            // 3. 加入 RTC(观众身份先进房收听)
            self.rtc.joinChannel(channel: self.roomId, token: self.token, asBroadcaster: false)
        }
    }

    // IBAction: 点击"上麦"
    @IBAction func onTapMicOn() {
        rtc.requestMicOn()
    }

    @IBAction func onTapSendMessage() {
        rtm.sendChat(text: "Hello 语聊房 👋")
    }

    deinit {
        rtc.leaveChannel()
        rtm.logout()
    }
}

九、常见踩坑 Checklist ✅

问题原因 & 解法
进房没声音忘记调 enableAudio()/ 忘了 autoSubscribeAudio = true/ Info.plist 没加麦克风权限
模拟器能跑但真机无声真机必须授麦克风权限,且第一次进房前系统弹框要允许
RTM 和 RTC pod 冲突(duplicate symbols)确认 RTM ≥ 2.2.0 与 RTC ≥ 4.3.0 时的官方 FAQ 处理方式,或用 ShengwangRtm新包名
Token 过期后音频断了监听 rtcEngine(_:tokenPrivilegeWillExpire:)回调,去后端换新 token 后调 renewToken:
上麦后远端听不到确认 publishMicrophoneTrack = truesetClientRole(.broadcaster),且没被 muteLocalAudioStream(true)静音着
语聊房耗电/发热disableVideo()setAudioProfile(.default, scenario: .chatroom)、退出时 leaveChannel+ 适时 destroy

十、总结 & 下一步

至此你已经有了一个最小可跑的语音聊天室骨架

RTC ──→ 音频流传输(谁发声 / 谁静音 / 谁进出)

RTM ──→ 文字消息 + 信令(上麦申请 / 麦位状态 / 系统通知)

下一步可以做的事

  1. 服务端房间管理(创建房间、持久化麦位状态、踢人鉴权)—— 别让客户端自己当"房主权威"
  2. Token 安全方案——生产环境务必用 App 证书在服务端签发 RTC + RTM Token
  3. 麦位队列 UI——把 didJoinedOfUiddidOfflineOfUid映射到固定麦位 Grid
  4. 声音增强——AI 降噪(setAudioProfile高级参数)、耳返、音量指示(enableAudioVolumeIndication
  5. RTM Storage / Lock——用 RTM 的分布式锁做"同一时刻只有一个人操作麦位"的轻量原子控制