微信小程序实现聊天室功能:基于 ZEGO ZIM 的房间实践

上一篇文章《小程序 im 聊天功能实现:基于 ZEGO ZIM 的接入教程》做的是单聊功能。这篇做聊天室:直播间的公屏、语聊房的在线列表、活动页的讨论区,本质都是同一件事即一群人进同一个房间,实时收发消息,有人进出你能看见。

环境准备沿用上一篇(开通 ZIM 服务、配置服务器域名、服务端签发 Token、SDK 初始化封装),本文从已经登录成功开始,跟着做大概 30 分钟。

第一步:先分清聊天室和单聊

很多人上手聊天室,第一反应是把单聊的 conversationType 从 0 改成 2,当群聊用。方向就错了。

ZEGO ZIM 里聊天室是房间,和单聊、群聊是三套不同的东西:

单聊聊天室(房间)
conversationType01
toConversationID对方 userIDroomID
会话列表有不在会话列表范畴
未读数有,可以清没有这一说
用户离开后离线消息会补收不到,也没有补
消息存储云端保存,可翻历史房间解散后不存储
规模一对一免费 20 / 专业 500 / 旗舰 6000

这几条差异会直接改写你的页面设计:

  1. 聊天室没有会话列表入口。用户是从直播间、活动页带着 roomID 进来的,不是从消息列表点进来的。所以你不需要 queryConversationList,也不需要维护最后一条消息和红点。
  2. 不需要清未读。没有未读数这个概念,clearConversationUnreadMessageCount 在房间场景用不上。
  3. 不要做消息补偿。用户退出房间再进来,中间的消息是真的看不到,普通房间消息在房间解散后不存储,只有旗舰版才支持进房后拉最近 2000 条。

第二步:进入房间

三个接口都能进房间,区别只有一个:房间不存在时怎么办。

接口房间不存在时什么时候用
createRoom(roomInfo, config)创建并加入必须由当前用户建房,房间已存在会报错(6000323)
joinRoom(roomID)直接失败确定房间已经存在
enterRoom(roomInfo, config)自动创建再进入聊天室,推荐

聊天室的 roomID 一般由业务生成(直播间 ID、活动 ID),第一个用户点进来时房间本来就不存在。用 enterRoom 一步到位,不用在客户端判断「该建还是该进」:

import { ZIMRoomInfo, ZIMRoomAdvancedConfig } from 'zego-zim-miniprogram';
import { getZim } from '../../utils/zim';

export async function enterChatRoom(roomID: string, roomName: string) {
  const roomInfo: ZIMRoomInfo = { roomID, roomName };

  const config: ZIMRoomAdvancedConfig = {
    roomAttributes: { title: roomName },
    roomDestroyDelayTime: 60, // 最后一个人离开后延迟 60 秒销毁,上限 3 小时
  };

  await getZim().enterRoom(roomInfo, config);
}

两条规则先记住:

  • roomID 最长 128 字节,只允许数字、英文字母和常见英文标点。不要用中文、空格、emoji。roomName 最长 64 字节,没有字符限制。
  • roomName 和 roomAttributes 只在「第一个进房的人」身上生效。后面进来的人传什么都覆盖不了。所以房间标题、公告这类会变的内容,不要指望进房参数,要用第六步的 setRoomAttributes 改。

2.1 注册房间事件监听

当前 SDK 注册事件用 zim.on(type, listener),移除用 zim.off(type)。上一篇的 initZimEvents 只处理了单聊事件,房间相关的在 utils/zim.ts 里补一个房间版本:

// utils/zim.ts
import { ZIMEventHandler } from 'zego-zim-miniprogram';

