Android 群组聊天怎么实现:基于 ZIM 的完整落地指南(附可运行 Demo)

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) 默认不存,需单独开通 按频道隔离存储
频道 / 子群 不支持 不支持 支持多频道(公开 / 私密)
未读数、会话列表 完整支持 不支持 支持
加入方式 邀请 / 申请 / 搜索加入 随房间创建加入 主动加入或被邀请
典型场景 团队协作、兴趣群、客服群 直播弹幕、临时会议 大规模社区、论坛式产品

选型决策树:

  1. 消息要不要在用户下次打开 App 时还在?不在 → 房间。在 → 下一步。
  2. 成员规模会不会超过 6000,或者需要把不同话题拆成独立子频道?会 / 需要 → 社群(需旗舰版 + 联系 ZEGO 技术支持开通)。不会 → 群组。
  3. 绝大多数产品落在第 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。

权限 群主 管理员 普通成员
修改群头像 / 群名称 / 群公告 支持 支持 支持
修改群成员昵称 所有人 所有普通成员 仅自己
撤回群成员消息 所有人 所有普通成员 仅自己
踢人 支持 支持 不支持
对单个成员禁言 支持 支持 不支持
对特定角色禁言 支持 支持 不支持
设置群成员角色 支持 不支持 不支持
转让群主 支持 不支持 不支持
解散群组 支持 不支持 不支持
全员禁言 支持 不支持 不支持

注意矩阵里的两处反直觉:

  1. 改群名、改群公告,普通成员也能改。 如果你的产品不允许,必须自己在 UI 层禁掉。
  2. 管理员不能设置角色、不能解散群、不能全员禁言。

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 下发。它有三个行为必须知道:

  1. 登录成功后回调一次,此时拿到的是全量总未读。
  2. 之后每次新消息、每次清空未读,都会回调。
  3. 被设为免打扰的会话,其未读变化不会驱动总未读数。

第 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 要求包名严格匹配。建议按厂商逐个联调,不要一次性全接。

参考资料

相关推荐
三少爷的鞋4 小时前
现代 Android 架构指南:不要再主动设计 Callback 接口
android
TimeFine9 小时前
AI 编码工作流的设计
android·ai编程
骑着蜗牛撵大象32710 小时前
C++20 协程 × 屏障:手写一个 co_await 版 Barrier,让等待不再钉死线程
android·c++20·协程·并发编程·co_await·屏障同步
山东孙燕姿12 小时前
设备台账重构:从一张表到三层模型
android·java·网络·低代码·重构·rxjava
蒸鱼Yuzheng13 小时前
移动端 CPU 调度分析:线程亲和性、大小核迁移与卡顿
android·perfetto·性能分析·线程优化·cpu调度
booboosui13 小时前
安卓唤醒词:12 维 MFCC 加 DTW,阈值 1.8
android
传奇开心果13 小时前
Compose Multiplatform 共享边界实战:架构契约、互操作同步与可回退设计
android·kotlin
事圆则缓13 小时前
从开机到首帧:Android 系统与应用启动流程源码分析
android
AI吃大瓜13 小时前
指针压力表读数4:Android实现指针压力表读数(可实时检测)
android·指针读数·指针压力表·压力表·压力表读数