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

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

很多人第一次做语聊房会有疑问: "我只想传语音,用 RTC 不就够了?"

实际上一个完整的语音聊天室至少需要解决两类问题:

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

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


二、前置准备

1. 声网控制台操作

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

2. 环境要求

项目 最低版本
iOS Deployment Target iOS 11.0+ (建议 13.0+)
Xcode 13.0+
语言 Swift(本文示例用 Swift)
真机 必须有,模拟器无法采集麦克风

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

vbnet 复制代码
xml
xml
<key>NSMicrophoneUsageDescription</key>
<string>语音聊天室需要访问麦克风</string>

三、SDK 集成(CocoaPods)

Podfile

ruby 复制代码
ruby
ruby
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 里的链接冲突处理。

终端执行:

lua 复制代码
bash
bash
pod install --repo-update
open VoiceChatRoom.xcworkspace

四、架构设计与角色模型

一个标准语聊房的角色划分:

ini 复制代码
纯文本
纯文本
┌──────────┐
│   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. 创建引擎

csharp 复制代码
swift
swift
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. 加入频道(区分角色)

swift 复制代码
swift
swift
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
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")
}

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


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

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

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

1. 初始化 RTM & 登录

swift 复制代码
swift
swift
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 消息频道 & 收发消息

swift 复制代码
swift
swift
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 消息回调

swift 复制代码
swift
swift
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 动作------观众申请上麦 → 切角色 → 开始发流

swift 复制代码
swift
swift
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
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 = true+ setClientRole(.broadcaster),且没被 muteLocalAudioStream(true)静音着
语聊房耗电/发热 disableVideo()setAudioProfile(.default, scenario: .chatroom)、退出时 leaveChannel+ 适时 destroy

十、总结 & 下一步

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

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

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

下一步可以做的事

  1. 服务端房间管理(创建房间、持久化麦位状态、踢人鉴权)------ 别让客户端自己当"房主权威"
  2. Token 安全方案------生产环境务必用 App 证书在服务端签发 RTC + RTM Token
  3. 麦位队列 UI ------把 didJoinedOfUid/ didOfflineOfUid映射到固定麦位 Grid
  4. 声音增强 ------AI 降噪(setAudioProfile高级参数)、耳返、音量指示(enableAudioVolumeIndication
  5. RTM Storage / Lock------用 RTM 的分布式锁做"同一时刻只有一个人操作麦位"的轻量原子控制
相关推荐
白玉cfc6 小时前
【iOS】底层原理:类的加载
ios·objective-c·xcode
光电的一只菜鸡8 小时前
shell脚本开发技巧
开发语言·ios·swift
2501_916007479 小时前
iOS应用性能优化全面指南:从内存管理到工具使用
android·ios·性能优化·小程序·uni-app·iphone·webview
库奇噜啦呼10 小时前
【iOS】源码学习-类的加载
学习·ios·cocoa
ayqy贾杰10 小时前
我同事,40了,他vibe coding了个App
前端·ios·客户端
吠品11 小时前
Ubuntu下grep配合管道用的几个场景
ios·notepad++·iphone
人月神话-Lee12 小时前
【图像处理】框架设计——协议、值类型与工程化思维
图像处理·人工智能·ios·设计模式·架构·ai编程·swift
何乐乐12 小时前
【Taro 5.0 技术与实践】 - 高性能 iOS 渲染层与 TaroUI 跨端框架介绍
android·前端·ios