export function initRoomEvents(handler: {
  roomStateChanged?: ZIMEventHandler['roomStateChanged'];
  roomMemberJoined?: ZIMEventHandler['roomMemberJoined'];
  roomMemberLeft?: ZIMEventHandler['roomMemberLeft'];
  roomAttributesUpdated?: ZIMEventHandler['roomAttributesUpdated'];
}) {
  const zim = getZim();
  if (handler.roomStateChanged) zim.on('roomStateChanged', handler.roomStateChanged);
  if (handler.roomMemberJoined) zim.on('roomMemberJoined', handler.roomMemberJoined);
  if (handler.roomMemberLeft) zim.on('roomMemberLeft', handler.roomMemberLeft);
  if (handler.roomAttributesUpdated) zim.on('roomAttributesUpdated', handler.roomAttributesUpdated);
}

注意别在页面里反复注册。官方类型声明里写明重复调用会覆盖上一次注册的回调,所以 App 里最好统一在入口注册一次,再按当前页面分发。

2.2 进房成功的信号

enterRoom 的 Promise resolve 只代表请求被受理了,不代表房间连接已经就绪。这个时间差里发消息会失败。

真正的信号是 roomStateChanged 事件:

// pages/chatroom/index.ts
Page({
  data: { roomID: '', roomName: '', messages: [], onlineCount: 0 },
  inRoom: false,
  seenMessageIds: new Set<string>(),

  onLoad(options) {
    this.setData({ roomID: options.roomID, roomName: options.roomName || '' });

    initRoomEvents({
      roomStateChanged: (zim, result) => {
        const { state, event } = result;

        if (state === 2 && event === 0) {
          // Connected(2) + Success(0):进房成功,此刻才能发消息
          this.inRoom = true;
          this.syncOnlineCount(); // 第五步实现
        } else if (state === 0) {
          // Disconnected(0):退房成功、网络断开、被顶下线、房间被后台销毁
          this.inRoom = false;
        }
      },
      roomMemberJoined: (zim, result) => {
        this.setData({ onlineCount: this.data.onlineCount + result.memberList.length });
      },
      roomMemberLeft: (zim, result) => {
        this.setData({ onlineCount: Math.max(0, this.data.onlineCount - result.memberList.length) });
      },
    });

    getZim().on('messageReceived', (zim, result) => this.handleRoomMessage(result));

    enterChatRoom(this.data.roomID, this.data.roomName);
  },
});

event 要一起看。同样是 state === 0,event === 10 是被别的设备顶下线(多端登录),不是网络抖动,这时候去重连会立刻又被踢。

先跑一次:进房后控制台应该看到 state=2, event=0。没等到就依次查三件事:ZIM 服务是否在控制台开通了;roomID 有没有中文或超长;免费版单个用户只能同时在 1 个房间内,之前的房间没退出会报 6000325。

第三步:发送消息

房间消息和单聊走同一个接口,只换两个参数:toConversationID 传 roomID,conversationType 传 1。

export async function sendRoomText(roomID: string, text: string) {
  if (!text.trim()) return; // 空消息会被拒,错误码 6000001

  // 消息对象直接用对象字面量,type = 1 表示文本消息
  const message = { type: 1, message: text };

  const result = await getZim().sendMessage(
    message,
    roomID,
    1, // 1 = Room
    { priority: 1 } // 1 = Low,2 = Medium,3 = High
  );

  return result.message;
}

房间场景有个单聊不那么容易撞上的限制:两次发送的间隔要大于 100ms(单客户端 10 次/秒)。用户连点、或者你做了「发送后自动补一条系统提示」,很容易撞上频率限制。加个最小间隔:

let lastSendAt = 0;

export async function sendThrottled(roomID: string, text: string) {
  const wait = 100 - (Date.now() - lastSendAt);
  if (wait > 0) await new Promise((r) => setTimeout(r, wait));
  lastSendAt = Date.now();

  return sendRoomText(roomID, text);
}

如果你做的是直播弹幕那种高频场景,别用文本消息硬扛。ZIM 有专门的 ZIMBarrageMessage(type 20),不限频、不存储、允许丢,就是为弹幕设计的。普通聊天室的公屏还是用文本消息,保住送达和顺序。

第四步:接收消息

