Skip to main content
Version: v3

一,从简单的单聊、群聊、收发图文消息开始

阅读准备

在阅读本章之前,如果你还不太了解即时通讯服务的总体架构,建议先阅读即时通讯服务总览。 另外,如果你还没有下载对应开发环境(语言)的 SDK,请参考相应语言的 SDK 配置指南完成 SDK 安装与初始化:

本章导读

在很多产品里面,都存在让用户实时沟通的需求,例如:

  • 员工与客户之间的实时交流,如房地产行业经纪人与客户的沟通,商业产品客服与客户的沟通,等等。
  • 企业内部沟通协作,如内部的工作流系统、文档/知识库系统,增加实时互动的方式可能就会让工作效率得到极大提升。
  • 直播互动,不论是文体行业的大型电视节目中的观众互动、重大赛事直播,娱乐行业的游戏现场直播、网红直播,还是教育行业的在线课程直播、KOL 知识分享,在支持超大规模用户积极参与的同时,也需要做好内容审核管理。
  • 应用内社交,游戏公会嗨聊,等等。社交产品要能长时间吸引住用户,除了实时性之外,还需要更多的创新玩法,对于标准化通讯服务会存在更多的功能扩展需求。

根据功能需求的层次性和技术实现的难易程度不同,我们分为多篇文档来一步步地讲解如何利用即时通讯服务实现不同业务场景需求:

  • 本篇文档,我们会从实现简单的单聊/群聊开始,演示创建和加入「对话」、发送和接收富媒体「消息」的流程,同时让大家了解历史消息云端保存与拉取的机制,希望可以满足在成熟产品中快速集成一个简单的聊天页面的需求。
  • 离线消息文档会介绍一些特殊消息的处理,例如 @ 成员提醒、撤回和修改、消息送达和被阅读的回执通知等,离线状态下的推送通知和消息同步机制,多设备登录的支持方案,以及如何扩展自定义消息类型,希望可以满足一个社交类产品的多方面需求。
  • 权限与聊天室文档会介绍一下系统的安全机制,包括第三方的操作签名,同时也会介绍直播聊天室和临时对话的用法,希望可以帮助开发者提升产品的安全性和易用性,并满足特殊场景的需求。
  • Hook 与系统对话文档会介绍即时通讯服务端 Hook 机制,系统对话的用法,以及给出一个基于这两个功能打造一个属于自己的聊天机器人的方案,希望可以满足业务层多种多样的扩展需求。

希望开发者最终顺利完成产品开发的同时,也对即时通讯服务的体系结构有一个清晰的了解,以便于产品的长期维护和定制化扩展。

一对一单聊

在开始讨论聊天之前,我们需要介绍一下在即时通讯 SDK 中的 IMClient 对象:

IMClient 对应实体的是一个用户,它代表着一个用户以客户端的身份登录到了即时通讯的系统。

具体可以参考即时通讯服务总览中《clientId、用户和登录》一节的说明。

创建 IMClient

假设我们产品中有一个叫「Tom」的用户,首先我们在 SDK 中创建出一个与之对应的 IMClient 实例(创建实例前请确保已经成功初始化了 SDK):

LCIMClient tom = new LCIMClient("Tom");

注意这里一个 IMClient 实例就代表一个终端用户,我们需要把它全局保存起来,因为后续该用户在即时通讯上的所有操作都需要直接或者间接使用这个实例。

登录即时通讯服务器

创建好了「Tom」这个用户对应的 IMClient 实例之后,我们接下来需要让该实例「登录」即时通讯服务器。 只有登录成功之后客户端才能开始与其他用户聊天,也才能接收到云端下发的各种事件通知。

