Android 群组聊天的实现分两条路:自建长连接 + 消息存储,或者接类似 ZEGO ZIM 这类第三方即时通讯 SDK。如果你的团队没有 IM 基础设施经验和至少两个专职后端,接第三方即时通讯 SDK 是更好的选择。
本文演示代码基于 ZIM Android SDK 3.2.0(2026-09-18 发布),所有接口名与回调名均已逐条对照官方文档核验,可直接复制运行。
1. 先选模型:群组、房间、还是社群?
这是最容易埋雷的一步。三者的消息模型不同,中途换选型约等于重做。
| 维度 | 群组(Group) | 房间(Room) | 社群(Community) |
|---|---|---|---|
| 成员关系 | 持久,有成员列表 | 临时,随会话生命周期 | 持久,超大规模型 |
| 成员规模 | 免费版 20 / 专业版 200(可调)/ 旗舰版 2000(可调),扩展最高 6000 | 按房间规格 | 默认 10 万,可扩展 |
| 历史消息 | 长期存储(按套餐,见 §11) | 默认不存,需单独开通 | 按频道隔离存储 |
| 频道 / 子群 | 不支持 | 不支持 | 支持多频道(公开 / 私密) |
| 未读数、会话列表 | 完整支持 | 不支持 | 支持 |
| 加入方式 | 邀请 / 申请 / 搜索加入 | 随房间创建加入 | 主动加入或被邀请 |
| 典型场景 | 团队协作、兴趣群、客服群 | 直播弹幕、临时会议 | 大规模社区、论坛式产品 |
选型决策树:
- 消息要不要在用户下次打开 App 时还在?不在 → 房间。在 → 下一步。
- 成员规模会不会超过 6000,或者需要把不同话题拆成独立子频道?会 / 需要 → 社群(需旗舰版 + 联系 ZEGO 技术支持开通)。不会 → 群组。
- 绝大多数产品落在第 3 步,也就是群组。
一个容易忽略的坑: 社群频道的 conversationID 与 channelID 不是同一个值。发消息和管理会话必须用从 ZIMCommunityChannel 取到的 conversationID,用 channelID 会直接失败。
2. 跑通最小链路(可运行完整示例)
下面这套代码是可以直接跑的:建空工程 → 贴入三个文件 → 改 appID / appSign / 两个 userID → 运行。它会完成 初始化 → 登录 → 建群 → 发消息 → 收消息 → 拉会话列表 的全部动作。
2.1 环境与依赖
环境要求:Android Studio 2021.1.1+、Android SDK Platform 31、Build-Tools 30.0.3、Android 4.4+。
settings.gradle(AGP ≥ 7.1.0):
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
maven { url 'https://maven.zego.im' }
mavenCentral()
google()
}
}
AGP < 7.1.0 则把上面三个仓库写到根 build.gradle 的 allprojects.repositories 里。
app/build.gradle:
dependencies {
implementation 'im.zego:zim:3.2.0'
}
AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".DemoApplication"
... >
</application>
版本号写死为 3.2.0 是本文核验时的最新版。实际集成时请到官方发布日志页确认当前版本再替换。
2.2 初始化:Application 里只做一次
package com.example.groupchatdemo;
import android.app.Application;
import im.zego.zim.ZIM;
import im.zego.zim.entity.ZIMAppConfig;
public class DemoApplication extends Application {
private static ZIM zim;
@Override
public void onCreate() {
super.onCreate();
ZIMAppConfig appConfig = new ZIMAppConfig();
appConfig.appID = 1234567890L; // long 类型,注意加 L
appConfig.appSign = "YOUR_APP_SIGN"; // 正式环境请改为服务端下发的 Token 鉴权
zim = ZIM.create(appConfig, this);
}
public static ZIM getZim() {
return zim;
}
}
两个要点:
ZIM.create()是静态方法,Android 上必须传Application对象。创建成功后通过ZIM.getInstance()取实例;在create之前或destroy之后,getInstance()返回 null,业务代码要做空判断。appSign是明文密钥,只适合调试。生产环境必须切到 Token 鉴权——Token 由你的服务端生成下发,客户端不持有长期密钥。
2.3 群聊管理器:登录、建群、收发消息
package com.example.groupchatdemo;
import androidx.annotation.Nullable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import org.json.JSONObject;
import im.zego.zim.ZIM;
import im.zego.zim.callback.ZIMEventHandler;
import im.zego.zim.callback.ZIMGroupCreatedCallback;
import im.zego.zim.callback.ZIMLoggedInCallback;
import im.zego.zim.callback.ZIMMessageSentFullCallback;
import im.zego.zim.entity.ZIMError;
import im.zego.zim.entity.ZIMErrorUserInfo;
import im.zego.zim.entity.ZIMGroupAdvancedConfig;
import im.zego.zim.entity.ZIMGroupFullInfo;
import im.zego.zim.entity.ZIMGroupInfo;
import im.zego.zim.entity.ZIMGroupMemberInfo;
import im.zego.zim.entity.ZIMLoginConfig;
import im.zego.zim.entity.ZIMMessage;
import im.zego.zim.entity.ZIMMessageReceivedEventResult;
import im.zego.zim.entity.ZIMMessageReceivedInfo;
import im.zego.zim.entity.ZIMMessageSendConfig;
import im.zego.zim.entity.ZIMTextMessage;
import im.zego.zim.enums.ZIMConnectionEvent;
import im.zego.zim.enums.ZIMConnectionState;
import im.zego.zim.enums.ZIMConversationType;
import im.zego.zim.enums.ZIMErrorCode;
import im.zego.zim.enums.ZIMMessagePriority;
import im.zego.zim.enums.ZIMMessageType;
public class GroupChatManager {
/** 演示用:当前打开的会话 ID。群聊场景下,conversationID 就等于 groupID */
private String currentConversationID;
/** 当前会话的消息列表,UI 直接渲染它 */
private final List<ZIMMessage> messageList = new ArrayList<>();
private MessageListListener listener;
public interface MessageListListener {
void onMessageListChanged(List<ZIMMessage> list);
}
public void setListener(MessageListListener listener) {
this.listener = listener;
}
// ---------- 1. 注册事件回调(登录前注册,越早越好)----------
public void registerEventHandler(ZIM zim) {
zim.setEventHandler(new ZIMEventHandler() {
@Override
public void onConnectionStateChanged(ZIM zim, ZIMConnectionState state,
ZIMConnectionEvent event,
JSONObject extendedData) {
switch (state) {
case CONNECTED:
// 在线,可正常收发
break;
case CONNECTING:
case RECONNECTING:
// SDK 自动重连中,UI 层提示「连接中」即可,无需自行重连
break;
case DISCONNECTED:
// 已断开,SDK 会在网络恢复后自动重连
break;
default:
break;
}
}
// ZIM SDK 3.0.0 起,单聊 / 群组 / 房间 / 社群频道消息统一走这一个回调
@Override
public void onMessageReceived(ZIM zim, ZIMMessageReceivedEventResult result) {
ZIMMessageReceivedInfo info = result.getReceivedInfo();
if (info.getConversationType() != ZIMConversationType.GROUP) {
return; // 只处理群聊消息,其他会话类型自行分流
}
if (!info.getConversationID().equals(currentConversationID)) {
return; // 不是当前打开的群,走未读数逻辑
}
addMessage(result.getMessageList());
}
});
}
// ---------- 2. 登录 ----------
public void login(ZIM zim, String userID, @Nullable String token) {
ZIMLoginConfig loginConfig = new ZIMLoginConfig();
loginConfig.userName = userID;
loginConfig.token = token == null ? "" : token; // 未启用 Token 鉴权时传空串
loginConfig.isOfflineLogin = false; // 无网时不强制登录
zim.login(userID, loginConfig, new ZIMLoggedInCallback() {
@Override
public void onLoggedIn(ZIMError error) {
if (error.code != ZIMErrorCode.SUCCESS) {
// 登录失败:检查 AppID / AppSign / 网络
}
}
});
}
// ---------- 3. 建群 ----------
public void createGroup(ZIM zim, String groupID, String groupName) {
ZIMGroupInfo groupInfo = new ZIMGroupInfo();
groupInfo.groupID = groupID; // 留空则由服务端自动生成
groupInfo.groupName = groupName; // 最长 50 字节
groupInfo.groupAvatarUrl = "";
ArrayList<String> userIDs = new ArrayList<>(); // 建群时一并拉入的成员,单次最多 100 个
ZIMGroupAdvancedConfig config = new ZIMGroupAdvancedConfig();
config.joinMode = 0; // 0 任何人可直接加群 / 1 需群主或管理员审批 / 2 禁止他人入群
config.inviteMode = 0; // 0 任何群成员可邀请 / 1 仅群主或管理员可邀请
config.beInviteMode = 0; // 0 无需被邀请人同意 / 1 被邀请人同意后才成为成员
config.maxMemberCount = 200; // 取值 [0, 套餐默认的最大群成员数量]
zim.createGroup(groupInfo, userIDs, config, new ZIMGroupCreatedCallback() {
@Override
public void onGroupCreated(ZIMGroupFullInfo groupInfo,
ArrayList<ZIMGroupMemberInfo> userList,
ArrayList<ZIMErrorUserInfo> errorUserList,
ZIMError errorInfo) {
if (errorInfo.code == ZIMErrorCode.SUCCESS) {
// 群聊会话的 conversationID 就是 groupID,直接复用入参即可。
// 若建群时 groupID 传空交由服务端生成,则需从 groupInfo 中取回真实群 ID
// (字段见官方 Struct 文档 ZIMGroupFullInfo)。
currentConversationID = groupID;
// 建群成功后即可进入会话页
}
}
});
}
// ---------- 4. 发送群消息 ----------
public void sendTextMessage(ZIM zim, String text) {
ZIMTextMessage message = new ZIMTextMessage();
message.message = text;
ZIMMessageSendConfig config = new ZIMMessageSendConfig();
config.priority = ZIMMessagePriority.MEDIUM; // LOW / MEDIUM / HIGH
// 群聊场景:toConversationID 传 groupID,会话类型传 GROUP
zim.sendMessage(message, currentConversationID, ZIMConversationType.GROUP, config,
new ZIMMessageSentFullCallback() {
@Override
public void onMessageAttached(ZIMMessage zimMessage) {
// 消息已进入本地队列,此时可以先把气泡渲染出来(状态:发送中)
addMessage(Collections.singletonList(zimMessage));
}
@Override
public void onMessageSent(ZIMMessage zimMessage, ZIMError error) {
// 发送结果回来,同一对象会被再次推入,靠去重逻辑替换旧对象
addMessage(Collections.singletonList(zimMessage));
}
});
}
// ---------- 5. 消息合并、去重、排序(官方推荐写法)----------
/**
* 历史消息、实时收到的消息、本地发送的消息,全部经由这一个方法进入列表。
* 排序:orderKey 升序(越大越新)
* 去重:优先比 messageID,其次比 localMessageID,命中则用新对象替换旧对象
*/
public void addMessage(List<ZIMMessage> addList) {
if (addList == null || addList.isEmpty()) return;
List<ZIMMessage> mutableList = new ArrayList<>(messageList);
for (ZIMMessage newMsg : addList) {
boolean replaced = false;
for (int i = 0; i < mutableList.size(); i++) {
ZIMMessage oldMsg = mutableList.get(i);
if ((newMsg.getMessageID() != null && newMsg.getMessageID().equals(oldMsg.getMessageID()))
|| (newMsg.getLocalMessageID() != null
&& newMsg.getLocalMessageID().equals(oldMsg.getLocalMessageID()))) {
mutableList.set(i, newMsg);
replaced = true;
break;
}
}
if (!replaced) {
mutableList.add(newMsg);
}
}
mutableList.sort(Comparator.comparingLong(ZIMMessage::getOrderKey));
messageList.clear();
messageList.addAll(mutableList);
if (listener != null) {
listener.onMessageListChanged(Collections.unmodifiableList(messageList));
}
}
/** 取出可渲染的文本内容(节选:仅处理文本消息) */
public static String extractText(ZIMMessage message) {
if (message.getType() == ZIMMessageType.TEXT) {
return ((ZIMTextMessage) message).message;
}
return "[非文本消息]";
}
}
2.4 链路时序
App ZIM SDK ZEGO 服务端
│ create(appConfig) │ │
│────────────────────>│ 初始化,建立长连接 │
│ setEventHandler │ │
│────────────────────>│ 注册回调(须早于登录)│
│ login(userID,token)│ │
│────────────────────>│───────────────────>│ 鉴权
│ createGroup(...) │ │
│────────────────────>│───────────────────>│ 建群,落库
│ sendMessage(...) │ │
│────────────────────>│──────上传消息──────>│ 分配 messageID / orderKey
│ │<─────回执──────────│
│ onMessageReceived │ │
│<────────────────────│<────广播给群成员────│
3. 群 ID 设计:一个不可逆的坑
结论:ZIM 的 groupID 在同一 AppID 下是永久占用的,群解散后也不能再建同名群。 这是官方明确说明的行为,也是最容易在上线半年后反噬的设计缺陷。
groupID 的硬约束:
| 约束 | 值 |
|---|---|
| 最大长度 | 32 字节 |
| 允许字符 | 数字、英文字母,以及 `!’#$%&()+-:;<=.>?@[]^_{ |
| 首字符 | 不能是 # |
| 留空行为 | 由服务端自动生成 |
| 唯一性 | 同 AppID 下全局唯一,且永久不可复用 |
错误示范: 用自增 ID 或业务主键当群 ID。
// 危险:项目 ID 是自增的,测试环境建过 project_1001,
// 线上再建 project_1001 会直接失败
groupInfo.groupID = "project_" + projectId;
推荐做法: 业务前缀 + 不可复用后缀,且后缀本身可预测便于排查。
// 推荐:前缀标识业务域,后缀保证永不复用
groupInfo.groupID = "project_" + projectId + "_" + System.currentTimeMillis();
或者直接留空让服务端生成,把业务映射关系放到你自己的库里维护:
groupInfo.groupID = ""; // 服务端生成,客户端从 onGroupCreated 回调里取回
留空是更稳的选择——代价是你必须自己存一份 业务ID → groupID 的映射,而且这份映射不能丢。两种方案都行,唯一不能做的是把可复用的业务主键直接当群 ID。
4. 入群路径设计:三个开关决定产品形态
ZIMGroupAdvancedConfig 里有三个互相独立的开关,它们组合出的形态从「完全开放」到「严格审批」都有。这三个值必须在建群时想清楚,因为它们是群属性,改起来要调 updateGroupJoinMode / updateGroupInviteMode / updateGroupBeInviteMode。
4.1 三个开关
| 字段 | 取值 | 含义 |
|---|---|---|
joinMode | 0(默认) | 任何人都可以直接加群 |
| 1 | 需群主或管理员审批 | |
| 2 | 禁止其他用户入群 | |
inviteMode | 0(默认) | 任何群成员都可以邀请他人 |
| 1 | 仅群主或管理员可以邀请 | |
beInviteMode | 0(默认) | 无需被邀请人同意,直接成为群成员 |
| 1 | 被邀请人同意后才成为群成员 |
4.2 常见组合
| 产品形态 | joinMode | inviteMode | beInviteMode |
|---|---|---|---|
| 开放兴趣群(搜索即入) | 0 | 0 | 0 |
| 邀请制私密群 | 2 | 1 | 1 |
| 需审核的社群 / 班级群 | 1 | 1 | 0 |
| 成员可拉人、但被拉的人要确认 | 2 | 0 | 1 |
4.3 对应的接口
直接邀请(不需对方同意):
zim.inviteUsersIntoGroup(userList, groupID, new ZIMGroupUsersInvitedCallback() {
@Override
public void onGroupUsersInvited(ArrayList<ZIMGroupMemberInfo> userList,
ArrayList<ZIMErrorUserInfo> errorUserList,
ZIMError errorInfo) {
// errorUserList 里是被邀请失败的用户及原因,需要逐个处理
}
});
注意: 被邀请人必须已经登录过 ZIM(即账号在 ZIM 侧已完成注册),否则邀请会失败并出现在 errorUserList 里。所以正确的顺序是「先登录一次 → 再被邀请」,而不是「先被邀请 → 首次登录」。
带审批的入群:
// 申请方:发起入群申请
zim.sendGroupJoinApplication(groupID, config, callback);
// 审批方:同意 / 拒绝
zim.acceptGroupJoinApplication(userID, groupID, callback);
zim.rejectGroupJoinApplication(userID, groupID, callback);
// 审批方:查询待处理申请列表
zim.queryGroupApplicationList(groupID, callback);
审批事件通过 onGroupApplicationListChanged(ZIM zim, ArrayList<ZIMGroupApplicationInfo> applicationList, ZIMGroupApplicationListChangeAction action) 主动推送给群主和管理员——这是驱动审批红点的唯一正确方式,不要用轮询。
5. 群成员与权限:把权限表刻进 UI
结论:权限校验要做在 UI 层。 服务端会拦截越权操作并返回错误码,但让普通成员看到一个点不动的「踢人」按钮,比事后收到 6000541 要好得多。
5.1 角色与权限矩阵
角色数值:群主 = 1,管理员 = 2,普通成员 = 3。
| 权限 | 群主 | 管理员 | 普通成员 |
|---|---|---|---|
| 修改群头像 / 群名称 / 群公告 | 支持 | 支持 | 支持 |
| 修改群成员昵称 | 所有人 | 所有普通成员 | 仅自己 |
| 撤回群成员消息 | 所有人 | 所有普通成员 | 仅自己 |
| 踢人 | 支持 | 支持 | 不支持 |
| 对单个成员禁言 | 支持 | 支持 | 不支持 |
| 对特定角色禁言 | 支持 | 支持 | 不支持 |
| 设置群成员角色 | 支持 | 不支持 | 不支持 |
| 转让群主 | 支持 | 不支持 | 不支持 |
| 解散群组 | 支持 | 不支持 | 不支持 |
| 全员禁言 | 支持 | 不支持 | 不支持 |
注意矩阵里的两处反直觉:
- 改群名、改群公告,普通成员也能改。 如果你的产品不允许,必须自己在 UI 层禁掉。
- 管理员不能设置角色、不能解散群、不能全员禁言。
5.2 关键接口
// 设置角色:role 取 1/2/3;注意 role 不能被改成 1(群主只能靠转让产生)
// 自定义 role 建议取 >= 100,权限等同普通成员
zim.setGroupMemberRole(role, forUserID, groupID, callback);
// 转让群主
zim.transferGroupOwner(toUserID, groupID, callback);
// 踢人:非群主不可调用;无需对方同意或在线
// 被踢后,该成员的历史消息和会话都保留,且仍然可以再次进入该群 —— 需要产品侧另行处理
zim.kickGroupMembers(userIDs, groupID, callback);
// 修改昵称:群主可改所有人,成员只能改自己;最长 100 字符
zim.setGroupMemberNickname(nickname, forUserID, groupID, callback);
5.3 禁言
// 对指定成员禁言
ZIMGroupMemberMuteConfig muteConfig = new ZIMGroupMemberMuteConfig();
muteConfig.duration = 30; // 单位:秒
zim.muteGroupMembers(isMute, userIDList, groupID, muteConfig,
new ZIMGroupMembersMutedCallback() {
@Override
public void onGroupMembersMuted(String groupID, boolean isMute, int duration,
ArrayList<String> mutedMemberIDs,
ArrayList<ZIMErrorUserInfo> errorUserList,
ZIMError errorInfo) {
}
});
// 全员禁言
zim.muteGroup(isMute, groupID, new ZIMGroupMuteConfig(), new ZIMGroupMutedCallback() {
@Override
public void onGroupMuted(String groupID, boolean isMute, ZIMGroupMuteInfo info,
ZIMError errorInfo) {
// info.expiredTime:0 表示不禁言,-1 表示永久禁言
}
});
被禁言的成员发消息会失败,错误码 6000231。
5.4 群主退群的正确姿势
群主没有「退群」这个动作——只有先转让,再退出:
zim.transferGroupOwner(newOwnerUserID, groupID, new ZIMGroupOwnerTransferredCallback() {
@Override
public void onGroupOwnerTransferred(String groupID, String toUserID, ZIMError errorInfo) {
if (errorInfo.code == ZIMErrorCode.SUCCESS) {
zim.leaveGroup(groupID, new ZIMGroupLeftCallback() {
@Override
public void onGroupLeft(String groupID, ZIMError errorInfo) {
}
});
}
}
});
如果产品允许群主注销账号,必须在服务端编排一个「群主注销 → 自动转让给最早加入的成员」的兜底任务。否则这个群会永久失去管理员。
6. 群消息进阶:排序、去重、状态
这一节是群聊「看起来对不对」的分水岭。
6.1 按 orderKey 排序,不要按 timestamp
| 字段 | 语义 | 能否用于排序 |
|---|---|---|
timestamp | 消息到达服务器的时间 | ❌ 网络抖动时会偏差,导致乱序 |
orderKey | 专为排序设计,综合全局顺序 + 同一发送者的顺序 | ✅ 升序渲染 |
快速连续发消息、或者弱网环境下,timestamp 会出现后发的消息时间更早的情况。orderKey 越大表示消息越新,排序就是按 orderKey 升序。
6.2 messageID 与 localMessageID 缺一不可
| 场景 | messageID | localMessageID |
|---|---|---|
| 消息发送中 | 无 | 有 |
| 消息发送失败 | 无 | 有 |
本地插入的消息(insertMessageToLocalDB) | 无 | 有 |
| 正常收发的消息 | 有(服务器分配,全局唯一) | 有 |
匹配规则:messageID 可用时优先比 messageID,不可用时比 localMessageID。 只用其中一个,都会出现「发送成功后气泡变成两条」或者「状态永远停在发送中」。
6.3 SDK 会推送新对象,必须替换而不是追加
消息对象在生命周期内会变化,SDK 每次变化都推一个新对象过来:
- 发送状态从「发送中」→「发送成功 / 发送失败」
- 消息被撤回后,类型从
TEXT变成撤回消息 - 已发送的消息被编辑
所以 §2.3 里的 addMessage 是「先查重、命中就替换、最后统一按 orderKey 排序」,而不是简单的 list.addAll()。这是官方推荐的标准写法,请直接用。
发送状态变更通过 onMessageSentStatusChanged 下发:
@Override
public void onMessageSentStatusChanged(List<ZIMMessageSentStatusChangeInfo> infoList) {
for (ZIMMessageSentStatusChangeInfo info : infoList) {
if (!info.getMessage().getConversationID().equals(currentConversationID)) {
continue;
}
addMessage(Collections.singletonList(info.getMessage()));
}
}
6.4 消息优先级与限流
ZIMMessageSendConfig config = new ZIMMessageSendConfig();
config.priority = ZIMMessagePriority.HIGH; // LOW / MEDIUM / HIGH
优先级不是装饰,它有两个真实作用:
- 触发客户端限频时,高优先级消息优先被处理。举例:前 0.5 秒已经发满 10 条低优先级消息,剩余额度会留给高优先级消息。
- 服务端压力大时,高优先级消息优先下发。
限流硬指标:两次发送间隔必须大于 100ms,约合每秒 20 条。 超过会返回 6000004(SDK 内部限频)或 6000005(后台业务限频)。
所以不要在循环里直接 sendMessage。 正确做法是维护一个发送队列,串行化 + 固定间隔出队,业务优先级高的插队。
6.5 消息撤回与「对所有人删除」
zim.revokeMessage(message, new ZIMRevokeMessageSentCallback() { ... });
另外两个高频错误码要提前在 UI 上处理:
| 错误码 | 含义 | UI 处理 |
|---|---|---|
| 6000285 | 尝试编辑他人发送的消息 | 隐藏编辑入口 |
| 6000286 | 超过编辑时长(24 小时) | 隐藏编辑入口 |
7. 会话列表与未读数
群聊产品的「消息」Tab 靠两个东西驱动:会话列表和总未读角标。
7.1 拉取会话列表
ZIMConversationQueryConfig config = new ZIMConversationQueryConfig();
config.count = 100;
config.nextConversation = null; // 首次为 null,分页时传上一页最后一条
ZIMConversationFilterOption option = new ZIMConversationFilterOption();
option.conversationTypes.add(ZIMConversationType.GROUP); // 不填则同时查单聊和群聊
option.isOnlyUnreadConversation = false;
zim.queryConversationList(config, option, new ZIMConversationListQueriedCallback() {
@Override
public void onConversationListQueried(ArrayList<ZIMConversation> conversationList,
ZIMError errorInfo) {
// 渲染会话列表
}
});
ZIMConversationFilterOption.marks 支持按标记(1–20)过滤,-1 表示「所有已标记的会话」,0 表示「无标记的会话」。做「置顶 / 归档」功能时用它,不要自己在本地过滤。
7.2 未读数:一个必踩的坑
总未读数通过 onConversationTotalUnreadMessageCountUpdated 下发。它有三个行为必须知道:
- 登录成功后回调一次,此时拿到的是全量总未读。
- 之后每次新消息、每次清空未读,都会回调。
- 被设为免打扰的会话,其未读变化不会驱动总未读数。
第 3 点是最常见的 bug 来源:产品要求「免打扰群的未读也要显示在角标上」,而 SDK 的设计就是不显示。如果产品坚持要,只能在客户端自行累加免打扰会话的未读数,但要注意多端同步时本地累加会失真。
7.3 已读回执
// 进入消息列表页之前调用,标记该会话已读
zim.sendConversationMessageReceiptRead(conversationID, ZIMConversationType.GROUP, callback);
// 批量标记更精确的已读位置,用 sendMessageReceiptsRead
已读回执的变更通过 onConversationMessageReceiptChanged(ZIM zim, ArrayList<ZIMMessageReceiptInfo> infos) 下发(2.5.0+)。
两个限制:
- 已读回执是专业版及以上能力,免费版不支持。
- 查询群消息未读成员列表(
queryGroupMessageReceiptUnreadMemberList)时,SDK 版本低于 2.16.0 且群成员数大于 100 的场景,不会返回未读成员列表。做「谁还没看」的功能前先确认版本线。
8. 历史消息与离线补拉
8.1 分页拉历史
ZIMMessageQueryConfig queryConfig = new ZIMMessageQueryConfig();
queryConfig.count = 20;
queryConfig.nextMessage = null; // 首次为空;需要更多时,传当前列表中最早的一条消息
zim.queryHistoryMessage(currentConversationID, ZIMConversationType.GROUP, queryConfig,
(conversationID, conversationType, messageList, errorInfo) -> {
addMessage(messageList); // 复用 §2.3 的合并方法
});
分页锚点是 nextMessage——传的是消息对象本身,不是 ID,也不是时间戳。往上滑动加载更早的消息时,把当前列表第一条传回去。
8.2 按 messageSeq 精确取消息
ArrayList<Long> messageSeqs = new ArrayList<>(); // 最大长度 10
zim.queryMessages(messageSeqs, conversationID, ZIMConversationType.GROUP, callback);
典型场景:消息 A 回复了历史消息 B,客户端只拿到 B 的 messageSeq,用它换取 B 的完整结构。
8.3 离线消息窗口
| 场景 | 行为 |
|---|---|
| 用户在线 | 通过 onMessageReceived 实时收到 |
| 用户离线后重新登录 | 收到离线期间的单聊和群聊消息,漫游窗口最长 7 天 |
| 房间消息 | 仅当房间开通了离线消息存储时才会收到 |
| 超出 7 天的消息 | 收不到推送,必须主动 queryHistoryMessage |
免费版的消息存储时长本身就有限制(见 §11),离线漫游窗口和存储时长要一起看。
9. 离线推送:让消息在 App 被杀后还能到达
长连接在 App 被系统杀死后必然断开,此时只能靠厂商推送通道。ZIM 的方案是 ZPNs。
9.1 接入流程
App ZPNs SDK 厂商服务器 ZIM 服务端
│ registerPush │ │ │
│─────────────────────>│───────────────────>│ │
│ │<─── 厂商 token ────│ │
│ │─── 生成 PushID ───────────────────────>│ 绑定 用户 ↔ PushID
│ sendMessage │ │ │
│─────────────────────────────────────────────────────────────>│ 判断接收方是否在线
│ │ │<── 转发离线推送 ──│
│<──────────────── 厂商通道推送 ─────────────│ │
注意最后一步:接收方必须至少登录过一次 App,才能收到离线推送。
9.2 依赖与清单
dependencies {
implementation 'im.zego:zpns:x.y.z'
implementation 'im.zego:zpns-huawei:x.y.z'
implementation 'im.zego:zpns-xiaomi:x.y.z'
implementation 'im.zego:zpns-oppo:x.y.z'
implementation 'im.zego:zpns-vivo:x.y.z'
implementation 'im.zego:zpns-fcm:x.y.z'
}
<receiver
android:name=".MyZPNsReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="im.zego.zim.zpns.intent.action.MESSAGE" />
</intent-filter>
</receiver>
需要 WRITE_EXTERNAL_STORAGE 和 READ_PHONE_STATE 权限。
主要回调:ZPNsMessageReceiver 的 onThroughMessageReceived(透传消息)、onNotificationClicked(通知被点击)、onNotificationArrived(通知到达)。
9.3 发送时带推送内容
ZIMPushConfig pushConfig = new ZIMPushConfig();
pushConfig.title = "离线推送的标题";
pushConfig.content = "离线推送的内容";
pushConfig.payload = "自定义透传字段"; // 点击通知后取出来做页面跳转
ZIMMessageSendConfig sentConfig = new ZIMMessageSendConfig();
sentConfig.pushConfig = pushConfig;
zim.sendMessage(message, groupID, ZIMConversationType.GROUP, sentConfig, callback);
注意:房间消息不支持配置离线推送,需要联系 ZEGO 技术支持开通相关权限。
9.4 切换账号前必须 logout
这是最隐蔽的一个坑:PushID 与用户是绑定关系,同一台设备切换 userID 前必须先 zim.logout(),否则会清不掉上一个用户的 PushID 绑定,导致 A 用户收到 B 用户的消息推送。
zim.logout();
// 确认 logout 完成后再 login 新的 userID
另外,多端登录场景下可以用 updateUserOfflinePushRule 配置「哪些端接收推送」,避免手机和 Pad 同时响。
9.5 厂商通道的额外配置
| 厂商 | 额外要求 |
|---|---|
| 华为 | HMS Push SDK + com.huawei.agconnect 插件 + 华为 maven 仓库 + proguard keep 规则 |
| vivo | 手动导入 vivo push aar + meta-data 配置 api_key / app_id + proguard |
| OPPO | 需要 OPPO 开放平台的 AppKey / AppSecret / MasterSecret |
| 小米 | 小米开放平台应用配置 |
| FCM | google-services 插件 + Firebase BOM + firebase-messaging |
各厂商的控制台配置、签名校验、包名一致性是两个高发失败点——华为要求应用签名与 AGC 后台登记的一致,OPPO / vivo 要求包名严格匹配。建议按厂商逐个联调,不要一次性全接。
参考资料
- ZIM Android 收发消息
- ZIM Android 群组管理(建群、入群、群成员管理)
- ZIM Android 群成员管理(角色与权限矩阵)
本文来自作者投稿,版权归原作者所有。如需转载,请注明出处:https://www.nxrte.com/jishu/im/72738.html