messageReceived 是统一的,房间消息也从这里出来,靠 conversationType 和 conversationID 过滤:

handleRoomMessage(result) {
  const { messageList, receivedInfo } = result;

  if (receivedInfo.conversationType !== 1) return;             // 只要房间消息
  if (receivedInfo.conversationID !== this.data.roomID) return; // 只要当前房间

  messageList.forEach((msg) => {
    if (this.seenMessageIds.has(msg.messageID)) return;
    this.seenMessageIds.add(msg.messageID);
    this.appendMessage(msg);
  });
}

和单聊相比少了三步:不用清未读、不用更新会话列表、不用管总未读角标。

但有一件事不能省:去重。弱网重连时同一条消息可能被回调两次,房间消息量大,出现重复气泡的概率比单聊更高。seenMessageIds 这个 Set 记得在页面卸载时清掉,不然页面来回切几次会一直涨。

另外记住:退出房间后收不到房间内任何消息。这是设计如此,不是 bug——退房就等于彻底断开,你不用额外做任何屏蔽逻辑。

第五步:在线人数和成员列表

聊天室顶部一般要显示「当前 128 人在线」。三个接口分工:

接口拿到什么限制
queryRoomOnlineMemberCount(roomID)只拿数字必须人在房间内,否则 6000321
roomMemberJoined / roomMemberLeft谁进来、谁走了增量,断线期间会漏
queryRoomMemberList(roomID, config)完整成员列表需要联系 ZEGO 开通,最多返回 500 人

在线人数直接查:

async syncOnlineCount() {
  // roomID 必须是你当前所在的房间,否则查询失败
  const res = await getZim().queryRoomOnlineMemberCount(this.data.roomID);
  this.setData({ onlineCount: res.count });
}

成员进出靠回调做增量,进房成功后调一次 syncOnlineCount 兜底。这条链路在正常网络下够用。

坑在重连之后。 断线期间的成员变动,SDK 不会补发。你重连回来,roomMemberJoined 只会从重连那一刻开始报,中间走了谁、来了谁你都不知道。所以重连成功必须重新拉一次全量:

if (state === 2 && event === 0) {
  this.syncOnlineCount();
  this.syncMemberList(); // 重连后补全量,别只靠增量
}

需要成员头像和昵称的话,用 queryRoomMemberList(roomID, { nextFlag: '', count: 100 }):

async syncMemberList() {
  let nextFlag = '';
  const members = [];

  do {
    const res = await getZim().queryRoomMemberList(this.data.roomID, {
      nextFlag,
      count: 100,
    });
    members.push(...res.memberList);
    nextFlag = res.nextFlag;
  } while (nextFlag);

  this.setData({ members });
}

这个接口默认没开,要联系 ZEGO 技术支持开通。如果只是想知道某几个人在不在房间里(比如判断好友有没有进直播间),用 queryRoomMembers(userIDs, roomID),一次最多查 10 个。

第六步:房间属性

房间属性是挂在房间上的 Key-Value,房间销毁时一起销毁。聊天室常见放法:房间标题、公告、背景图、当前话题、是否全员禁言。

const roomAttributes = {
  notice: '今晚 8 点抽奖,别走开',
  topic: '聊聊你最近在做的项目',
  muted: '0',
};

await getZim().setRoomAttributes(roomAttributes, roomID, {
  isForce: false,              // true = 允许改别人设置的属性
  isUpdateOwner: false,        // true = 把自己变成该属性的 owner
  isDeleteAfterOwnerLeft: false, // true = owner 离开房间时自动删除
});

上限先记住,超了直接报错(6000330 ~ 6000337):

项上限
每个房间的属性个数20
Key 长度16 字节
Value 长度1024 字节
所有属性总大小5120 字节

真正卡人的是 5120 字节,不是 20 个。几条长公告塞进去就满了。内容长的东西只存 ID 或简短文案,正文放你自己的服务端,客户端拿到 ID 再去拉。

变更靠回调同步给房间内所有人:

