跳到主要内容
版本:v3

二,消息收发的更多方式,离线推送与消息同步,多设备登录

本章导读

在前一章从简单的单聊、群聊、收发图文消息开始里面,我们说明了如何在产品中增加一个基本的单聊/群聊页面,并响应服务端实时事件通知。接下来,在本篇文档中我们会讲解如何实现一些更复杂的业务需求,例如:

  • 支持消息被接收和被阅读的状态回执,实现「Ding」一下的效果
  • 发送带有成员提醒的消息(@ 某人),在超多用户群聊的场合提升目标用户的响应积极性
  • 支持消息的撤回和修改
  • 解决成员离线状态下的推送通知与重新上线后的消息同步,确保不丢消息
  • 支持多设备登录,或者强制用户单点登录
  • 扩展新的消息类型

消息收发的更多方式

在一个偏重工作协作或社交沟通的产品里,除了简单的消息收发之外,我们还会碰到更多需求,例如:

  • 在消息中能否直接提醒某人,类似于很多 IM 工具中提供的 @ 消息,这样接收方能更明确地知道哪些消息需要及时响应;
  • 消息发出去之后才发现内容不对,这时候能否修改或者撤回?
  • 除了普通的聊天内容之外,是否支持发送类似于「XX 正在输入」这样的状态消息?
  • 消息是否被其他人接收、读取,这样的状态能否反馈给发送者?
  • 客户端掉线一段时间之后,可能会错过一批消息,能否提醒并同步一下未读消息?

等等,所有这些需求都可以通过即时通讯服务解决,下面我们来逐一看看具体的做法。

@ 成员提醒消息

在一些多人聊天群里面,因为消息量较大,很容易就导致某一条重要的消息没有被目标用户看到就被刷下去了,所以在消息中「@成员」是一种提醒接收者注意的有效办法。在微信这样的聊天工具里面,甚至会在对话列表页对有提醒的消息进行特别展示,用来通知消息目标高优先级查看和处理。

一般提醒消息都使用「@ + 人名」来表示目标用户,但是这里「人名」是一个由应用层决定的属性,可能有的产品使用全名,有的使用昵称,并且这个名字和即时通讯服务里面标识一个用户使用的 clientId 可能根本不一样(毕竟一个是给人看的,一个是给机器读的)。使用「人名」来圈定用户,也存在一种例外,就是聊天群组里面的用户名是可以改变的,如果消息发送的时候「王五」还叫「王五」,等发送出来之后他恰好同步改成了「王大麻子」,这时候接收方的处理就比较麻烦了。还有第三个原因,就是「提醒全部成员」的表示方式,可能「@all」、「@group」、「@所有人」都会被选择,这是一个完全依赖应用层 UI 的选项。

所以「@ 成员」提醒消息并不能简单在文本消息中加入「@ + 人名」,解决方案是给普通消息(LCIMMessage)增加两个额外的属性:

  • mentionList,是一个字符串的数组,用来单独记录被提醒的 clientId 列表;
  • mentionAll,是一个 Bool 型的标志位,用来表示是否要提醒全部成员。

带有提醒信息的消息,有可能既有提醒全部成员的标志,也还单独设置了 mentionList,这由应用层去控制。发送方在发送「@ 成员」提醒消息的时候,如何输入、选择成员名称,这是业务方 UI 层面需要解决的问题,即时通讯 SDK 不关心其实现逻辑,SDK 只要求开发者在发送一条「@ 成员」消息的时候,调用 mentionListmentionAll 的 setter 方法,设置正确的成员列表即可。示例代码如下:

LCIMTextMessage textMessage = new LCIMTextMessage("@Tom 早点回家") {
MentionIdList = new string[] { "Tom" }
};
await conversation.Send(textMessage);

或者也可以通过设置 mentionAll 属性值提醒所有人:

LCIMTextMessage textMessage = new LCIMTextMessage("@all") {
MentionAll = true
};
await conv.Send(textMessage);

对于消息的接收方来说,可以通过调用 mentionListmentionAll 的 getter 方法来获得提醒目标用户的信息,示例代码如下:

jerry.onMessage = (conv, msg) => {
List<string> mentionIds = msg.MentionIdList;
};

