跳到主要内容

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

阅读准备

在阅读本章之前,如果你还不太了解即时通讯服务的总体架构,建议先阅读即时通讯服务总览。 另外,如果你还没有下载对应开发环境(语言)的 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}");
}
}