这里需要说明一点,有些 SDK(比如 C# SDK)在创建 IMClient 实例的同时会自动进行登录,另一些 SDK(比如 iOS 和 Android SDK)则需要调用开发者手动执行 open 方法进行登录:

await tom.Open();

使用 _User 登录

除了应用层指定 clientId 登录之外,我们也支持直接使用 _User 对象来创建 IMClient 并登录。这种方式能直接利用云端内置的用户鉴权系统而省掉登录签名操作,更方便地将存储和即时通讯这两个模块结合起来使用。示例代码如下:

var user = await LCUser.Login("USER_NAME", "PASSWORD");
var client = new LCIMClient(user);

创建对话 Conversation

用户登录之后,要开始与其他人聊天,需要先创建一个「对话」。

对话(Conversation)是消息的载体,所有消息都是发送给对话,即时通讯服务端会把消息下发给所有在对话中的成员。

Tom 完成了登录之后,就可以选择用户聊天了。现在他要给 Jerry 发送消息,所以需要先创建一个只有他们两个成员的 Conversation

var conversation = await tom.CreateConversation(new string[] { "Jerry" }, name: "Tom & Jerry", unique: true);

createConversation 这个接口会直接创建一个对话,并且该对话会被存储在 _Conversation 表内,可以打开 开发者中心 > 你的游戏 > 游戏服务 > 云服务 > 数据存储 > 结构化数据 查看数据。不同 SDK 提供的创建对话接口如下:

/// <summary>
/// Creates a conversation
/// </summary>
/// <param name="members">The list of clientIds of participants in this conversation (except the creator)</param>
/// <param name="name">The name of this conversation</param>
/// <param name="unique">Whether this conversation is unique;
/// if it is true and an existing conversation contains the same composition of members,
/// the existing conversation will be reused, otherwise a new conversation will be created.</param>
/// <param name="properties">Custom attributes of this conversation</param>
/// <returns></returns>
public async Task<LCIMConversation> CreateConversation(
IEnumerable<string> members,
string name = null,
bool unique = true,
Dictionary<string, object> properties = null) {
return await ConversationController.CreateConv(members: members,
name: name,
unique: unique,
properties: properties);
}

虽然不同语言/平台接口声明有所不同,但是支持的参数是基本一致的。在创建一个对话的时候,我们主要可以指定:

  1. members:必要参数,包含对话的初始成员列表,请注意当前用户作为对话的创建者,是默认包含在成员里面的,所以 members 数组中可以不包含当前用户的 clientId

  2. name:对话名字,可选参数,上面代码指定为了「Tom & Jerry」。

  3. attributes:对话的自定义属性,可选。上面示例代码没有指定额外属性,开发者如果指定了额外属性的话,以后其他成员可以通过 LCIMConversation 的接口获取到这些属性值。附加属性在 _Conversation 表中被保存在 attr 列中。

  4. unique/isUnique 或者是 LCIMConversationOptionUnique:唯一对话标志位,可选。

    • 如果设置为唯一对话,云端会根据完整的成员列表先进行一次查询,如果已经有正好包含这些成员的对话存在,那么就返回已经存在的对话,否则才创建一个新的对话。
    • 如果指定 unique 标志为假,那么每次调用 createConversation 接口都会创建一个新的对话。
    • 未指定 unique 时,SDK 默认值为真。
    • 从通用的聊天场景来看,不管是 Tom 发出「创建和 Jerry 单聊对话」的请求,还是 Jerry 发出「创建和 Tom 单聊对话」的请求,或者 Tom 以后再次发出创建和 Jerry 单聊对话的请求,都应该是同一个对话才是合理的,否则可能因为聊天记录的不同导致用户混乱。
  5. 对话类型的其他标志,可选参数,例如 transient/isTransient 表示「聊天室」,tempConv/tempConvTTLLCIMConversationOptionTemporary 用来创建「临时对话」等等。什么都不指定就表示创建普通对话,对于这些标志位的含义我们先不管,以后会有说明。

创建对话之后,可以获取对话的内置属性,云端会为每一个对话生成一个全局唯一的 ID 属性:Conversation.id,它是其他用户查询对话时常用的匹配字段。

发送消息

对话已经创建成功了,接下来 Tom 可以在这个对话中发出第一条文本消息了:

var textMessage = new LCIMTextMessage("Jerry,起床了!");
await conversation.Send(textMessage);

上面接口实现的功能就是向对话中发送一条消息,同一对话中其他在线成员会立刻收到此消息。

现在 Tom 发出了消息,那么接收者 Jerry 他要在界面上展示出来这一条新消息,该怎么来处理呢?

接收消息

在另一个设备上,我们用 Jerry 作为 clientId 来创建一个 IMClient 并登录即时通讯服务(与前两节 Tom 的处理流程一样):

var jerry = new LCIMClient("Jerry");

Jerry 作为消息的被动接收方,他不需要主动创建与 Tom 的对话,可能也无法知道 Tom 创建好的对话信息,Jerry 端需要通过设置即时通讯客户端事件的回调函数,才能获取到 Tom 那边操作的通知。

即时通讯客户端事件回调能处理多种服务端通知,这里我们先关注这里会出现的两个事件:

  • 用户被邀请进入某个对话的通知事件。Tom 在创建和 Jerry 的单聊对话的时候,Jerry 这边就能立刻收到一条通知,获知到类似于「Tom 邀请你加入了一个对话」的信息。
  • 已加入对话中新消息到达的通知。在 Tom 发出「Jerry,起床了!」这条消息之后,Jerry 这边也能立刻收到一条新消息到达的通知,通知中带有消息具体数据以及对话、发送者等上下文信息。

现在,我们看看具体应该如何响应服务端发过来的通知。Jerry 端会分别处理「加入对话」的事件通知和「新消息到达」的事件通知:

jerry.OnInvited = (conv, initBy) => {
WriteLine($"{initBy} 邀请 Jerry 加入 {conv.Id} 对话");
};
jerry.OnMessage = (conv, msg) => {
if (msg is LCIMTextMessage textMessage) {
// textMessage.ConversationId 是该条消息所属于的对话 ID
// textMessage.Text 是该文本消息的文本内容
// textMessage.FromClientId 是消息发送者的 clientId
}
};

Jerry 端实现了上面两个事件通知函数之后,就顺利收到 Tom 发送的消息了。之后 Jerry 也可以回复消息给 Tom,而 Tom 端实现类似的接收流程,那么他们俩就可以开始愉快的聊天了。

我们现在可以回顾一下 Tom 和 Jerry 发送第一条消息的过程中,两方完整的处理时序:

sequenceDiagram Tom->>Cloud: 1. Tom 将 Jerry 加入对话 Cloud-->>Jerry: 2. 下发通知:你被邀请加入对话 Jerry-->>UI: 3. 加载聊天的 UI 界面 Tom->>Cloud: 4. 发送消息 Cloud-->>Jerry: 5. 下发通知:接收到有新消息 Jerry-->>UI: 6. 显示收到的消息内容

在聊天过程中,接收方除了响应新消息到达通知之外,还需要响应多种对话成员变动通知,例如「新用户 XX 被 XX 邀请加入了对话」、「用户 XX 主动退出了对话」、「用户 XX 被管理员剔除出对话」,等等。 云端会实时下发这些事件通知给客户端,具体细节可以参考后续章节:成员变更的事件通知总结

多人群聊

上面我们讨论了一对一单聊的实现流程,假设我们还需要实现一个「朋友群」的多人聊天,接下来我们就看看怎么完成这一功能。

从即时通讯云端来看,多人群聊与单聊的流程十分接近,主要差别在于对话内成员数量的多少。群聊对话支持在创建对话的时候一次性指定全部成员,也允许在创建之后通过邀请的方式来增加新的成员。

创建多人群聊对话

在 Tom 和 Jerry 的对话中(假设对话 ID 为 CONVERSATION_ID,这只是一个示例,并不代表实际数据),后来 Tom 又希望把 Mary 也拉进来,他可以使用如下的办法:

// 首先根据 ID 获取 Conversation 实例
var conversation = await tom.GetConversation("CONVERSATION_ID");
// 邀请 Mary 加入对话
await conversation.AddMembers(new string[] { "Mary" });

而 Jerry 端增加「新成员加入」的事件通知处理函数,就可以及时获知 Mary 被 Tom 邀请加入当前对话了:

jerry.OnMembersJoined = (conv, memberList, initBy) => {
WriteLine($"{initBy} 邀请了 {memberList} 加入了 {conv.Id} 对话");
}

其中 AVIMOnInvitedEventArgs 参数包含如下内容:

  1. InvitedBy:该操作的发起者
  2. JoinedMembers:此次加入对话的包含的成员列表
  3. ConversationId:被操作的对话

这一流程的时序图如下:

sequenceDiagram Tom->>Cloud: 1. 添加 Mary Cloud->>Tom: 2. 下发通知:Mary 被你邀请加入了对话 Cloud-->>Mary: 2. 下发通知:你被 Tom 邀请加入对话 Cloud-->>Jerry: 2. 下发通知:Mary 被 Tom 邀请加入了对话

而 Mary 端如果要能加入到 Tom 和 Jerry 的对话中来,Ta 可以参照 一对一单聊 中 Jerry 侧的做法监听 INVITED 事件,就可以自己被邀请到了一个对话当中。

重新创建一个对话,并在创建的时候指定全部成员 的方式如下:

var conversation = await tom.CreateConversation(new string[] { "Jerry","Mary" }, name: "Tom & Jerry & friends", unique: true);

群发消息

多人群聊中一个成员发送的消息,会实时同步到所有其他在线成员,其处理流程与单聊中 Jerry 接收消息的过程是一样的。

例如,Tom 向好友群发送了一条欢迎消息:

var textMessage = new LCIMTextMessage("大家好,欢迎来到我们的群聊对话!");
await conversation.Send(textMessage);

而 Jerry 和 Mary 端都会有 Event.MESSAGE 事件触发,利用它来接收群聊消息,并更新产品 UI。

将他人踢出对话

三个好友的群其乐融融不久,后来 Mary 出言不逊,惹恼了群主 Tom,Tom 直接把 Mary 踢出了对话群。Tom 端想要踢人,该怎么实现呢?

await conversation.RemoveMembers(new string[] { "Mary" });

Tom 端执行了这段代码之后会触发如下流程:

sequenceDiagram Tom->>Cloud: 1. 对话中移除 Mary Cloud-->>Mary: 2. 下发通知:你被 Tom 从对话中剔除了 Cloud-->>Jerry: 2. 下发通知:Mary 被 Tom 移除 Cloud-->>Tom: 2. 下发通知:Mary 被移除了对话

这里出现了两个新的事件:当前用户被踢出对话 KICKED(Mary 收到的),成员 XX 被踢出对话 MEMBERS_LEFT(Jerry 和 Tom 收到的)。其处理方式与邀请人的流程类似:

jerry.OnMembersLeft = (conv, leftIds, kickedBy) => {
WriteLine($"{leftIds} 离开对话 {conv.Id};操作者为:{kickedBy}");
}
jerry.OnKicked = (conv, initBy) => {
WriteLine($"你已经离开对话 {conv.Id};操作者为:{initBy}");
};

用户主动加入对话

把 Mary 踢走之后,Tom 嫌人少不好玩,所以他找到了 William,说他和 Jerry 有一个很好玩的聊天群,并且把群的 ID(或名称)告知给了 William。William 也很想进入这个群看看他们究竟在聊什么,他自己主动加入了对话:

var conv = await william.GetConversation("CONVERSATION_ID");
await conv.Join();

执行了这段代码之后会触发如下流程:

sequenceDiagram William->>Cloud: 1. 加入对话 Cloud-->>William: 2. 下发通知:你已加入对话 Cloud-->>Tom: 2. 下发通知:William 加入对话 Cloud-->>Jerry: 2. 下发通知:William 加入对话

其他人则通过订阅 MEMBERS_JOINED 来接收 William 加入对话的通知 :

jerry.OnMembersJoined = (conv, memberList, initBy) => {
WriteLine($"{memberList} 加入了 {conv.Id} 对话;操作者为:{initBy}");
}

用户主动退出对话

随着 Tom 邀请进来的人越来越多,Jerry 觉得跟这些人都说不到一块去,他不想继续呆在这个对话里面了,所以选择自己主动退出对话,这时候可以调用下面的方法完成退群的操作:

await conversation.Quit();

执行了这段代码 Jerry 就离开了这个聊天群,此后群里所有的事件 Jerry 都不会再知晓。各个成员接收到的事件通知流程如下:

sequenceDiagram Jerry->>Cloud: 1. 离开对话 Cloud-->>Jerry: 2. 下发通知:你已离开对话 Cloud-->>Mary: 2. 下发通知:Jerry 已离开对话 Cloud-->>Tom: 2. 下发通知:Jerry 已离开对话

而其他人需要通过订阅 MEMBERS_LEFT 来接收 Jerry 离开对话的事件通知:

mary.OnMembersLeft = (conv, members, initBy) => {
WriteLine($"{members} 离开了 {conv.Id} 对话;操作者为:{initBy}");
}

成员变更的事件通知总结

前面的时序图和代码针对成员变更的操作做了逐步的分析和阐述,为了确保开发者能够准确的使用事件通知,如下表格做了一个统一的归类和划分:

假设 Tom 和 Jerry 已经在对话内了:

操作TomJerryMaryWilliam
Tom 添加 MaryOnMembersJoinedOnMembersJoinedOnInvited/
Tom 剔除 MaryOnMembersLeftOnMembersLeftOnKicked/
William 加入OnMembersJoinedOnMembersJoined/OnMembersJoined
Jerry 主动退出OnMembersLeftOnMembersLeft/OnMembersLeft

文本之外的聊天消息

上面的示例都是发送文本消息,但是实际上可能图片、视频、位置等消息也是非常常见的消息格式,接下来我们就看看如何发送这些富媒体类型的消息。

即时通讯服务默认支持文本、文件、图像、音频、视频、位置、二进制等不同格式的消息,除了二进制消息之外,普通消息的收发接口都是字符串,但是文本消息和文件、图像、音视频消息有一点区别:

  • 文本消息发送的就是本身的内容
  • 而其他的多媒体消息,例如一张图片,实际上即时通讯 SDK 会首先调用存储服务的 AVFile 接口,将图像的二进制文件上传到存储服务云端,再把图像下载的 URL 放入即时通讯消息结构体中,所以 图像消息不过是包含了图像下载链接的固定格式文本消息

图像等二进制数据不随即时通讯消息直接下发的主要原因在于,文件存储服务默认都是开通了 CDN 加速选项的,通过文件下载对于终端用户来说可以有更快的展现速度,同时对于开发者来说也能获得更低的存储成本。

默认消息类型

即时通讯服务内置了多种结构化消息用来满足常见的需求:

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

所有消息均派生自 LCIMMessage,每种消息实例都具备如下属性:

属性类型描述
contentString消息内容。
clientIdString消息发送者的 clientId
conversationIdString消息所属对话 ID。
messageIdString消息发送成功之后,由云端给每条消息赋予的唯一 ID。
timestamplong消息发送的时间。消息发送成功之后,由云端赋予的全局的时间戳。
receiptTimestamplong消息被对方接收到的时间。消息被接收之后,由云端赋予的全局的时间戳。
statusAVIMMessageStatus 枚举消息状态,有五种取值:

AVIMMessageStatusNone(未知)
AVIMMessageStatusSending(发送中)
AVIMMessageStatusSent(发送成功)
AVIMMessageStatusReceipt(被接收)
AVIMMessageStatusFailed(失败)
ioTypeAVIMMessageIOType 枚举消息传输方向,有两种取值:

AVIMMessageIOTypeIn(发给当前用户)
AVIMMessageIOTypeOut(由当前用户发出)

我们为每一种富媒体消息定义了一个消息类型,即时通讯 SDK 自身使用的类型是负数(如下面列表所示),所有正数留给开发者自定义扩展类型使用,0 作为「没有类型」被保留起来。

消息类型
文本消息-1
图像消息-2
音频消息-3
视频消息-4
位置消息-5
文件消息-6

图像消息

发送图像文件

即时通讯 SDK 支持直接通过二进制数据,或者本地图像文件的路径,来构造一个图像消息并发送到云端。其流程如下:

sequenceDiagram Tom-->>Local: 1. 获取图像实体内容 Tom-->>Storage: 2. SDK 后台上传文件(LCFile)到云端 Storage-->>Tom: 3. 返回图像的云端地址 Tom-->>Cloud: 4. SDK 将图像消息发送给云端 Cloud->>Jerry: 5. 收到图像消息,在对话框里面做 UI 展现

图解:

  1. Local 可能是来自于 localStorage/camera,表示图像的来源可以是本地存储例如 iPhone 手机的媒体库或者直接调用相机 API 实时地拍照获取的照片。
  2. LCFile 是云服务提供的文件存储对象。

对应的代码并没有时序图那样复杂,因为调用 send 接口的时候,SDK 会自动上传图像,不需要开发者再去关心这一步:

var image = new LCFile("screenshot.png", new Uri("http://example.com/screenshot.png"));
var imageMessage = new LCIMImageMessage(image);
imageMessage.Text = "发自我的 Windows";
await conversation.Send(imageMessage);

发送图像链接

除了上述这种从本地直接发送图片文件的消息之外,在很多时候,用户可能从网络上或者别的应用中拷贝了一个图像的网络连接地址,当做一条图像消息发送到对话中,这种需求可以用如下代码来实现:

var image = new LCFile("girl.gif", new Uri("http://example.com/girl.gif"));
var imageMessage = new LCIMImageMessage(image);
imageMessage.Text = "发自我的 Windows";
await conversation.Send(imageMessage);

接收图像消息

图像消息的接收机制和之前是一样的,只需要修改一下接收消息的事件回调逻辑,根据消息类型来做不同的 UI 展现即可,例如:

client.OnMessage = (conv, msg) => {
if (e.Message is LCIMImageMessage imageMessage) {
WriteLine(imageMessage.Url);
}
}

发送音频消息/视频/文件

发送流程

对于图像、音频、视频和文件这四种类型的消息,SDK 均采取如下的发送流程:

如果文件是从 客户端 API 读取的数据流(Stream),步骤为:

  1. 从本地构造 LCFile
  2. 调用 LCFile 的上传方法将文件上传到云端,并获取文件元信息(metaData
  3. LCFileobjectId、URL、文件元信息都封装在消息体内
  4. 调用接口发送消息

如果文件是 外部链接的 URL,则:

  1. 直接将 URL 封装在消息体内,不获取元信息(例如,音频消息的时长),不包含 objectId
  2. 调用接口发送消息

以发送音频消息为例,基本流程是:读取音频文件(或者录制音频)> 构建音频消息 > 消息发送。

var audio = new LCFile("tante.mp3", Path.Combine(Application.persistentDataPath, "tante.mp3"));
var audioMessage = new LCIMAudioMessage(audio);
audioMessage.Text = "听听人类的神曲";
await conversation.Send(audioMessage);

与图像消息类似,音频消息也支持从 URL 构建:

var audio = new LCFile("apple.aac", new Uri("https://some.website.com/apple.aac"));
var audioMessage = new LCIMAudioMessage(audio);
audioMessage.Text = "来自苹果发布会现场的录音";
await conversation.Send(audioMessage);

发送地理位置消息

地理位置消息构建方式如下:

var location = new LCGeoPoint(31.3753285, 120.9664658);
var locationMessage = new LCIMLocationMessage(location);
await conversation.Send(locationMessage);

再谈接收消息

C# SDK 通过 OnMessage 事件回调来通知新消息:

jerry.OnMessage = (conv, msg) => {
if (msg is LCIMImageMessage imageMessage) {

} else if (msg is LCIMAudioMessage audioMessage) {

} else if (msg is LCIMVideoMessage videoMessage) {

} else if (msg is LCIMFileMessage fileMessage) {

} else if (msg is AVIMLocationMessage locationMessage) {

} else if (msg is InputtingMessage) {
WriteLine($"收到自定义消息 {inputtingMessage.TextContent} {inputtingMessage.Ecode}");
}
}

上面的代码示例中涉及到接收自定义消息。 我们将在即时通讯开发指南第二篇的《自定义消息类型》一节介绍。

扩展对话:支持自定义属性

「对话(Conversation)」是即时通讯的核心逻辑对象,它有一些内置的常用的属性,与控制台中 _Conversation 表是一一对应的。默认提供的 内置 属性的对应关系如下:

AVIMConversation 属性名_Conversation 字段含义
CurrentClientN/A对话所属的 AVIMClient 对象
ConversationIdobjectId全局唯一的 ID
Namename成员共享的统一的名字
MemberIdsm成员列表
MuteMemberIdsmu静音该对话的成员
Creatorc对话创建者
IsTransienttr是否为聊天室
IsSystemsys是否为系统对话
IsUniqueunique是否为相同成员的唯一对话
IsTemporaryN/A是否为临时对话(临时对话数据不保存到 _Conversation 表中 )
CreatedAtcreatedAt创建时间
UpdatedAtupdatedAt最后更新时间
LastMessageAtlm该对话最后一条消息,也可以理解为最后一次活跃时间

不过,我们不建议直接对 _Conversation 进行写操作,因为:

  • 客户端 SDK 查询会话数据是走 websocket 长连接,会首先从即时通讯服务器的内存缓存中查。直接操作 _Conversation 表,不会更新即时通讯服务器的缓存,这就带来了缓存不一致问题。
  • 直接操作 _Conversation 表的情况下,即时通讯服务器不会下发相应的事件通知客户端,客户端自然也就无从响应。
  • 如果定义了即时通讯服务的 hook 函数,直接操作 _Conversation 表不会触发这些 hook。

如有管理需求,我们推荐调用专门的即时通讯 REST API 接口。

另外,我们可以通过「自定义属性」来在「对话」中保存更多业务层数据。

创建自定义属性

在最开始介绍 创建单聊对话 的时候,我们提到过 IMClient#createConversation 接口支持附加自定义属性,现在我们就来演示一下如何使用自定义属性。

假如在创建对话的时候,我们需要添加两个额外的属性值对 { "type": "private", "pinned": true },那么在调用 IMClient#createConversation 方法时可以把附加属性传进去:

var properties = new Dictionary<string, object> {
{ "type", "private" },
{ "pinned", true }
};
var conversation = await tom.CreateConversation("Jerry", name: "Tom & Jerry", unique: true, properties: properties);

自定义属性在 SDK 级别是对所有成员可见的。我们也支持通过自定义属性来查询对话,请参见 使用复杂条件来查询对话

修改和使用属性

Conversation 对象中,系统默认提供的属性,例如对话的名字(name),如果业务层没有限制的话,所有成员都是可以修改的,示例代码如下:

await conversation.UpdateInfo(new Dictionary<string, object> {
{ "name", "聪明的喵星人" }
});

Conversation 对象中自定义的属性,即时通讯服务也是允许对话内其他成员来读取、使用和修改的,示例代码如下:

// 获取自定义属性
var type = conversation["type"];
// 为 pinned 属性设置新的值
await conversation.UpdateInfo(new Dictionary<string, object> {
{ "pinned", false }
});

对自定义属性名的说明

IMClient#createConversation 接口中指定的自定义属性,会被存入 _Conversation 表的 attr 字段,所以在之后对这些属性进行读取或修改的时候,属性名需要指定完整的路径,例如上面的 attr.type,这一点需要特别注意。

对话属性同步

对话的名字以及应用层附加的其他属性,一般都是需要全员共享的,一旦有人对这些数据进行了修改,那么就需要及时通知到全部成员。在前一个例子中,有一个用户对话名称改为了「聪明的喵星人」,那其他成员怎么能知道这件事情呢?

即时通讯云端提供了实时同步的通知机制,会把单个用户对「对话」的修改同步下发到所有在线成员(对于非在线的成员,他们下次登录上线之后,自然会拉取到最新的完整的对话数据)。对话属性更新的通知事件声明如下:

jerry.OnConversationInfoUpdated = (conv, attrs, initBy) => {
WriteLine($"对话:${conv.Id} 被更新");
};

使用提示:

应用层在该事件的响应函数中,可以获知当前什么属性被修改了,也可以直接从 SDK 的 Conversation 实例中获取最新的合并之后的属性值,然后依据需要来更新产品 UI。

获取群内成员列表

群内成员列表是作为对话的属性持久化保存在云端的,所以要获取一个 Conversation 对象的成员列表,我们可以在调用这个对象的更新方法之后,直接获取成员属性即可。

await conversation.Fetch();

使用提示:

成员列表是对 普通对话 而言的,对于像「聊天室」「系统对话」这样的特殊对话,并不存在「成员列表」属性。

使用复杂条件来查询对话

除了在事件通知接口中获得 Conversation 实例之外,开发者也可以根据不同的属性和条件来查询 Conversation 对象。例如有些产品允许终端用户根据名字或地理位置来匹配感兴趣聊天室,也有些业务场景允许查询成员列表中包含特定用户的所有对话,这些都可以通过对话查询的接口实现。

根据 ID 查询

ID 对应就是 _Conversation 表中的 objectId 的字段值,这是一种最简单也最高效的查询(因为云端会对 ID 建立索引):

var query = tom.GetQuery();
var conversation = await query.Get("551260efe4b01608686c3e0f");

基础的条件查询

即时通讯 SDK 提供了丰富的条件查询方式,可以满足各种复杂的业务需求。

我们首先从最简单的 equalTo 开始。例如查询所有自定义属性 type(字符串类型)为 private 的对话,需要如下代码:

var query = tom.GetQuery()
.WhereEqualTo("type", "private");
await query.Find();

熟悉数据存储服务的开发者可以更容易理解对话的查询构建,因为对话查询和数据存储服务的对象查询在接口上是十分接近的:

  • 可以通过 find 获取当前结果页数据
  • 支持通过 count 获取结果数
  • 支持通过 first 获取第一个结果
  • 支持通过 skiplimit 对结果进行分页

equalTo 类似,针对 NumberDate 类型的属性还可以使用大于、大于等于、小于、小于等于等,详见下表:

逻辑比较AVIMConversationQuery 方法
等于WhereEqualTo
不等于WhereNotEqualsTo
大于WhereGreaterThan
大于等于WhereGreaterThanOrEqualsTo
小于WhereLessThan
小于等于WhereLessThanOrEqualsTo

使用注意:默认查询条件

为了防止用户无意间拉取到所有的对话数据,在客户端不指定任何 where 条件的时候,ConversationQuery 会默认查询包含当前用户的对话。如果客户端添加了任一 where 条件,那么 ConversationQuery 会忽略默认条件而严格按照指定的条件来查询。如果客户端要查询包含某一个 clientId 的对话,那么使用下面的 数组查询 语法对 m 属性列和 clientId 值进行查询即可,不会和默认查询条件冲突。

正则匹配查询

ConversationsQuery 也支持在查询条件中使用正则表达式来匹配数据。比如要查询所有 language 是中文的对话:

query.WhereMatches("language", "[\\u4e00-\\u9fa5]"); // language 是中文字符

字符串查询

前缀查询 类似于 SQL 的 LIKE 'keyword%' 条件。例如查询名字以「教育」开头的对话:

query.WhereStartsWith("name", "教育");

包含查询 类似于 SQL 的 LIKE '%keyword%' 条件。例如查询名字中包含「教育」的对话:

query.WhereContains("name", "教育");

不包含查询 则可以使用 正则匹配查询 来实现。例如查询名字中不包含「教育」的对话:

query.WhereMatches("name", "^((?!教育).)* $ ");

数组查询

可以使用 containsAllcontainedInnotContainedIn 来对数组进行查询。例如查询成员中包含「Tom」的对话:

var members = new List<string> { "Tom" };
query.WhereContainedIn("m", members);

空值查询

空值查询是指查询相关列是否为空值的方法,例如要查询 lm 列为空值的对话:

query.WhereDoesNotExist("lm");

反过来,如果要查询 lm 列不为空的对话,则替换为如下条件即可:

query.WhereExists("lm");

注意,系统消息(服务号)没有 lm 列,可以替换为 updatedAt。

组合查询

查询年龄小于 18 岁,并且关键字包含「教育」的对话:

query.WhereContains("keywords", "教育")
.WhereLessThan("age", 18);

另外一种组合的方式是,两个查询采用 or 或者 and 的方式构建一个新的查询。

查询年龄小于 18 或者关键字包含「教育」的对话:

// 暂不支持

结果排序

可以指定查询结果按照部分属性值的升序或降序来返回。例如:

query.OrderByDescending("createdAt");

不带成员信息的精简模式

普通对话最多可以容纳 500 个成员,在有些业务逻辑不需要对话的成员列表的情况下,可以使用「精简模式」进行查询,这样返回结果中不会包含成员列表(members 字段为空数组),有助于提升应用的性能同时减少流量消耗。

query.Compact = true;

让查询结果附带一条最新消息

对于一个聊天应用,一个典型的需求是在对话的列表界面显示最后一条消息,默认情况下,针对对话的查询结果是不带最后一条消息的,需要单独打开相关选项:

query.WithLastMessageRefreshed = true;

需要注意的是,这个选项真正的意义是「刷新对话的最后一条消息」,这意味着由于 SDK 缓存机制的存在,将这个选项设置为 false 查询得到的对话也还是有可能会存在最后一条消息的。

查询缓存

.NET SDK 暂不支持缓存功能。

性能优化建议

Conversation 数据是存储在云端数据库中的,与存储服务中的对象查询类似,我们需要尽可能利用索引来提升查询效率,这里有一些优化查询的建议:

  • ConversationobjectIdupdatedAtcreatedAt 等属性上是默认建了索引的,所以通过这些条件来查询会比较快。
  • 虽然 skip 搭配 limit 的方式可以翻页,但是在结果集较大的时候不建议使用,因为数据库端计算翻页距离是一个非常低效的操作,取而代之的是尽量通过 updatedAtlastMessageAt 等属性来限定返回结果集大小,并以此进行翻页。
  • 使用 m 列的 contains 查询来查找包含某人的对话时,也尽量使用默认的 limit 大小 10,再配合 updatedAt 或者 lastMessageAt 来做条件约束,性能会提升较大。
  • 整个应用对话如果数量太多,可以考虑在云引擎封装一个云函数,用定时任务启动之后,周期性地做一些清理,例如可以归档或删除一些不活跃的对话。

聊天记录查询

消息记录默认会在云端保存 180 天,开发者可以通过额外付费来延长这一期限(有需要的用户请提工单联系技术支持),也可以通过 REST API 将聊天记录同步到自己的服务器上。

SDK 提供了多种方式来拉取历史记录,iOS 和 Android SDK 还提供了内置的消息缓存机制,以减少客户端对云端消息记录的查询次数,并且在设备离线情况下,也能展示出部分数据保障产品体验不会中断。

从新到旧获取对话的消息记录

在终端用户进入一个对话的时候,最常见的需求就是由新到旧、以翻页的方式拉取并展示历史消息,这可以通过如下代码实现:

// limit 取值范围 1~100,默认 20
var messages = await conversation.QueryMessages(limit: 10);
foreach (var message in messages) {
if (message is LCIMTextMessage textMessage) {

}
}

queryMessage 接口也是支持翻页的。即时通讯云端通过消息的 messageId 和发送时间戳来唯一定位一条消息,因此要从某条消息起拉取后续的 N 条记录,只需要指定起始消息的 messageId 和发送时间戳作为锚定就可以了,示例代码如下:

// limit 取值范围 1~1000,默认 100
var messages = await conversation.QueryMessages(limit: 10);
var oldestMessage = messages[0];
var start = new LCIMMessageQueryEndpoint {
MessageId = oldestMessage.Id,
SentTimestamp = oldestMessage.SentTimestamp
};
var messagesInPage = await conversation.QueryMessages(start: start);

按照消息类型获取

除了按照时间先后顺序拉取历史消息之外,即时通讯服务云端也支持按照消息的类型来拉取历史消息,这一功能可能对某些产品来说非常有用,例如我们需要展现某一个聊天群组里面所有的图像。

queryMessage 接口还支持指定特殊的消息类型,其示例代码如下:

// 传入泛型参数,SDK 会自动读取类型的信息发送给服务端,用作筛选目标类型的消息
var imageMessages = await conversation.QueryMessages(messageType: -2);

如要获取更多图像消息,可以效仿前一章节中的示例代码,继续翻页查询即可。

从旧到新反向获取历史消息

即时通讯云端支持的历史消息查询方式是非常多的,除了上面列举的两个最常见需求之外,还可以支持按照由旧到新的方向进行查询。如下代码演示从对话创建的时间点开始,从前往后查询消息记录:

var earliestMessages = await conversation.QueryMessages(direction: LCIMMessageQueryDirection.OldToNew);

这种情况下要实现翻页,接口会稍微复杂一点,请继续阅读下一节。

从某一时间戳往某一方向查询

即时通讯服务云端支持以某一条消息的 ID 和时间戳为准,往一个方向查:

  • 从新到旧:以某一条消息为基准,查询它 之前 产生的消息
  • 从旧到新:以某一条消息为基准,查询它 之后 产生的消息

这样我们就可以在不同方向上实现消息翻页了。

var earliestMessages = await conversation.QueryMessages(direction: LCIMMessageQueryDirection.OldToNew, limit: 1);
// 获取 earliestMessages.Last() 之后的消息
var lastMessage = earliestMessages.Last();
var start = new LCIMMessageQueryEndpoint {
MessageId = lastMessage.Id,
SentTimestamp = lastMessage.SentTimestamp
};
var nextPageMessages = await conversation.QueryMessages(start: start);

获取指定区间内的消息

除了顺序查找之外,我们也支持获取特定时间区间内的消息。假设已知 2 条消息,这 2 条消息以较早的一条为起始点,而较晚的一条为终点,这个区间内产生的消息可以用如下方式查询:

注意:每次查询也有 100 条限制,如果想要查询区间内所有产生的消息,替换区间起始点的参数即可。

var earliestMessage = await conversation.QueryMessages(direction: LCIMMessageQueryDirection.OldToNew, limit: 1);
var latestMessage = await conversation.QueryMessages(limit: 1);
var start = new LCIMMessageQueryEndpoint {
MessageId = earliestMessage[0].Id
};
var end = new LCIMMessageQueryEndpoint {
MessageId = latestMessage[0].Id
};
// messagesInInterval 最多可包含 100 条消息
var messagesInInterval = await conversation.QueryMessages(start: start, end: end);

客户端消息缓存

iOS 和 Android SDK 针对移动设备的特殊性,实现了客户端消息的缓存。开发者无需进行特殊设置,只要接收或者查询到的新消息,默认都会进入被缓存起来,该机制给开发者提供了如下便利:

  1. 客户端可以在未联网的情况下进入对话列表之后,可以获取聊天记录,提升用户体验
  2. 减少查询的次数和流量的消耗
  3. 极大地提升了消息记录的查询速度和性能

客户端缓存是默认开启的,如果开发者有特殊的需求,SDK 也支持关闭缓存功能。例如有些产品在应用层进行了统一的消息缓存,无需 SDK 层再进行冗余存储,可以通过如下接口来关闭消息缓存:

// 暂不支持

用户退出与网络状态变化

用户退出即时通讯服务

如果产品层面设计了用户退出登录或者切换账号的接口,对于即时通讯服务来说,也是需要完全注销当前用户的登录状态的。在 SDK 中,开发者可以通过调用 LCIMClientclose 系列方法完成即时通讯服务的「退出」:

await tom.Close();

调用该接口之后,客户端就与即时通讯服务云端断开连接了,从云端查询前一 clientId 的状态,会显示「离线」状态。

客户端事件与网络状态响应

即时通讯服务与终端设备的网络连接状态休戚相关,如果网络中断,那么所有的消息收发和对话操作都会失败,这时候产品层面需要在 UI 上给予用户足够的提示,以免影响使用体验。

我们的 SDK 内部和即时通讯云端会维持一个「心跳」机制,能够及时感知到客户端的网络变化,同时将底层网络变化事件通知到应用层。具体来讲,当网络连接出现中断、恢复等状态变化时,SDK 会派发以下事件:

LCIMClient 上会有如下事件通知:

  • OnPaused 指网络连接断开事件发生,此时聊天服务不可用
  • OnResume 指网络连接恢复正常,此时聊天服务变得可用
  • OnClose 指连接关闭,且不会自动重连

其他开发建议

如何根据活跃度来展示对话列表

不管是当前用户参与的「对话」列表,还是全局热门的开放聊天室列表展示出来了,我们下一步要考虑的就是如何把最活跃的对话展示在前面,这里我们把「活跃」定义为最近有新消息发出来。我们希望有最新消息的对话可以展示在对话列表的最前面,甚至可以把最新的那条消息也附带显示出来,这时候该怎么实现呢?

我们专门为 LCIMConversation 增加了一个动态的属性 lastMessageAt(对应 _Conversation 表里的 lm 字段),记录了对话中最后一条消息到达即时通讯云端的时间戳,这一数字是服务器端的时间(精确到秒),所以不用担心客户端时间对结果造成影响。另外,LCIMConversation 还提供了一个方法可以直接获取最新的一条消息。这样在界面展现的时候,开发者就可以自己决定展示内容与顺序了。

自动重连

如果开发者没有明确调用退出登录的接口,但是客户端网络存在抖动或者切换(对于移动网络来说,这是比较常见的情况),我们 iOS 和 Android SDK 默认内置了断线重连的功能,会在网络恢复的时候自动建立连接,此时 IMClient 的网络状态可以通过底层的网络状态响应接口得到回调。

更多「对话」类型

即时通讯服务提供的功能就是让一个客户端与其他客户端进行在线的消息互发,对应不同的使用场景,除了前两章节介绍的 一对一单聊多人群聊 之外,我们也支持其他形式的「对话」模型:

  • 开放聊天室,例如直播中的弹幕聊天室,它与普通的「多人群聊」的主要差别是允许的成员人数以及消息到达的保证程度不一样。有兴趣的开发者可以参考即时通讯开发指南第三篇的《玩转直播聊天室》一节。

  • 临时对话,例如客服系统中用户和客服人员之间建立的临时通道,它与普通的「一对一单聊」的主要差别在于对话总是临时创建并且不会长期存在,在提升实现便利性的同时,还能降低服务使用成本(能有效减少存储空间方面的花费)。有兴趣的开发者可以参考即时通讯开发指南第三篇的《使用临时对话》一节。

  • 系统对话,例如在微信里面常见的公众号/服务号,系统全局的广播账号,与普通「多人群聊」的主要差别,在于「服务号」是以订阅的形式加入的,也没有成员限制,并且订阅用户和服务号的消息交互是一对一的,一个用户的上行消息不会群发给其他订阅用户。有兴趣的开发者可以参考即时通讯开发指南第四篇《「系统对话」的使用》一节。

进一步阅读