一、为什么语音聊天室需要 RTM + RTC 两套 SDK?
很多人第一次做语聊房会有疑问: "我只想传语音,用 RTC 不就够了?"
实际上一个完整的语音聊天室至少需要解决两类问题:
| 层 | 负责什么 | 用哪个 SDK |
|---|---|---|
| 音频流传输层 | 麦克风采集 → 编码 → 网络传输 → 远端播放 | RTC SDK (AgoraRtcKit) |
| 信令/消息层 | 用户上下麦通知、聊天室文字消息、在线人数同步、房主踢人、送礼等 | RTM SDK (AgoraRtmKit/ ShengwangRtm) |
简单记一句话:RTC 管声音,RTM 管"谁在说话/谁上了麦/大家聊了什么"。 两者配合,才是完整方案。
二、前置准备
1. 声网控制台操作
- 前往 声网控制台创建项目,拿到 App ID
- 如果需要正式环境 Token,还需获取 App 证书,在服务端签发 Token
- 测试阶段可以直接在控制台生成 临时 Token(有效期 24 小时)
- 如果使用 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 通常做两件事:
- 聊天室文字消息(广播给频道内所有人)
- 自定义信令------上麦申请、房主批准、踢人通知等(可用 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)vsmuteLocalAudioStream(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 ──→ 文字消息 + 信令(上麦申请 / 麦位状态 / 系统通知)
下一步可以做的事:
- 服务端房间管理(创建房间、持久化麦位状态、踢人鉴权)------ 别让客户端自己当"房主权威"
- Token 安全方案------生产环境务必用 App 证书在服务端签发 RTC + RTM Token
- 麦位队列 UI ------把
didJoinedOfUid/didOfflineOfUid映射到固定麦位 Grid - 声音增强 ------AI 降噪(
setAudioProfile高级参数)、耳返、音量指示(enableAudioVolumeIndication) - RTM Storage / Lock------用 RTM 的分布式锁做"同一时刻只有一个人操作麦位"的轻量原子控制