getZim().on('roomAttributesUpdated', (zim, result) => {
  console.log('[ZIM] room attrs updated', result.roomID, result.infos);
  this.syncAllAttributes(); // 增量合并容易出错,直接重拉全量
});

result.infos 是变更项列表,每项包含变更类型和属性键值。做 UI 时最省事的做法就是收到通知后重拉一次 queryRoomAllAttributes(roomID)——属性总量本来就只有几 KB,一次请求换掉一堆合并逻辑,划算。

和成员列表同理:重连成功后要重新读一次全量,别只依赖增量回调。

第七步:断线和退房

7.1 SDK 不会给房间无限重连

这是房间和单聊最大的运维差异,也是最容易上线后才发现的坑:单聊断线后 SDK 会自己持续重连,但房间连接 SDK 只做有限次重连,之后就不管了。

结果是:用户安安静静地待在聊天室页面,界面看起来正常,实际一条消息都收不到。你不主动兜,这个 bug 会一直存在。

兜底思路是监听 roomStateChanged,掉线后自己按退避策略重进房:

async reconnect() {
  if (this.reconnecting) return;
  this.reconnecting = true;

  let delay = 1000;
  for (let i = 0; i < 3; i++) {
    try {
      await enterChatRoom(this.data.roomID, this.data.roomName);
      this.reconnecting = false;
      return;
    } catch (err) {
      await new Promise((r) => setTimeout(r, delay));
      delay *= 2; // 1s → 2s → 4s
    }
  }

  this.reconnecting = false;
  wx.showToast({ title: '连接失败,请退出重进', icon: 'none' });
}

四个细节:

  • 必须等状态落定再重连。 收到 state === 0 的 DISCONNECTED 之后才调进房接口。连接状态还在中间态时重复调,会返回 6000326。
  • 被顶下线不要重连。 event === 10 是多端登录被踢,重连只会立刻再被踢一次,应该提示用户「该账号已在其他设备进入」。
  • 房间被后台销毁也别重连。 SDK 2.13.0 起,服务端销毁房间时回调里的事件是 ROOM_NOT_EXIST,不是网络问题,重连也进不去,直接提示用户房间已结束。
  • 重连成功要补全量。 成员和房间属性都要重拉,见第五、六步。

7.2 一定要退房

onUnload() {
  getZim().leaveRoom(this.data.roomID).catch(() => {});
}

不退房的后果很具体:

  • 房间一直算你在线,人数虚高
  • 你还在房间里,房间每条消息都会推给你,白耗流量和电量
  • 免费版单个用户只能同时进 1 个房间,不退房就进不了下一个
  • 所有人都离开房间后房间才会自动销毁,你挂着就销毁不了

页面栈里跨了多个房间、或者拿不准自己在哪,用 leaveAllRoom() 一把清空(2.15.0 起支持)。

小结

  1. 聊天室用 conversationType = 1,toConversationID 传 roomID
  2. 房间不进会话列表、没有未读数,入口由业务页面带 roomID 进来
  3. 用 enterRoom 一步建/进,别在客户端判断房间存不存在
  4. 进房成功的信号是 roomStateChanged 的 Connected,不是 Promise resolve
  5. 房间连接不会无限重连,必须自己兜重连,重连后补全量成员和属性
  6. 一定要 leaveRoom,否则人数虚高、消息白推、下个房间进不去
  7. 房间历史消息只有旗舰版有,聊天室不要做消息补偿

继续往下做:想给用户加角色、等级、麦位,用房间用户属性(setRoomMembersAttributes);想做禁言,把状态写进房间属性,客户端读属性判断能不能发;需要服务端管控(批量踢人、撤回房间消息)走 ZIM 服务端 API。群聊是另一套模型(conversationType = 2),不在本篇范围。

参考资料

本文来自作者投稿,版权归原作者所有。如需转载,请注明出处:https://www.nxrte.com/jishu/im/73194.html

赞 (0)

相关推荐