此外,为了方便应用层 UI 展现,我们特意为 LCIMMessage 增加了两个标识位,用来显示被提醒的状态:

  • 一个是 mentionedAll 标识位,用来表示该消息是否提醒了当前对话的全体成员。只有 mentionAll 属性为 true,这个标识位才为 true,否则就为 false
  • 另一个是 mentioned 标识位,用来快速判断该消息是否提醒了当前登录用户。如果 mentionList 属性列表中包含有当前登录用户的 clientId,或者 mentionAll 属性为 true,那么 mentioned 方法都会返回 true,否则返回 false

调用示例如下:

client.OnMessage = (conv, msg) => {
bool mentioned = msg.MentionAll || msg.MentionList.Contains("Tom");
};

修改消息

开发者中心 > 你的游戏 > 游戏服务 > 云服务 > 即时通讯 > 设置 > 即时通讯设置 启用「允许通过 SDK 编辑消息」后,终端用户可以对自己已经发送的消息进行修改(Conversation#updateMessage 方法)。目前即时通讯服务端并没有在时效性上进行限制,不过只允许用户修改自己发出去的消息,不允许修改别人的消息。

修改已经发送的消息,并不是直接在老的消息对象上修改,而是像发新消息一样创建一个消息实例,然后调用 Conversation#updateMessage(oldMessage, newMessage) 方法来向云端提交请求,示例代码如下:

LCIMTextMessage newMessage = new LCIMTextMessage("修改后的消息内容");
await conversation.UpdateMessage(oldMessage, newMessage);

消息修改成功之后,对话内的其他成员会立刻接收到 MESSAGE_UPDATE 事件:

tom.OnMessageUpdated = (conv, msg) => {
if (msg is LCIMTextMessage textMessage) {
WriteLine($"内容 {textMessage.Text}, 消息 ID {textMessage.Id}");
}
};

对于 Android 和 iOS SDK 来说,如果开启了消息缓存的选项的话(默认开启),SDK 内部会先从缓存中修改这条消息记录,然后再通知应用层。所以对于开发者来说,收到这条通知之后刷新一下目标聊天页面,让消息列表更新即可(这时候消息列表会出现内容变化)。

如果系统修改了消息(例如触发了内置的敏感词过滤功能,或者云引擎的 hook 函数),发送者会收到 MESSAGE_UPDATE 事件,其他对话成员接收到的是修改过的消息。

撤回消息

除了修改消息,终端用户还可以撤回一条自己之前发送过的消息。 和修改消息类似,这一功能需要在控制台启用(开发者中心 > 你的游戏 > 游戏服务 > 云服务 > 即时通讯 > 设置 > 即时通讯设置 启用「允许通过 SDK 撤回消息」)。 同样,即时通讯服务端并没有在时效性上进行限制,不过只允许用户撤回自己发出去的消息,不允许撤回别人的消息。

撤回消息调用 Conversation#recallMessage 方法,示例代码如下:

await conversation.RecallMessage(message);

成功撤回消息后,对话内的其他成员会接收到 MESSAGE_RECALL 的事件:

tom.OnMessageRecalled = (conv, recalledMsg) => {
// recalledMsg 即为被撤回的消息
};

对于 Android 和 iOS SDK 来说,如果开启了消息缓存的选项的话(默认开启),SDK 内部需要保证数据的一致性,所以会先从缓存中删除这条消息记录,然后再通知应用层。对于开发者来说,收到这条通知之后刷新一下目标聊天页面,让消息列表更新即可(此时消息列表中的消息会直接变少,或者显示撤回提示)。

暂态消息

有时候我们需要发送一些特殊的消息,譬如聊天过程中「某某正在输入…」这样的实时状态信息,或者当群聊的名称修改以后给该群成员发送「群名称被某某修改为 XX」这样的通知信息。这类消息与终端用户发送的消息不一样,发送者不要求把它保存到历史记录里,也不要求一定会被送达(如果成员不在线或者现在网络异常,那么没有下发下去也无所谓),这种需求可以使用「暂态消息」来实现。

「暂态消息」是一种特殊的消息,与普通消息相比有以下几点不同:

  • 它不会被自动保存到云端,以后在历史消息中无法找到它
  • 只发送给当时在线的成员,不支持延迟接收,离线用户更不会收到推送通知
  • 对当时在线成员也不保证百分百送达,如果因为当时网络原因导致下发失败,服务端不会重试

我们可以用「暂态消息」发送一些实时的、频繁变化的状态信息,或者用来实现简单的控制协议。

暂态消息的数据和构造方式与普通消息是一样的,只是其发送方式与普通消息有一些区别。到目前为止,我们演示的 LCIMConversation 发送消息接口都是这样的:

public async Task<LCIMMessage> Send(LCIMMessage message, LCIMMessageSendOptions options = null);

其实即时通讯 SDK 还允许在发送一条消息的时候,指定额外的参数 LCIMMessageOptionLCIMConversation 完整的消息发送接口如下:

/// <summary>
/// Sends a message in this conversation.
/// </summary>
/// <param name="message">The message to send.</param>
/// <returns></returns>
public async Task<LCIMMessage> Send(LCIMMessage message, LCIMMessageSendOptions options = null);

通过 LCIMMessageOption 参数我们可以指定:

  • 是否作为暂态消息发送(设置 transient 属性);
  • 服务端是否需要通知该消息的接收状态(设置 receipt 属性,消息回执,后续章节会进行说明);
  • 消息的优先级(设置 priority 属性,后续章节会说明);
  • 是否为「遗愿消息」(设置 will 属性,后续章节会说明);
  • 消息对应的离线推送内容(设置 pushData 属性,后续章节会说明),如果消息接收方不在线,会推送指定的内容。

如果我们需要让 Tom 在聊天页面的输入框获得焦点的时候,给群内成员同步一条「Tom 正在输入…」的状态信息,可以使用如下代码:

LCIMTextMessage textMessage = new LCIMTextMessage("Tom 正在输入…");
LCIMMessageSendOptions option = new LCIMMessageSendOptions() {
Transient = true
};
await conversation.Send(textMessage, option);

暂态消息的接收逻辑和普通消息一样,开发者可以按照消息类型进行判断和处理,这里不再赘述。上面使用了内建的文本消息只是一种示例,从展现端来说,我们如果使用特定的类型来表示「暂态消息」,是一种更好的方案。即时通讯 SDK 并没有提供固定的「暂态消息」类型,可以由开发者根据自己的业务需要来实现专门的自定义,具体可以参考后述章节:扩展自己的消息类型

消息回执

即时通讯服务端在进行消息投递的时候,会按照消息上行的时间先后顺序下发(先收到的消息先下发,保证顺序性),且内部协议上会要求 SDK 对收到的每一条消息进行确认(ack)。如果 SDK 收到了消息,但是在发送 ack 的过程中出现网络丢包,即时通讯服务端还是会认为消息没有投递下去,之后会再次投递,直到收到 SDK 的应答确认为止。与之对应,SDK 内部也进行了消息去重处理,保证在上面这种异常条件下应用层也不会收到重复的消息。所以我们的消息系统从协议上是可以保证不丢任何一条消息的。

不过,有些业务场景会对消息投递的细节有更高的要求,例如消息的发送方要能知道什么时候接收方收到了这条消息,什么时候 ta 又点开阅读了这条消息。有一些偏重工作写作或者私密沟通的产品,消息发送者在发送一条消息之后,还希望能看到消息被送达和阅读的实时状态,甚至还要提醒未读成员。这样「苛刻」的需求,就依赖于我们的「消息回执」功能来实现。

与上一节「暂态消息」的发送类似,要使用消息回执功能,需要在发送消息时在 LCIMMessageOption 参数中标记「需要回执」选项:

LCIMTextMessage textMessage = new LCIMTextMessage("一条非常重要的消息。");
LCIMMessageSendOptions option = new LCIMMessageSendOptions {
Receipt = true
};
await conversation.Send(textMessage, option);

注意:

只有在发送时设置了「需要回执」的标记,云端才会发送回执,默认不发送回执,且目前消息回执只支持单聊对话(成员不超过 2 人)。

那么发送方后续该如何响应回执的通知消息呢?

送达回执

当接收方收到消息之后,云端会向发送方发出一个回执通知,表明消息已经送达。请注意与「已读回执」区别开。

// Tom 用自己的名字作为 clientId 建立了一个 LCIMClient
LCIMClient client = new LCIMClient("Tom");
// Tom 登录到系统
await client.Open();

// 设置送达回执
client.OnMessageDelivered = (conv, msgId) => {
// 在这里可以书写消息送达之后的业务逻辑代码
};
// 发送消息
LCIMTextMessage textMessage = new LCIMTextMessage("夜访蛋糕店,约吗?");
await conversation.Send(textMessage);

请注意这里送达回执的内容,不是某一条具体的消息,而是当前对话内最后一次送达消息的时间戳(lastDeliveredAt)。最开始我们有过解释,服务端在下发消息的时候,是能够保证顺序的,所以在送达回执的通知里面,我们不需要对逐条消息进行确认,只给出当前确认送达的最新消息的时间戳,那么在这之前的所有消息就都是已经送达的状态。在 UI 层展示的时候,可以将早于 lastDeliveredAt 的消息都标记为「已送达」。

已读回执

消息送达只是即时通讯服务端和客户端之间的投递行为完成了,可能终端用户并没有进入对话聊天页面,或者根本没有激活应用(Android 平台应用在后台也是可以收到消息的),所以「送达」并不等于终端用户真正「看到」了这条消息。

即时通讯服务还支持「已读」消息的回执,不过这首先需要接收方显式完成消息「已读」的确认。

由于即时通讯服务端是顺序下发新消息的,客户端不需要对每一条消息单独进行「已读」确认。我们设想的场景如下图所示:

在一个标题为「欢迎回来」的对话框中写着「好久不见!你有 5002 条未读消息。是否跳过这些消息?(选择「是」将清除所有未读消息标记)」。对话框的底部有两个按钮,分别为「是,跳过」和「否」。

用户在进入一个对话的时候,一次性清除当前对话的所有未读消息即可。Conversation 的清除接口如下:

/// <summary>
/// Mark the last message of this conversation as read.
/// </summary>
/// <returns></returns>
public Task Read();

对方「阅读」了消息之后,云端会向发送方发出一个回执通知,表明消息已被阅读。

Tom 和 Jerry 聊天,Tom 想及时知道 Jerry 是否阅读了自己发去的消息,这时候双方的处理流程是这样的:

  1. Tom 向 Jerry 发送一条消息,且标记为「需要回执」:

    LCIMTextMessage textMessage = new LCIMTextMessage("一条非常重要的消息。");
    LCIMMessageSendOptions options = new LCIMMessageSendOptions {
    Receipt = true
    };
    await conversation.Send(textMessage);
  2. Jerry 阅读 Tom 发的消息后,调用对话上的 read 方法把「对话中最近的消息」标记为已读:

    await conversation.Read();
  3. Tom 将收到一个已读回执,对话的 lastReadAt 属性会更新。此时可以更新 UI,把时间戳小于 lastReadAt 的消息都标记为已读:

    tom.OnLastReadAtUpdated = (conv) => {
    // Jerry 阅读了你的消息。可以通过调用 conversation.LastReadAt 来获得对方已经读取到的时间
    };

注意:

要使用已读回执,应用需要在初始化的时候开启 未读消息数更新通知 选项。

消息免打扰

假如某一用户不想再收到某对话的消息提醒,但又不想直接退出对话,可以使用静音操作,即开启「免打扰模式」。具体可以参考即时通讯开发指南第三篇的《消息免打扰》一节。

Will(遗愿)消息

即时通讯服务还支持一类比较特殊的消息:Will(遗愿)消息。「Will 消息」是在一个用户突然掉线之后,系统自动通知对话的其他成员关于该成员已掉线的消息,好似在掉线后要给对话中的其他成员一个妥善的交待,所以被戏称为「遗愿」消息,如下图中的「Tom 已断线,无法收到消息」:

在一个名为「Tom &amp; Jerry」的对话中,Jerry 收到内容为「Tom 已断线,无法收到消息」的 Will 消息。这条消息看起来像一条系统通知,与普通消息的样式不同。

要发送 Will 消息,用户需要设定好消息内容发给云端,云端并不会将其马上发送给对话的成员,而是缓存下来,一旦检测到该用户掉线,云端立即将这条遗愿消息发送出去。开发者可以利用它来构建自己的断线通知的逻辑。

LCIMTextMessage message = new LCIMTextMessage("我是一条遗愿消息,当发送者意外下线的时候,我会被下发给对话里面的其他成员。");
LCIMMessageSendOptions options = new LCIMMessageSendOptions {
Will = true
};
await conversation.Send(message, options);

客户端发送完毕之后就完全不用再关心这条消息了,云端会自动在发送方异常掉线后通知其他成员,接收端则根据自己的需求来做 UI 的展现。

Will 消息有 如下限制

  • Will 消息是与当前用户绑定的,并且只对最后一次设置的「对话 + 消息」生效。如果用户在多个对话中设置了 Will 消息,那么只有最后一次设置有效;如果用户在同一个对话中设置了多条 Will 消息,也只有最后一次设置有效。
  • Will 消息不会进入目标对话的消息历史记录。
  • 当用户主动退出即时通讯服务时,系统会认为这是计划性下线,不会下发 Will 消息(如有)。

消息内容过滤

对于多人参与的聊天群组来说,内容的审核和实时过滤是产品运营上的基本要求。我们即时通讯服务默认提供了敏感词过滤的功能,具体请参考即时通讯开发指南第三篇的《消息内容的实时过滤》一节。

本地发送失败的消息

有时你可能需要将发送失败的消息临时保存到客户端本地的缓存中,等到合适时机再进行处理。例如,将由于网络突然中断而发送失败的消息先保留下来,在消息列表中展示这种消息时,额外添加出错的提示符号和重发按钮,待网络恢复后再由用户选择是否重发。

即时通讯 Android 和 iOS SDK 默认提供了消息本地缓存的功能,消息缓存中保存的都是已经成功上行到云端的消息,并且能够保证和云端的数据同步。为了方便开发者,SDK 也支持将一时失败的消息加入到缓存中。

将消息加入缓存的代码如下:

// 暂不支持

将消息从缓存中删除:

// 暂不支持

从缓存中取出来的消息,在 UI 展示的时候可以根据 message.status 的属性值来做不同的处理,status 属性为 LCIMMessageStatusFailed 时即表示是发送失败了的本地消息,这时可以在消息旁边显示一个重新发送的按钮。通过将失败消息加入到 SDK 缓存中,还有一个好处就是,消息从缓存中取出来再次发送,不会造成服务端消息重复,因为 SDK 有做专门的去重处理。

离线推送通知

对于移动设备来说,在聊天的过程中部分客户端难免会临时下线,如何保证离线用户也能及时收到消息,是我们需要考虑的重要问题。即时通讯云端会在用户下线的时候,主动通过「Push Notification」这种外部方式来通知客户端新消息到达事件,以促使用户尽快打开应用查看新消息。

iOS 和 Android 分别提供了内置的离线消息推送通知服务,但是使用的前提是按照推送文档配置 iOS 的推送证书和开启 Android 推送的开关,详细请阅读如下文档:

  1. 即时通讯总览
  2. Android 混合推送开发指南 / iOS 推送开发指南

云端会将用户的即时通讯 clientId 与推送服务的设备数据 _Installation 自动进行关联。当用户 A 发出消息后,如果对话中部分成员当前不在线,而且这些成员使用的是 iOS 设备,或者是成功开通混合推送功能的 Android 设备的话,云端会自动将即时通讯消息转成特定的推送通知发送至客户端,同时我们也提供扩展机制,允许开发者对接第三方的消息推送服务。

要有效使用本功能,关键在于 自定义推送的内容。我们提供三种方式允许开发者来指定推送内容:

  1. 静态配置提醒消息

    用户可以在控制台中为应用设置一个全局的静态 JSON 字符串,指定固定内容来发送通知。例如,我们进入 开发者中心 > 你的游戏 > 游戏服务 > 云服务 > 即时通讯 > 设置 > 离线推送,填入:

    { "alert": "您有新的消息", "badge": "Increment" }

    那么在有新消息到达的时候,符合条件的离线用户会收到一条「您有新的消息」的通知栏消息。

    注意,这里 badge 参数为 iOS 设备专用,且 Increment 大小写敏感,表示自动增加应用 badge 上的数字计数。 通常需要在打开或退出应用时,通过设置 Installation 的 badge 字段清零 badge 计数。

    此外,对于 iOS 设备您还可以设置声音等推送属性,具体的字段可以参考 推送 REST API 使用指南 的《消息内容参数》一节。

  2. 客户端发送消息的时候额外指定推送信息

    第一种方法虽然发出去了通知,但是因为通知文本与实际消息内容完全无关,存在一些不足。有没有办法让推送消息的内容与即时通讯消息动态相关呢?

    还记得我们发送「暂态消息」时的 LCIMMessageOption 参数吗?即时通讯 SDK 允许客户端在发送消息的时候,指定附加的推送信息(在 LCIMMessageOption 中设置 pushData 属性),这样在需要离线推送的时候我们就会使用这里设置的内容来发出推送通知。示例代码如下:

LCIMTextMessage message = new LCIMTextMessage("Jerry,今晚有比赛,我约了 Kate,咱们仨一起去酒吧看比赛啊?!");
LCIMMessageSendOptions sendOptions = new LCIMMessageSendOptions {
PushData = new Dictionary<string, object> {
{ "alert", "您有一条未读的消息"},
{ "category", "消息"},
{ "badge", 1},
{ "sound", "message.mp3"}, // 声音文件名,前提在应用里存在
{ "custom-key", "由用户添加的自定义属性,custom-key 仅是举例,可随意替换"}
}
};
  1. 服务端动态生成通知内容

    第二种方法虽然动态,但是需要在客户端发送消息的时候提前准备好推送内容,这对于开发阶段的要求比较高,并且在灵活性上有比较大的限制,所以看上去也不够完美。

    我们还提供了第三种方式,让开发者在推送动态内容的时候,也不失实现上的灵活性。这种方式需要使用即时通讯 Hook 机制在服务端来统一指定离线推送消息内容,感兴趣的开发者可以参阅详解消息 hook 与系统对话

三种方式之间的优先级如下:服务端动态生成通知 > 客户端发送消息的时候额外指定推送信息 > 静态配置提醒消息

也就是说如果开发者同时采用了多种方式来指定消息推送,那么有服务端动态生成的通知的话,最后以它为准进行推送。其次是客户端发送消息的时候额外指定推送内容,最后是静态配置的提醒消息。

实现原理与限制

同时使用了推送服务和即时通讯服务的应用,客户端在成功登录即时通讯服务时,SDK 会自动关联当前的 clientId 和设备数据(推送服务中的 Installation 表)。关联的方式是通过让目标设备 订阅 名为 clientId 的 Channel 实现的。开发者可以在数据存储的 _Installation 表中的 channels 字段查到这组关联关系。在实际离线推送时,云端系统会根据用户 clientId 找到对应的关联设备进行推送。

由于即时通讯触发的推送量比较大,内容单一,所以推送服务云端不会保留这部分记录,开发者在 开发者中心 > 你的游戏 > 游戏服务 > 云服务 > 推送通知 > 推送记录 中也无法找到这些记录。

推送服务的通知过期时间是 7 天,也就是说,如果一个设备 7 天内没有连接到 APNs、MPNs 或设备对应的混合推送平台,系统将不会再给这个设备推送通知。

其他推送设置

iOS 环境下,离线消息默认推送至 APNs 的生产环境。 推送时使用 "_profile": "dev" 可以切换至 APNs 的开发环境(如果基于证书鉴权方式进行推送,此时会使用开发证书):

{
"alert": "您有一条未读消息",
"_profile": "dev"
}

基于 Apple 推荐使用的 Token Authentication 方式进行推送时,如果应用配置了多个不同 Team ID 的 Private Key,请确认目标用户设备使用的 APNs Team ID 并将其填写在 _apns_team_id 参数内,以保证推送正常进行,只有指定 Team ID 的设备能收到推送(Apple 不允许在一次推送请求中向多个从属于不同 Team ID 的设备发推送)。如:

{
"alert": "您有一条未读消息",
"_apns_team_id": "my_fancy_team_id"
}

_profile_apns_team_id 属性为推送服务内部使用,均不会实际推送。 指定附加推送信息时,支持为不同种类的设备(比如 iosandroid)附加不同的推送信息,需要特别注意的是,_profile_apns_team_id 这两个内部属性不要在 ios 对象内部指定,否则不会生效。 例如,这样的附加推送消息会导致离线消息被推送至 APNs 的生产环境:

{
"ios": {
"badge": "Increment",
"category": "NEW_CHAT_MESSAGE",
"sound": "default",
"thread-id": "chat",
"alert": {
"title": "您有一条未读消息",
"body": "因为 _profile 内部属性的位置错误,这条消息仍然会被推送到 APNs 的生产环境"
},
"_profile": "dev"
},
"android": {
"title": "您有一条未读消息",
"alert": ""
}
}

这样才能推送至开发环境:

{
"_profile": "dev",
"ios": {
/* 略 */
},
"android": {
/* 略 */
}
}

目前,开发者中心 > 你的游戏 > 游戏服务 > 云服务 > 即时通讯 > 设置 > 离线推送 这里的推送内容也支持一些内置变量,你可以将上下文信息直接设置到推送内容中:

  • ${convId} 推送相关的对话 ID
  • ${timestamp} 触发推送的时间戳(Unix 时间戳)
  • ${fromClientId} 消息发送者的 clientId

离线消息同步

离线推送通知是一种非常有效的提醒用户的手段,但是如果用户不上线,即时通讯的消息就总是无法下发,客户端如果长时间下线,会导致大量消息堆积在云端,此后如果用户再上线,我们该如何处理才能保证消息完全不丢失呢?

即时通讯服务提供客户端主动从云端「拉」的方式。云端会记录下用户在每一个参与对话中接收的最后一条消息的位置,在用户重新登录上线后,实时计算出用户离线期间产生未读消息的对话列表及对应的未读消息数,以「未读消息数更新」的事件通知到客户端,然后客户端在需要的时候来主动拉取这些离线消息。

未读消息数更新通知

在客户端重新登录上线后,即时通讯云端会实时计算下线时间段内当前用户参与过的对话中的新消息数量。

客户端只有设置了主动拉取的方式,云端才会在必要的时候下发这一通知。如前所述,对于 JavaScript / Android / iOS SDK 来说,仅支持客户端主动拉取未读消息,所以不需要再做什么设置。

客户端 SDK 会在 IMConversation 上维护一个 unreadMessagesCount 字段,来统计当前对话中存在有多少未读消息。

客户端用户登录之后,云端会以「未读消息数更新」事件的形式,将当前用户所在的多对 <Conversation, UnreadMessageCount, LastMessage> 数据通知到客户端,这就是客户端维护的 <Conversation, UnreadMessageCount> 初始值。之后 SDK 在收到新的在线消息的时候,会自动增加对应的 unreadMessageCount 计数。直到用户把某一个对话的未读消息清空,这时候云端和 SDK 的 <Conversation, UnreadMessageCount> 计数都会清零。

注意:开启未读消息数后,在开发者没有主动重置未读消息的情况下,未读消息数将一直累计。 客户端再次离线并不会重置未读消息数。 包括客户端在线时收到的消息,也会导致未读消息数增加。 因此开发者需要在合适时机通过将对话标记为已读主动清除未读消息数。

客户端 SDK 在 <Conversation, UnreadMessageCount> 数字变化的时候,会通过 IMClient 派发「未读消息数量更新(UNREAD_MESSAGES_COUNT_UPDATE)」事件到应用层。开发者可以监听 UNREAD_MESSAGES_COUNT_UPDATE 事件,在对话列表界面上更新这些对话的未读消息数量。建议开发者在应用层面对未读计数的结果进行持久化缓存,如果同一个对话有两个不同的未读数,则使用新数据直接覆盖老数据,这样对话列表里面展示的未读数会比较准确。

tom.OnUnreadMessagesCountUpdated = (convs) => {
foreach (LCIMConversation conv in convs) {
// conv.Unread 即该 conversation 的未读消息数量
}
};

对开发者来说,在 UNREAD_MESSAGES_COUNT_UPDATE 事件响应的时候,SDK 传给应用层的 Conversation 对象,其 lastMessage 应该是当前时间点当前用户在当前对话里面接收到的最后一条消息,开发者如果要展示更多的未读消息,就需要通过消息拉取的接口来主动获取了(参见即时通讯开发指南第一篇的《聊天记录查询》一节。

清除对话未读消息数的唯一方式是调用 Conversation#read 方法将对话标记为已读,一般来说开发者至少需要在下面两种情况下将对话标记为已读:

  • 在对话列表点击某对话进入到对话页面时
  • 用户正在某个对话页面聊天,并在这个对话中收到了消息时

iOS 和 Android 应用层需要持久化缓存未读计数的细节说明

对于未读通知的下发时机和数量,iOS 和 Java/Android 两个平台的 SDK 在内部处理上稍有差异:iOS SDK(Objective-C 和 Swift 都包括)在每次登录即时通讯云端的时候,都会获得云端下发的大量未读通知;而 Java/Android SDK 由于内部持久化缓存了通知的时间戳(能减轻服务端压力),所以登录即时通讯云端之后客户端只会收到上次通知时间戳之后发生了变化的部分未读数通知。

因此 Java SDK 的开发者需要在应用层缓存收到的未读数通知(同一个对话的未读数采用覆盖的方式来更新),而 iOS SDK 这里收到的大量未读通知并不等于全量数据(云端追踪的有未读消息的对话数不超过 50 个),所以也是一样需要在应用层面缓存收到的未读计数结果,这样才能保证对话列表超过 50 个之后未读计数值的准确性。

多端登录与单设备登录

一个用户可以使用相同的账号在不同的客户端上登录(例如 QQ 网页版和手机客户端可以同时接收到消息和回复消息,实现多端消息同步),而有一些场景下,需要禁止一个用户同时在不同客户端登录,例如我们不能用同一个微信账号在两个手机上同时登录。即时通讯服务提供了灵活的机制,来满足 多端登录单设备登录 这两种完全相反的需求。

即时通讯 SDK 在生成 IMClient 实例的时候,允许开发者在 clientId 之外,增加一个额外的 tag 标记。云端在用户主动登录的时候,会检查 <ClientId, Tag> 组合的唯一性。如果当前用户已经在其他设备上使用同样的 tag 登录了,那么云端会强制让之前登录的设备下线。如果多个 tag 不发生冲突,那么云端会把他们当成独立的设备进行处理,应该下发给该用户的消息会分别下发给所有设备,不同设备上的未读消息计数则是合并在一起的(各端之间消息状态是同步的);该用户在单个设备上发出来的上行消息,云端也会默认同步到其他设备。

基于以上机制,即时通讯可以支持应用实现多种业务需求:

  1. 无限制的多端登录:不设置 tag,默认对用户的多端登录不作限制。用户可以在多个设备上登录,比如在手机和平板上同时登录,甚至在两台不同的手机上登录,多个设备可以同时接收和回复消息。
  2. 单设备登录:在所有客户端都设置同一个 tag,限制用户只能在一台设备上登录。
  3. 有限制的多端登录:通过设置不同的 tag,允许用户在多台不同类型的设备上登录。例如,我们可以设计三种 tagMobilePadWeb,分别对应三种类型的设备:手机、平板和电脑,那么用户分别在三种设备上登录就都是允许的,但是却不能同时在两台电脑上登录。详见下面的代码示例。

设置登录标记

按照上面的方案,以手机端登录为例,在创建 IMClient 实例的时候,我们增加 tag: Mobile 这样的标记:

LCIMClient client = new LCIMClient(clientId, "Mobile", "your-device-id");

之后如果同一个用户在另一个手机上再次登录,则较早前登录系统的客户端会被强制下线。

处理登录冲突

即时通讯云端在登录用户的 <ClientId, Tag> 相同的时候,总是踢掉较早登录的设备,这时候较早登录设备端会收到被云端下线(CONFLICT)的事件通知:

tom.OnClose = (code, detail) => {

};

如上述代码中,被动下线的时候,云端会告知原因,因此客户端在做展现的时候也可以做出类似于 QQ 一样友好的通知。

以上提到的登录均指用户主动进行登录操作。已登录用户在应用启动、网络中断等场景下,SDK 会自动重新登录。这种情况下,如果触发登录冲突,云端并不会踢掉较早登录的设备,自动重新登录的设备则会收到登录冲突的报错,登录失败。

相应地,应用开发者如果希望在用户主动登录触发冲突时,不踢掉较早登录的设备,而提示用户登录失败,可以在登录时传入参数指明这一点:

await tom.Open(false);

扩展自己的消息类型

尽管即时通讯服务默认已经包含了丰富的消息类型,但是我们依然支持开发者根据业务需要扩展自己的消息类型,例如允许用户之间发送名片、红包等等。这里「名片」和「红包」就可以是应用层定义的自己的消息类型。

自定义消息属性

即时通讯 SDK 默认提供了多种消息类型用来满足常见的需求:

  • TextMessage 文本消息
  • ImageMessage 图像消息
  • AudioMessage 音频消息
  • VideoMessage 视频消息
  • FileMessage 普通文件消息(.txt/.doc/.md 等各种)
  • LocationMessage 地理位置消息

这些消息类型还支持应用层设置若干 key-value 自定义属性来实现扩展。譬如有一条文本消息需要附带城市信息,这时候开发者使用消息类中预留的 attributes 属性就可以保存额外信息了。

LCIMTextMessage messageWithCity = new LCIMTextMessage("天气太冷了");
messageWithCity["city"] = "北京";

自定义消息类型

在默认的消息类型完全无法满足需求的时候,可以实现和使用自定义的消息类型。

继承于 LCIMTypedMessage,开发者也可以扩展自己的富媒体消息。其要求和步骤是:

  • 首先定义一个自定义的子类继承自 LCIMTypedMessage
  • 然后在初始化的时候注册这个子类。
class EmojiMessage : LCIMTypedMessage {
public const int EmojiMessageType = 1;

public override int MessageType => EmojiMessageType;

public string Ecode {
get {
return data["ecode"] as string;
} set {
data["ecode"] = value;
}
}
}

// 注册子类
LCIMTypedMessage.Register(EmojiMessage.EmojiMessageType, () => new EmojiMessage());

自定义消息的接收,可以参看即时通讯开发指南第一篇的《再谈接收消息》。

进一步阅读