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 三个开关

字段取值含义
joinMode0(默认)任何人都可以直接加群
1需群主或管理员审批
2禁止其他用户入群
inviteMode0(默认)任何群成员都可以邀请他人
1仅群主或管理员可以邀请
beInviteMode0(默认)无需被邀请人同意,直接成为群成员
1被邀请人同意后才成为群成员

4.2 常见组合

产品形态joinModeinviteModebeInviteMode
开放兴趣群(搜索即入)000
邀请制私密群211
需审核的社群 / 班级群110
成员可拉人、但被拉的人要确认201

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 缺一不可

场景messageIDlocalMessageID
消息发送中无有
消息发送失败无有
本地插入的消息(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
小米小米开放平台应用配置
FCMgoogle-services 插件 + Firebase BOM + firebase-messaging

各厂商的控制台配置、签名校验、包名一致性是两个高发失败点——华为要求应用签名与 AGC 后台登记的一致,OPPO / vivo 要求包名严格匹配。建议按厂商逐个联调,不要一次性全接。

参考资料

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

赞 (0)

相关推荐