语聊房场景 RTM 最佳实践
- Web
更新时间不可用
场景介绍
语聊房中,房主创建房间、管理公告和麦位,听众进入房间收听音频,也可以申请上麦、聊天或发送互动。所有成员需要看到一致的麦位归属,并能区分主动闭麦、被房主静音和麦克风异常。
本文按一轮完整的使用流程介绍实现方式:
- 用户取得身份和访问凭证,建立 RTM 会话。
- 房主创建房间,听众加入同一房间;各端同步公告、麦位和在线成员,并加入 RTC 收听音频。
- 听众申请上麦,房主审批并更新麦位;听众确认自己获得麦位后发布音频。
- 在麦成员开闭麦,房主执行麦控,各端同步成员状态。
- 成员发送公屏消息、礼物或爱心,接收端校验后展示。
- 用户下麦、离房或切换房间;再次进入时恢复当前状态。
麦位归属与音频发布结果分别维护。 成员在麦位上但没有声音,可能是主动闭麦、强制静音、采集失败或发布失败,不能仅凭 RTC 音频流判断其是否已下麦。
职责分工
| 能力 | 负责 | 不负责 |
|---|---|---|
| RTC | 麦克风采集、音频发布与订阅、实时音频传输 | 麦位归属、房间公告、互动消息、成员业务状态 |
| RTM | 房间状态、成员在线状态、麦位协同、文本互动、业务事件 | 音频数据传输 |
核心规则
集成 RTM 时,遵循以下五条核心规则:
- 一个用户身份只创建一个 RTM client,生命周期跟随 App 会话或语聊模块;进出房间不重复创建、不重复登录。
- 一个语聊房对应一个 RTM Channel,以稳定、唯一的
roomId作为 Channel Name;进房即订阅,离房即退订。 - 按数据特性选能力:需要被后来者恢复的房间状态放 Channel Storage,随成员在线而存在的临时状态放 Presence State,只处理一次的动作用 Message。
- 订阅成功 ≠ 房间状态就绪,链路恢复 ≠ 业务可恢复,两组状态分别建模。
- 所有业务消息带统一信封,消费前校验版本、房间、目标、有效期和消息 ID。
一次完整的上麦按以下顺序执行,麦位归属由 RTM 确认,RTC 发布结果不反向改变麦位:
RTM 确认麦位归属 → RTC 发布音频 → 发布失败时保留麦位 → 区分采集故障与发布故障只有确认本地采集异常时,才通过 Presence 同步 microphoneError;采集正常但发布失败应单独提示,避免将网络发布故障显示为麦克风故障。
1. 为什么语聊房需要 RTM
RTM 在语聊房中主要解决三类业务问题:
- 表达业务状态。 RTC 音频流本身无法区分不在麦位、主动闭麦、采集异常或发布失败,麦位和成员状态需要独立同步。
- 恢复当前房间。 新加入或重连的用户需要直接获取完整麦位表、公告和在线成员,不能依赖过去的动作消息。
- 协同互动动作。 申请上麦、邀请上麦、公屏聊天、礼物和点赞通过实时消息完成。
这些需求对应以下能力:
| RTM 能力 | 在语聊房中的用途 |
|---|---|
| Message | 上麦协同、公屏消息、礼物、点赞等业务动作 |
| Presence | 在线成员列表及其临时状态(闭麦、麦克风异常) |
| Channel Storage | 麦位表、公告等房间权威状态,供新成员和重连成员直接读取 |
| Lock | 多个角色可修改麦位时的写入互斥 |
2. 设计原则
本节内容与平台无关。
Demo 与生产接入
Demo 的浏览器房间目录、昵称和准入逻辑用于演示,不构成服务端鉴权。生产环境应由业务服务端维护用户身份、房间目录、成员准入和权限,并提供 Token 服务。部署步骤见 部署 Token 服务器。
2.1 RTM client 与会话同生命周期
一个 RTM 用户身份只维护一个 client。client 可在 App 初始化时创建,是否立即登录取决于语聊在 App 中的定位:
- 语聊是核心功能:App 取得用户身份后即登录,App 退出或用户身份失效时登出;
- 语聊是附加功能:进入语聊模块时登录,退出整个模块时登出,避免闲置连接占用资源。
无论采用哪种生命周期,都要遵守:
- 所有事件监听在
login()之前注册,且只注册一次。否则可能收不到首次连接状态或登录期间到达的事件。 - 进出房间只对应
subscribe/unsubscribe。切房是先退订旧频道、再订阅新频道,不重建 client、不重复登录。 - 网络重连期间继续使用当前 client,等待 SDK 自动重连和随后的全量事件,不手动重登、不盲目重订阅。
- App 层持有 SDK 原始事件,按当前房间分发。切房时更换房间事件处理器,避免旧房间迟到的事件更新新房间页面。
2.2 一个房间对应一个 Channel
以业务 roomId 作为 RTM Channel Name,roomId 在当前 App ID 下唯一且稳定:
| 语聊房 | RTM Channel Name |
|---|---|
| 房间 A | voice-room-a8f31c |
| 房间 B | voice-room-c27d90 |
同一房间的房主与听众订阅同一 Channel,不同房间订阅不同 Channel。不要让多个房间共用一个 Channel 再靠消息字段区分:各房间会互相收到 Presence、Storage 和 Message,既浪费消息量,也容易误处理他房数据。
语聊房需要同时接收消息、Presence 和 Metadata。先在声网控制台的 实时消息 RTM > 功能配置 中确认已开启 Presence 和 Storage,再在订阅时设置 withMessage、withPresence 和 withMetadata。如果多个角色需要通过 Lock 协同写入,还需在控制台开启 Lock,并设置 withLock;只有单一角色写麦位时无需开启。控制台操作见 开通 RTM 服务。
切房原则:先完整离开旧房间(停止新写入 → 等待关键写入结束 → 退出 RTC → 退订频道 → 清理本地状态与事件处理器),再进入新房间。用户连续切房时,客户端必须能识别并忽略旧房间迟到的异步结果,并对迟到成功的旧频道订阅补做一次退订。实现方式见 3.4 切换与离开房间。
2.3 按数据特性选择 Storage、Presence、Message
根据数据的有效期和接收范围选择能力:
| RTM 能力 | 适合的数据 | 语聊房示例 | 新成员是否需要获得 |
|---|---|---|---|
| Channel Storage | 房间当前的权威状态 | 公告、完整麦位表 | 是 |
| Presence State | 与在线成员绑定的临时状态 | 自主闭麦、麦克风异常 | 是,仅在成员在频道期间有效 |
USER Message | 只发给一个人的瞬时动作 | 申请上麦、邀请上麦、拒绝邀请 | 否 |
MESSAGE Message | 发给房间所有人的瞬时动作 | 公屏消息、礼物、点赞 | 否 |
Channel Storage:保存当前结果
后加入或重连的成员仍需读取的数据写入 Channel Storage。麦位表不能靠"某人刚刚上麦"的消息累加得出,因为晚加入者收不到之前的消息。
- 麦位由房主单点写入,避免多端并发修改;若有多个角色可管理麦位,配合 RTM Lock 使用。
- Storage 的
SNAPSHOT和UPDATE事件都携带完整的频道 Metadata。收到storage事件后,直接以最新快照替换本地房间状态;需要提示"某用户上麦"时,比较前后快照中对应麦位的userId是否从null变为具体用户即可。
Presence State:保存在线临时状态
Presence State 保存用户自己在频道期间有效的状态。每次只写入发生变化的字段;成员下麦后删除只在麦位上有意义的字段。收到 Presence SNAPSHOT 时全量替换在线成员列表,收到加入、离开或状态变化事件时增量更新。
用户离开频道后,其 Presence State 会同时从 RTM 服务端删除,因此不要用 Presence State 保存长期用户资料;长期资料使用 User Storage。
Message:传递瞬时动作
Message 用于发生后只处理一次的动作。只发给一个参与者的动作使用 USER Channel;房间内所有成员都要接收的互动使用当前房间的 MESSAGE Channel。
publish()的成功语义取决于 Channel Type:
- 使用
USERChannel 发送点对点消息时,publish()成功表示目标用户已经收到消息,但不代表目标用户已经完成对应的业务处理。- 使用
MESSAGEChannel 发送频道消息时,publish()成功表示消息已经发送到 RTM 服务端。服务端随后向频道内其他成员推送消息;其他成员能否收到,取决于其与 RTM 服务端的连接状态和网络质量。
因此,申请上麦等点对点动作不能以发送成功视为流程完成:审批后的麦位归属必须写入 Channel Storage,各端最终以 Storage 中的完整麦位表为准。
2.4 区分"频道已订阅"与"房间状态已就绪"
subscribe() 完成只表示 SDK 的订阅调用已结束,不表示首个 Presence 或 Storage 快照已到达。客户端需要分别维护两个状态:
- 频道已订阅:
subscribe()成功,可以结束"正在加入"提示、开始 RTC 入房; - 房间状态已就绪:当前房间的完整 Storage 快照已到达,依赖麦位或公告的操作才可开放。
不要把等待首个快照合并进订阅的 Promise 中。
2.5 重连:链路恢复与业务恢复分开判断
网络中断后,把房间界面置为"恢复中"并禁用依赖实时状态的操作。连接恢复后不重建 client、不重复 login()、不主动重复 subscribe(),继续处理 SDK 随后下发的全量事件,并分三步判断恢复程度:
| 信号 | 含义 | 可恢复的操作 |
|---|---|---|
linkState 恢复为已连接 | RTM 链路已恢复 | 无(继续等待状态事件) |
| 收到当前房间完整 Storage 状态 | 麦位、公告已刷新 | 依赖麦位与公告的操作 |
| 收到 Presence 快照 | 在线成员及临时状态已刷新 | 依赖成员列表的操作 |
linkState 恢复后不要立即开放全部操作。
2.6 消息协议必须可校验
RTM Message 支持文本和二进制两种格式。无论使用哪种,业务层都应定义统一的消息信封,标识协议版本、房间、目标用户、有效期和消息 ID。本文示例使用 JSON 文本便于跨端调试;使用 Protobuf 等二进制协议时应包含含义相同的字段。
收到消息后按以下顺序校验,任一项不通过即丢弃:
- 内容为合法格式,字段和类型完整;
schemaVersion为当前客户端支持的版本;- 信封中的
roomId属于当前房间;对于MESSAGE事件,channelName应为该房间 ID;对于USER事件,channelName应为本端 RTM 用户 ID; - 点对点消息的
targetUserId是当前用户; - 当前时间早于
expiresAt; messageId未处理过。
通过 TTL 丢弃已失效的上麦邀请或申请;用有容量上限的集合保存近期已处理的 messageId,避免重复执行,也避免长期运行后内存持续增长。
3. 实现指南
以下代码基于 RTM Web SDK 2.3.0,展示关键调用片段。开始前可参考 实现收发消息 完成基础接入,并按 部署 Token 服务器 准备凭证服务。事件名、参数结构和 Token 更新方式以实际接入的 SDK 版本为准。
3.1 创建 client 并注册事件
appId、userId 由业务提供;下列 onLinkState、onMessage 等为 App 自己实现的事件处理函数。
import AgoraRTM from "agora-rtm";
const client = new AgoraRTM.RTM(appId, userId);
// 全部事件在 login() 之前注册,且只注册一次
client.addEventListener("linkState", onLinkState);
client.addEventListener("message", onMessage);
client.addEventListener("presence", onPresence);
client.addEventListener("storage", onStorage);
client.addEventListener("token", onToken);3.2 登录
按 2.1 RTM client 与会话同生命周期 确定的生命周期调用 login()。同一会话内只登录一次;若有并发调用,复用同一个登录 Promise,登录失败后清除以便重试。
await client.login({ token });3.3 进入房间
await client.subscribe(roomId, {
withMessage: true,
withPresence: true,
withMetadata: true,
withLock: false, // 多角色管理麦位时改为 true 并配合 Lock 使用
});在权威房间状态到达前(见 2.4 区分频道已订阅与房间状态已就绪),禁用依赖麦位或公告的操作。
3.4 切换与离开房间
离开房间:停止新的状态写入 → 等待已发起的关键写入结束 → 退出 RTC → unsubscribe(roomId) → 清理本地房间状态与事件处理器。用户仍在会话或语聊模块内时保留已登录的 client。
切换房间即"离开旧房间 → 进入新房间"。为每次入房生成递增的 generation(或使用 AbortController),每个异步步骤返回后先检查该次入房是否仍然有效,再更新 UI:
const generation = ++this.generation;
await client.subscribe(roomId, options);
if (generation !== this.generation) {
// 切房流程串行编排:旧订阅清理完毕后再启动新订阅。
// 订阅期间用户已切房:补退订,忽略本次结果
await client.unsubscribe(roomId).catch(() => undefined);
return;
}3.5 写入房间状态
麦位表写入 Channel Storage,可选择整表作为一个 key(写入简单,整表竞争),或每个麦位一个 key(按麦位竞争,便于配合 Lock)。以下 JSON 仅展示业务数据组织方式;当前 Demo 使用 seats key 保存完整麦位对象,客户端按约定结构消费。
{
"seats": [
{ "seatId": "seat-1", "userId": "user-a" },
{ "seatId": "seat-2", "userId": null }
]
}{ "seat-1": { "userId": "user-a" }, "seat-2": { "userId": null } }成员上麦后的临时状态写入 Presence State,只更新变化的字段:
await client.presence.setState(roomId, "MESSAGE", { muted: "true" });听众下麦后,使用 presence.removeState(roomId, "MESSAGE", { states: ["muted", "microphoneError"] }) 清理仅在麦位上有效的字段。
3.6 发送与消费消息
统一信封示例:
interface VoiceRoomEnvelope<T extends Record<string, unknown>> {
schemaVersion: 1;
messageId: string;
type: string;
roomId: string;
targetUserId?: string;
sentAt: number;
expiresAt: number;
payload: T;
}onMessage 中按 2.6 消息协议必须可校验 的六步校验后再交给业务处理。
3.7 退出
语聊是核心功能时,仅在 App 销毁、用户退出账号或 RTM 身份变化时移除监听并调用 logout();语聊是附加功能时,退出整个语聊模块时执行。单纯离开一个房间不要 logout()。
3.8 封装建议
将 SDK client 封装在应用级 RTM 会话对象中,房间业务通过进入房间、申请上麦、更新麦位和发送消息等方法调用它,避免协议规则散落在多个 UI 组件中。
当前 Demo 使用 AppRtmSession 管理会话,HostRoomRtm / AudienceRoomRtm 封装角色操作,SingleRoomClient 消费房间状态并协调 RTC。这些都是 Demo 类,不是 SDK 接口。参考 会话封装 和 房间业务实现。
4. 常见错误
每次进房都创建并登录一个 RTM client
重复注册监听、重复维护连接状态,快速切房时留下旧实例。一个会话只登录一个 client,房间逻辑只负责订阅与退订。
多个房间共用一个 Channel,再靠消息字段过滤
各房间互相收到彼此的 Presence、Storage 和 Message,浪费消息量且容易误处理。每个语聊房使用独立 Channel。
用 Message 保存最终房间状态
后加入的成员收不到之前的 Message。公告、麦位表等当前状态必须写入 Channel Storage,Message 只传递动作。
用 Storage 保存高频临时状态
自主闭麦、麦克风异常只在成员在线期间有效,应写入 Presence State。写进 Storage 会增加无意义的状态合并和并发写入。
把 subscribe() 完成当作房间状态已就绪
订阅成功与首个快照到达是两个阶段。收到 Storage 快照前,依赖麦位或公告的按钮保持不可用。
链路恢复后立即开放全部操作
连接恢复不代表麦位、公告和成员状态已更新。等待当前房间的完整状态事件到达后再恢复相关操作。
收到消息后直接执行
未经校验的消息可能已过期、属于其他房间或被重复投递。版本、房间、目标、TTL 和 messageId 全部通过后才交给业务处理。
5. 上线检查清单
- 一个 App 会话或语聊模块会话中,同一 RTM 身份只创建一个 client
- 所有 SDK 事件都在
login()前注册,且只注册一次 - 每个语聊房都有唯一、稳定的 Channel Name
- 进房使用
subscribe(roomId),离房使用unsubscribe(roomId) - 离开或切换房间不触发
logout() - 核心功能在 App 退出或用户身份结束时
logout();附加功能在退出语聊模块时logout() - Channel Storage 只保存需要被恢复的房间当前状态
- Presence State 只保存在线成员的临时状态
- 单人动作使用
USERMessage,房间广播使用MESSAGEMessage - 最终麦位状态以 Storage 为准,不从 Message 历史推导
- "频道已订阅"与"房间状态已就绪"分别建模
- 重连后等待全量状态事件,不重建 client、不盲目重复订阅
- 消息包含版本、唯一 ID、房间、时间和过期时间,并在消费前校验
- 快速切房时使用 generation 或取消标记忽略迟到结果
- RTC 失败不会自动回滚已确认的 RTM 房间状态,仅采集异常同步麦克风错误
- 目录、准入、权限和 Token 已接入业务服务