二,消息收发的更多方式,离线推送与消息同步,多设备登录
本章导读
在前一章从简单的单聊、群聊、收发图文消息开始里面,我们说明了如何在产品中增加一个基本的单聊/群聊页面,并响应服务端实时事件通知。接下来,在本篇文档中我们会讲解如何实现一些更复杂的业务需求,例如:
- 支持消息被接收和被阅读的状态回执,实现「Ding」一下的效果
- 发送带有成员提醒的消息(@ 某人),在超多用户群聊的场合提升目标用户的响应积极性
- 支持消息的撤回和修改
- 解决成员离线状态下的推送通知与重新上线后的消息同步,确保不丢消息
- 支持多设备登录,或者强制用户单点登录
- 扩展新的消息类型
消息收发的更多方式
在一个偏重工作协作或社交沟通的产品里,除了简单的消息收发之外,我们还会碰到更多需求,例如:
- 在消息中能否直接提醒某人,类似于很多 IM 工具中提供的 @ 消息,这样接收方能更明确地知道哪些消息需要及时响应;
- 消息发出去之后才发现内容不对,这时候能否修改或者撤回?
- 除了普通的聊天内容之外,是否支持发送类似于「XX 正在输入」这样的状态消息?
- 消息是否被其他人接收、读取,这样的状态能否反馈给发送者?
- 客户端掉线一段时间之后,可能会错过一批消息,能否提醒并同步一下未读消息?
等等,所有这些需求都可以通过即时通讯服务解决,下面我们来逐一看看具体的做法。
@ 成员提醒消息
在一些多人聊天群里面,因为消息量较大,很容易就导致某一条重要的消息没有被目标用户看到就被刷下去了,所以在消息中「@成员」是一种提醒接收者注意的有效办法。在微信这样的聊天工具里面,甚至会在对话列表页对有提醒的消息进行特别展示,用来通知消息目标高优先级查看和处理。
一般提醒消息都使用「@ + 人名」来表示目标用户,但是这里「人名」是一个由应用层决定的属性,可能有的产品使用全名,有的使用昵称,并且这个名字和即时通讯服务里面标识一个用户使用的 clientId
可能根本不一样(毕竟一个是给人看的,一个是给机器读的)。使用「人名」来圈定用户,也存在一种例外,就是聊天群组里面的用户名是可以改变的,如果消息发送的时候「王五」还叫「王五」,等发送出来之后他恰好同步改成了「王大麻子」,这时候接收方的处理就比较麻烦了。还有第三个原因,就是「提醒全部成员」的表示方式,可能「@all」、「@group」、「@所有人」都会被选择,这是一个完全依赖应用层 UI 的选项。
所以「@ 成员」提醒消息并不能简单在文本消息中加入「@ + 人名」,解决方案是给普通消息(LCIMMessage
)增加两个额外的属性:
mentionList
,是一个字符串的数组,用来单独记录被提醒的clientId
列表;mentionAll
,是一个Bool
型的标志位,用来表示是否要提醒全部成员。
带有提醒信息的消息,有可能既有提醒全部成员的标志,也还单独设置了 mentionList
,这由应用层去控制。发送方在发送「@ 成员」提醒消息的时候,如何输入、选择成员名称,这是业务方 UI 层面需要解决的问题,即时通讯 SDK 不关心其实现逻辑,SDK 只要求开发者在发送一条「@ 成员」消息的时候,调用 mentionList
和 mentionAll
的 setter 方法,设置正确的成员列表即可。示例代码如下:
- Unity
- Android
- iOS
LCIMTextMessage textMessage = new LCIMTextMessage("@Tom 早点回家") {
MentionIdList = new string[] { "Tom" }
};
await conversation.Send(textMessage);
String content = "@Tom 早点回家";
LCIMTextMessage message = new LCIMTextMessage();
message.setText(content);
List<String> list = new ArrayList<>(); // 部分用户的 mention list,你可以像下面代码这样来填充
list.add("Tom");
message.setMentionList(list);
imConversation.sendMessage(message, new LCIMConversationCallback() {
@Override
public void done(LCIMException e) {
}
});
LCIMMessage *message = [LCIMTextMessage messageWithText:@"@Tom 早点回家" attributes:nil];
message.mentionList = @[@"Tom"];
[conversation sendMessage:message callback:^(BOOL succeeded, NSError * _Nullable error) {
/* 一条提及 Tom 的消息已发出 */
}];
或者也可以通过设置 mentionAll
属性值提醒所有人:
- Unity
- Android
- iOS
LCIMTextMessage textMessage = new LCIMTextMessage("@all") {
MentionAll = true
};
await conv.Send(textMessage);
String content = "@all";
LCIMTextMessage message = new LCIMTextMessage();
message.setText(content);
boolean mentionAll = true; // 指示是否提及了所有人
message.mentionAll(mentionAll);
imConversation.sendMessage(message, new LCIMConversationCallback() {
@Override
public void done(LCIMException e) {
}
});
LCIMMessage *message = [LCIMTextMessage messageWithText:@"@all" attributes:nil];
message.mentionAll = YES;
[conversation sendMessage:message callback:^(BOOL succeeded, NSError * _Nullable error) {
/* 一条提及所有用户的消息已发出 */
}];
对于消息的接收方来说,可以通过调用 mentionList
和 mentionAll
的 getter 方法来获得提醒目标用户的信息,示例代码如下:
- Unity
- Android
- iOS
jerry.onMessage = (conv, msg) => {
List<string> mentionIds = msg.MentionIdList;
};
@Override
public void onMessage(LCIMAudioMessage msg, LCIMConversation conv, LCIMClient client) {
// 读取消息 @ 的 clientId 列表
List<String> currentMsgMentionUserList = message.getMentionList();
}
// 示例代码演示 LCIMTypedMessage 接收时,获取该条消息提醒的 clientId 列表,同理可以用类似的代码操作 LCIMMessage 的其他子类
- (void)conversation:(LCIMConversation *)conversation didReceiveTypedMessage:(LCIMTypedMessage *)message {
// 读取消息 @ 的 clientId 列表
NSArray *mentionList = message.mentionList;
}
此外,并且为了方便应用层 UI 展现,我们特意为 LCIMMessage
增加了两个标识位,用来显示被提醒的状态:
- 一个是
mentionedAll
标识位,用来表示该消息是否提醒了当前对话的全体成员。只有mentionAll
属性为true
,这个标识位才为true
,否则就为false
。 - 另一个是
mentioned
标识位,用来快速判断该消息是否提醒了当前登录用户。如果mentionList
属性列表中包含有当前登录用户的clientId
,或者mentionAll
属性为true
,那么mentioned
方法都会返回true
,否则返回false
。
调用示例如下:
- Unity
- Android
- iOS
client.OnMessage = (conv, msg) => {
bool mentioned = msg.MentionAll || msg.MentionList.Contains("Tom");
};
@Override
public void onMessage(LCIMAudioMessage msg, LCIMConversation conv, LCIMClient client) {
// 读取消息是否 @ 了对话的所有成员
boolean currentMsgMentionAllUsers = message.isMentionAll();
// 读取消息是否 @ 了当前用户
boolean currentMsgMentionedMe = message.mentioned();
}
// 示例代码演示 LCIMTypedMessage 接收时,获取该条消息是否 @ 了当前对话里的所有成员或当前用户,同理可以用类似的代码操作 LCIMMessage 的其他子类
- (void)conversation:(LCIMConversation *)conversation didReceiveTypedMessage:(LCIMTypedMessage *)message {
// 读取消息是否 @ 了对话的所有成员
BOOL mentionAll = message.mentionAll;
// 读取消息是否 @ 了当前用户
BOOL mentionedMe = message.mentioned;
}
修改消息
在 云服务控制台 > 即时通讯 > 设置 > 即时通讯选项 启用 「允许通过 SDK 编辑消息」后,终端用户可以对自己已经发送的消息进行修改(Conversation#updateMessage
方法)。目前即时通讯服务端并没有在时效性上进行限制,不过只允许用户修改自己发出去的消息,不允许修改别人的消息。
修改已经发送的消息,并不是直接在老的消息对象上修改,而是像发新消息一样创建一个消息实例,然后调用 Conversation#updateMessage(oldMessage, newMessage)
方法来向云端提交请求,示例代码如下:
- Unity
- Android
- iOS
LCIMTextMessage newMessage = new LCIMTextMessage("修改后的消息内容");
await conversation.UpdateMessage(oldMessage, newMessage);
LCIMTextMessage textMessage = new LCIMTextMessage();
textMessage.setContent("修改后的消息");
imConversation.updateMessage(oldMessage, textMessage, new LCIMMessageUpdatedCallback() {
@Override
public void done(LCIMMessage avimMessage, LCException e) {
if (null == e) {
// 消息修改成功,avimMessage 即为被修改后的最新的消息
}
}
});
LCIMMessage *oldMessage = <#MessageYouWantToUpdate#>;
LCIMMessage *newMessage = [LCIMTextMessage messageWithText:@"Just a new message" attributes:nil];
[conversation updateMessage:oldMessage
toNewMessage:newMessage
callback:^(BOOL succeeded, NSError * _Nullable error) {
if (succeeded) {
NSLog(@"消息已被修改。");
}
}];
消息修改成功之后,对话内的其他成员会立刻接收到 MESSAGE_UPDATE
事件:
- Unity
- Android
- iOS
tom.OnMessageUpdated = (conv, msg) => {
if (msg is LCIMTextMessage textMessage) {
WriteLine($"内容 {textMessage.Text}, 消息 ID {textMessage.Id}");
}
};
void onMessageUpdated(LCIMClient client, LCIMConversation conversation, LCIMMessage message) {
// message 即为被修改的消息
}
/* 实现 delegate 方法,以处理消息修改的事件 */
- (void)conversation:(LCIMConversation *)conversation messageHasBeenUpdated:(LCIMMessage *)message reason:(LCIMMessagePatchedReason * _Nullable)reason {
/* 有消息被修改 */
}
对于 Android 和 iOS SDK 来说,如果开启了消息缓存的选项的话(默认开启),SDK 内部会先从缓存中修改这条消息记录,然后再通知应用层。所以对于开发者来说,收到这条通知之后刷新一下目标聊天页面,让消息列表更新即可(这时候消息列表会出现内容变化)。
如果系统修改了消息(例如触发了内置的敏感词过滤功能,或者云引擎的 hook 函数),发送者会收到 MESSAGE_UPDATE
事件,其他对话成员接收到的是修改过的消息。
撤回消息
除了修改消息,终端用户还可以撤回一条自己之前发送过的消息。 和修改消息类似,这一功能需要在控制台启用(云服务控制台 > 即时通讯 > 设置 > 即时通讯选项 启用「允许通过 SDK 撤回消息」)。 同样,即时通讯服务端并没有在时效性上进行限制,不过只允许用户撤回自己发出去的消息,不允许撤回别人的消息。
撤回消息调用 Conversation#recallMessage
方法,示例代码如下:
- Unity
- Android
- iOS
await conversation.RecallMessage(message);
conversation.recallMessage(message, new LCIMMessageRecalledCallback() {
@Override
public void done(LCIMRecalledMessage recalledMessage, LCException e) {
if (null == e) {
// 消息撤回成功,可以更新 UI
}
}
});
LCIMMessage *oldMessage = <#MessageYouWantToRecall#>;
[conversation recallMessage:oldMessage callback:^(BOOL succeeded, NSError * _Nullable error, LCIMRecalledMessage * _Nullable recalledMessage) {
if (succeeded) {
NSLog(@"消息已被撤回。");
}
}];
成功撤回消息后,对话内的其他成员会接收到 MESSAGE_RECALL
的事件:
- Unity
- Android
- iOS
tom.OnMessageRecalled = (conv, recalledMsg) => {
// recalledMsg 即为被撤回的消息
};
void onMessageRecalled(LCIMClient client, LCIMConversation conversation, LCIMMessage message) {
// message 即为被撤回的消息
}
/* 实现 delegate 方法,以处理消息撤回的事件 */
- (void)conversation:(LCIMConversation *)conversation messageHasBeenRecalled:(LCIMRecalledMessage *)message reason:(LCIMMessagePatchedReason * _Nullable)reason {
/* 有消息被撤回 */
}
对于 Android 和 iOS SDK 来说,如果开启了消息缓存的选项的话(默认开启),SDK 内部需要保证数据的一致性,所以会先从缓存中删除这条消息记录,然后再通知应用层。对于开发者来说,收到这条通知之后刷新一下目标聊天页面,让消息列表更新即可(此时消息列表中的消息会直接变少,或者显示撤回提示)。
暂态消息
有时候我们需要发送一些特殊的消息,譬如聊天过程中「某某正在输入…」这样的实时状态信息,或者当群聊的名称修改以后给该群成员发送「群名称被某某修改为 XX」这样的通知信息。这类消息与终端用户发送的消息不一样,发送者不要求把它保存到历史记录里,也不要求一定会被送达(如果成员不在线或者现在网络异常,那么没有下发下去也无所谓),这种需求可以使用「暂态消息」来实现。
「暂态消息」是一种特殊的消息,与普通消息相比有以下几点不同:
- 它不会被自动保存到云端,以后在历史消息中无法找到它
- 只发送给当时在线的成员,不支持延迟接收,离线用户更不会收到推送通知
- 对当时在线成员也不保证百分百送达,如果因为当时网络原因导致下发失败,服务端不会重试
我们可以用「暂态消息」发送一些实时的、频繁变化的状态信息,或者用来实现简单的控制协议。
暂态消息的数据和构造方式与普通消息是一样的,只是其发送方式与普通消息有一些区别。到目前为止,我们演示的 LCIMConversation
发送消息接口都是这样的:
- Unity
- Android
- iOS
public async Task<LCIMMessage> Send(LCIMMessage message, LCIMMessageSendOptions options = null);
/**
* 发送一条消息
*/
public void sendMessage(LCIMMessage message, final LCIMConversationCallback callback)
/*!
往对话中发送消息。
*/
- (void)sendMessage:(LCIMMessage *)message
callback:(void (^)(BOOL succeeded, NSError * _Nullable error))callback;
其实即时通讯 SDK 还允许在发送一条消息的时候,指定额外的参数 LCIMMessageOption
,LCIMConversation
完整的消息发送接口如下:
- Unity
- Android
- iOS
/// <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);
/**
* 发送消息
* @param message
* @param messageOption
* @param callback
*/
public void sendMessage(final LCIMMessage message, final LCIMMessageOption messageOption, final LCIMConversationCallback callback);
/*!
往对话中发送消息。
@param message - 消息对象
@param option - 消息发送选项
@param callback - 结果回调
*/
- (void)sendMessage:(LCIMMessage *)message
option:(nullable LCIMMessageOption *)option
callback:(void (^)(BOOL succeeded, NSError * _Nullable error))callback;
通过 LCIMMessageOption
参数我们可以指定:
- 是否作为暂态消息发送(设置
transient
属性); - 服务端是否需要通知该消息的接收状态(设置
receipt
属性,消息回执,后续章节会进行说明); - 消息的优先级(设置
priority
属性,后续章节会说明); - 是否为「遗愿消息」(设置
will
属性,后续章节会说明); - 消息对应的离线推送内容(设置
pushData
属性,后续章节会说明),如果消息接收方不在线,会推送指定的内容。
如果我们需要让 Tom 在聊天页面的输入框获得焦点的时候,给群内成员同步一条「Tom 正在输入…」的状态信息,可以使用如下代码:
- Unity
- Android
- iOS
LCIMTextMessage textMessage = new LCIMTextMessage("Tom 正在输入…");
LCIMMessageSendOptions option = new LCIMMessageSendOptions() {
Transient = true
};
await conversation.Send(textMessage, option);
String content = "Tom 正在输入…";
LCIMTextMessage message = new LCIMTextMessage();
message.setText(content);
LCIMMessageOption option = new LCIMMessageOption();
option.setTransient(true);
imConversation.sendMessage(message, option, new LCIMConversationCallback() {
@Override
public void done(LCIMException e) {
}
});
LCIMMessage *message = [LCIMTextMessage messageWithText:@"Tom 正在输入…" attributes:nil];
LCIMMessageOption *option = [[LCIMMessageOption alloc] init];
option.transient = true;
[conversation sendMessage:message option:option callback:^(BOOL succeeded, NSError * _Nullable error) {
/* 一条暂态消息已发出 */
}];
暂态消息的接收逻辑和普通消息一样,开发者可以按照消息类型进行判断和处理,这里不再赘述。上面使用了内建的文本消息只是一种示例,从展现端来说,我们如果使用特定的类型来表示「暂态消息」,是一种更好的方案。即时通讯 SDK 并没有提供固定的「暂态消息」类型,可以由开发者根据自己的业务需要来实现专门的自定义,具体可以参考后述章节:扩展自己的消息类型。
消息回执
即时通讯服务端在进行消息投递的时候,会按照消息上行的时间先后顺序下发(先收到的消息先下发,保证顺序性),且内部协议上会要求 SDK 对收到的每一条消息进行确认(ack)。如果 SDK 收到了消息,但是在发送 ack 的过程中出现网络丢包,即时通讯服务端还是会认为消息没有投递下去,之后会再次投递,直到收到 SDK 的应答确认为止。与之对应,SDK 内部也进行了消息去重处理,保证在上面这种异常条件下应用层也不会收到重复的消息。所以我们的消息系统从协议上是可以保证不丢任何一条消息的。
不过,有些业务场景会对消息投递的细节有更高的要求,例如消息的发送方要能知道什么时候接收方收到了这条消息,什么时候 ta 又点开阅读了这条消息。有一些偏重工作写作或者私密沟通的产品,消息发送者在发送一条消息之后,还希望能看到消息被送达和阅读的实时状态,甚至还要提醒未读成员。这样「苛刻」的需求,就依赖于我们的「消息回执」功能来实现。
与上一节「暂态消息」的发送类似,要使用消息回执功能,需要在发送消息时在 LCIMMessageOption
参数中标记「需要回执」选项:
- Unity
- Android
- iOS
LCIMTextMessage textMessage = new LCIMTextMessage("一条非常重要的消息。");
LCIMMessageSendOptions option = new LCIMMessageSendOptions {
Receipt = true
};
await conversation.Send(textMessage, option);
LCIMMessageOption messageOption = new LCIMMessageOption();
messageOption.setReceipt(true);
imConversation.sendMessage(message, messageOption, new LCIMConversationCallback() {
@Override
public void done(LCIMException e) {
}
});
LCIMMessageOption *option = [[LCIMMessageOption alloc] init];
option.receipt = true;
[conversation sendMessage:message option:option callback:^(BOOL succeeded, NSError *error) {
if (succeeded) {
NSLog(@"发送成功!需要回执。");
}
}];
注意:
只有在发送时设置了「需要回执」的标记,云端才会发送回执,默认不发送回执,且目前消息回执只支持单聊对话(成员不超过 2 人)。
那么发送方后续该如何响应回执的通知消息呢?
送达回执
当接收方收到消息之后,云端会向发送方发出一个回执通知,表明消息已经送达。请注意与「已读回执」区别开。
- Unity
- Android
- iOS
// Tom 用自己的名字作为 clientId 建立了一个 LCIMClient
LCIMClient client = new LCIMClient("Tom");
// Tom 登录到系统
await client.Open();
// 设置送达回执
client.OnMessageDelivered = (conv, msgId) => {
// 在这里可以书写消息送达之后的业务逻辑代码
};
// 发送消息
LCIMTextMessage textMessage = new LCIMTextMessage("夜访蛋糕店,约吗?");
await conversation.Send(textMessage);
public class CustomConversationEventHandler extends LCIMConversationEventHandler {
/**
* 实现本地方法来处理对方已经接收消息的通知
*/
public void onLastDeliveredAtUpdated(LCIMClient client, LCIMConversation conversation) {
;
}
}
// 设置全局的对话事件处理 handler
LCIMMessageManager.setConversationEventHandler(new CustomConversationEventHandler());
// 监听消息是否已送达实现 `conversation:messageDelivered` 即可。
- (void)conversation:(LCIMConversation *)conversation messageDelivered:(LCIMMessage *)message {
NSLog(@"%@", @"消息已送达。"); // 打印消息
}
请注意这里送达回执的内容,不是某一条具体的消息,而是当前对话内最后一次送达消息的时间戳(lastDeliveredAt
)。最开始我们有过解释,服务端在下发消息的时候,是能够保证顺序的,所以在送达回执的通知里面,我们不需要对逐条消息进行确认,只给出当前确认送达的最新消息的时间戳,那么在这之前的所有消息就都是已经送达的状态。在 UI 层展示的时候,可以将早于 lastDeliveredAt
的消息都标记为「已送达」。
已读回执
消息送达只是即时通讯服务端和客户端之间的投递行为完成了,可能终端用户并没有进入对话聊天页面,或者根本没有激活应用(Android 平台应用在后台也是可以收到消息的),所以「送达」并不等于终端用户真正「看到」了这条消息。
即时通讯服务还支持「已读」消息的回执,不过这首先需要接收方显式完成消息「已读」的确认。
由于即时通讯服务端是顺序下发新消息的,客户端不需要对每一条消息单独进行「已读」确认。我们设想的场景如下图所示:
用户在进入一个对话的时候,一次性清除当前对话的所有未读消息即可。Conversation
的清除接口如下:
- Unity
- Android
- iOS
/// <summary>
/// Mark the last message of this conversation as read.
/// </summary>
/// <returns></returns>
public Task Read();
/**
* 清除未读消息
*/
public void read();
/*!
将对话标记为已读。
该方法将本地对话中其他成员发出的最新消息标记为已读,该消息的发送者会收到已读通知。
*/
- (void)readInBackground;
对方「阅读」了消息之后,云端会向发送方发出一个回执通知,表明消息已被阅读。
Tom 和 Jerry 聊天,Tom 想及时知道 Jerry 是否阅读了自己发去的消息,这时候双方的处理流程是这样的:
Tom 向 Jerry 发送一条消息,且标记为「需要回执」:
- Unity
- Android
- iOS
LCIMTextMessage textMessage = new LCIMTextMessage("一条非常重要的消息。");
LCIMMessageSendOptions options = new LCIMMessageSendOptions {
Receipt = true
};
await conversation.Send(textMessage);LCIMClient tom = LCIMClient.getInstance("Tom");
LCIMConversation conv = client.getConversation("551260efe4b01608686c3e0f");
LCIMTextMessage textMessage = new LCIMTextMessage();
textMessage.setText("Hello, Jerry!");
LCIMMessageOption option = new LCIMMessageOption();
option.setReceipt(true); /* 将消息设置为需要回执。 */
conv.sendMessage(textMessage, option, new LCIMConversationCallback() {
@Override
public void done(LCIMException e) {
if (e == null) {
/* 发送成功 */
}
}
});LCIMMessageOption *option = [[LCIMMessageOption alloc] init];
option.receipt = YES; /* 将消息设置为需要回执。 */
LCIMTextMessage *message = [LCIMTextMessage messageWithText:@"Hello, Jerry!" attributes:nil];
[conversation sendMessage:message option:option callback:^(BOOL succeeded, NSError * _Nullable error) {
if (!error) {
/* 发送成功 */
}
}];Jerry 阅读 Tom 发的消息后,调用对话上的
read
方法把「对话中最近的消息」标记为已读:- Unity
- Android
- iOS
await conversation.Read();
conversation.read();
[conversation readInBackground];
Tom 将收到一个已读回执,对话的
lastReadAt
属性会更新。此时可以更新 UI,把时间戳小于lastReadAt
的消息都标记为已读:- Unity
- Android
- iOS
tom.OnLastReadAtUpdated = (conv) => {
// Jerry 阅读了你的消息。可以通过调用 conversation.LastReadAt 来获得对方已经读取到的时间
};public class CustomConversationEventHandler extends LCIMConversationEventHandler {
/**
* 实现本地方法来处理对方已经阅读消息的通知
*/
public void onLastReadAtUpdated(LCIMClient client, LCIMConversation conversation) {
/* Jerry 阅读了你的消息。可以通过调用 conversation.getLastReadAt() 来获得对方已经读取到的时间点 */
}
}
// 设置全局的对话事件处理 handler
LCIMMessageManager.setConversationEventHandler(new CustomConversationEventHandler());// Tom 可以在 client 的 delegate 方法中捕捉到 lastReadAt 的更新
- (void)conversation:(LCIMConversation *)conversation didUpdateForKey:(LCIMConversationUpdatedKey)key {
if ([key isEqualToString:LCIMConversationUpdatedKeyLastReadAt]) {
NSDate *lastReadAt = conversation.lastReadAt;
/* Jerry 阅读了你的消息。可以使用 lastReadAt 更新 UI,例如把时间戳小于 lastReadAt 的消息都标记为已读。 */
}
}
注意:
要使用已读回执,应用需要在初始化的时候开启 未读消息数更新通知 选项。
消息免打扰
假如某一用户不想再收到某对话的消息提醒,但又不想直接退出对话,可以使用静音操作,即开启「免打扰模式」。具体可以参考即时通讯开发指南第三篇的《消息免打扰》一节。
Will(遗愿)消息
即时通讯服务还支持一类比较特殊的消息:Will(遗愿)消息。「Will 消息」是在一个用户突然掉线之后,系统自动通知对话的其他成员关于该成员已掉线的消息,好似在掉线后要给对话中的其他成员一个妥善的交待,所以被戏称为「遗愿」消息,如下图中的「Tom 已断线,无法收到消息」:
要发送 Will 消息,用户需要设定好消息内容发给云端,云端并不会将其马上发送给对话的成员,而是缓存下来,一旦检测到该用户掉线,云端立即将这条遗愿消息发送出去。开发者可以利用它来构建自己的断线通知的逻辑。
- Unity
- Android
- iOS
LCIMTextMessage message = new LCIMTextMessage("我是一条遗愿消息,当发送者意外下线的时候,我会被下发给对话里面的其他成员。");
LCIMMessageSendOptions options = new LCIMMessageSendOptions {
Will = true
};
await conversation.Send(message, options);
LCIMTextMessage message = new LCIMTextMessage();
message.setText("我是一条遗愿消息,当发送者意外下线的时候,我会被下发给对话里面的其他成员。");
LCIMMessageOption option = new LCIMMessageOption();
option.setWill(true);
conversation.sendMessage(message, option, new LCIMConversationCallback() {
@Override
public void done(LCIMException e) {
if (e == null) {
// 发送成功
}
}
});
LCIMMessageOption *option = [[LCIMMessageOption alloc] init];
option.will = YES;
LCIMMessage *willMessage = [LCIMTextMessage messageWithText:@"我是一条遗愿消息,当发送者意外下线的时候,我会被下发给对话里面的其他成员。" attributes:nil];
[conversation sendMessage:willMessage option:option callback:^(BOOL succeeded, NSError * _Nullable error) {
if (succeeded) {
NSLog(@"遗愿消息已发出。");
}
}];
客户端发送完毕之后就完全不用再关心这条消息了,云端会自动在发送方异常掉线后通知其他成员,接收端则根据自己的需求来做 UI 的展现。
Will 消息有 如下限制:
- Will 消息是与当前用户绑定的,并且只对最后一次设置的「对话 + 消息」生效。如果用户在多个对话中设置了 Will 消息,那么只有最后一次设置有效;如果用户在同一个对话中设置了多条 Will 消息,也只有最后一次设置有效。
- Will 消息不会进入目标对话的消息历史记录。
- 当用户主动退出即时通讯服务时,系统会认为这是计划性下线,不会下发 Will 消息(如有)。
消息内容过滤
对于多人参与的聊天群组来说,内容的审核和实时过滤是产品运营上的基本要求。我们即时通讯服务默认提供了敏感词过滤的功能,具体可以参考即时通讯开发指南第三篇的《消息内容的实时过滤》一节。
本地发送失败的消息
有时你可能需要将发送失败的消息临时保存到客户端本地的缓存中,等到合适时机再进行处理。例如,将由于网络突然中断而发送失败的消息先保留下来,在消息列表中展示这种消息时,额外添加出错的提示符号和重发按钮,待网络恢复后再由用户选择是否重发。
即时通讯 Android 和 iOS SDK 默认提供了消息本地缓存的功能,消息缓存中保存的都是已经成功上行到云端的消息,并且能够保证和云端的数据同步。为了方便开发者,SDK 也支持将一时失败的消息加入到缓存中。
将消息加入缓存的代码如下:
- Unity
- Android
- iOS
// 暂不支持
conversation.addToLocalCache(message);
[conversation addMessageToCache:message];
将消息从缓存中删除:
- Unity
- Android
- iOS
// 暂不支持
conversation.removeFromLocalCache(message);
[conversation removeMessageFromCache:message];
从缓存中取出来的消息,在 UI 展示的时候可以根据 message.status
的属性值来做不同的处理,status
属性为 LCIMMessageStatusFailed
时即表示是发送失败了的本地消息,这时可以在消息旁边显示一个重新发送的按钮。通过将失败消息加入到 SDK 缓存中,还有一个好处就是,消息从缓存中取出来再次发送,不会造成服务端消息重复,因为 SDK 有做专门的去重处理。
离线消息同步
如果用户不上线,即时通讯的消息就总是无法下发,客户端如果长时间下线,会导致大量消息堆积在云端,此后如果用户再上线,我们该如何处理才能保证消息完全不丢失呢?
即时通讯服务提供两种方式进来同步离线消息:
- 一种是云端主动往客户端「推」的方式。云端会记录用户在每一个参与对话中接收消息的位置,在用户登录上线后,会以对话为单位来主动、多次下发消息(客户端按照收到新消息进行处理)。对每个对话,云端至多下发 20 条离线消息,更多消息则不会继续下发。
- 另一种是客户端主动从云端「拉」的方式。云端会记录下用户在每一个参与对话中接收的最后一条消息的位置,在用户重新登录上线后,实时计算出用户离线期间产生未读消息的对话列表及对应的未读消息数,以「未读消息数更新」的事件通知到客户端,然后客户端在需要的时候来主动拉取这些离线消息。
第一种方式实现简单,但是因为云端对一个对话只主动「推」 20 条离线消息,更多未读消息对客户端来说是透明的,所以只能满足一些轻量级的应用需求,如果产品层要在一个对话内显示消息阅读的进度,或者用精确的未读消息数来提示用户,就无法做到了。因此我们现在都切换到了第二种客户端主动「拉」的方式。
由于历史原因,不同平台的 SDK 对两种方式的支持度是不一样的:
- Android、iOS SDK 同时支持这两种方式,且默认是「拉」的方式
- JavaScript SDK 仅支持「拉」的方式
- .NET SDK 目前还不支持第二种方式。
注意,请不要混合使用上面两种方式,比如在 iOS 平台使用第一种方式获取离线消息,而 Android 平台使用第二种方式获取离线消息,可能导致所有离线消息无法正常获取。
未读消息数更新通知
在客户端重新登录上线后,即时通讯云端会实时计算下线时间段内当前用户参与过的对话中的新消息数量。
客户端只有设置了主动拉取的方式,云端才会在必要的时候下发这一通知。如前所述,对于 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
事件,在对话列表界面上更新这些对话的未读消息数量。建议开发者在应用层面对未读计数的结果进行持久化缓存,如果同一个对话有两个不同的未读数,则使用新数据直接覆盖老数据,这样对话列表里面展示的未读数会比较准确。
- Unity
- Android
- iOS
tom.OnUnreadMessagesCountUpdated = (convs) => {
foreach (LCIMConversation conv in convs) {
// conv.Unread 即该 conversation 的未读消息数量
}
};
// 实现 LCIMConversationEventHandler 的代理方法 onUnreadMessagesCountUpdated 来得到未读消息的数量变更的通知
onUnreadMessagesCountUpdated(LCIMClient client, LCIMConversation conversation) {
// conversation.getUnreadMessagesCount() 即该 conversation 的未读消息数量
}
// 使用代理方法 conversation:didUpdateForKey: 来观察对话的 unreadMessagesCount 属性
- (void)conversation:(LCIMConversation *)conversation didUpdateForKey:(LCIMConversationUpdatedKey)key {
if ([key isEqualToString:LCIMConversationUpdatedKeyUnreadMessagesCount]) {
NSUInteger unreadMessagesCount = conversation.unreadMessagesCount;
/* 有未读消息产生,请更新 UI,或者拉取对话。 */
}
}
对开发者来说,在 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
不发生冲突,那么云端会把他们当成独立的设备进行处理,应该下发给该用户的消息会分别下发给所有设备,不同设备上的未读消息计数则是合并在一起的(各端之间消息状态是同步的);该用户在单个设备上发出来的上行消息,云端也会默认同步到其他设备。
基于以上机制,即时通讯可以支持应用实现多种业务需求:
- 无限制的多端登录:不设置
tag
,默认对用户的多端登录不作限制。用户可以在多个设备上登录,比如在手机和平板上同时登录,甚至在两台不同的手机上登录,多个设备可以同时接收和回复消息。 - 单设备登录:在所有客户端都设置同一个
tag
,限制用户只能在一台设备上登录。 - 有限制的多端登录:通过设置不同的
tag
,允许用户在多台不同类型的设备上登录。例如,我们可以设计三种tag
:Mobile
、Pad
、Web
,分别对应三种类型的设备:手机、平板和电脑,那么用户分别在三种设备上登录就都是允许的,但是却不能同时在两台电脑上登录。详见下面的代码示例。
设置登录标记
按照上面的方案,以手机端登录为例,在创建 IMClient
实例的时候,我们增加 tag: Mobile
这样的标记:
- Unity
- Android
- iOS
LCIMClient client = new LCIMClient(clientId, "Mobile", "your-device-id");
// 第二个参数:登录标记 tag
LCIMClient currentClient = LCIMClient.getInstance(clientId, "Mobile");
currentClient.open(new LCIMClientCallback() {
@Override
public void done(LCIMClient avimClient, LCIMException e) {
if(e == null){
// 与云端建立连接成功
}
}
});
NSError *error;
LCIMClient *currentClient = [[LCIMClient alloc] initWithClientId:@"Tom" tag:@"Mobile" error:&error];
if (!error) {
[currentClient openWithCallback:^(BOOL succeeded, NSError *error) {
if (succeeded) {
// 与云端建立连接成功
}
}];
}
之后如果同一个用户在另一个手机上再次登录,则较早前登录系统的客户端会被强制下线。
处理登录冲突
即时通讯云端在登录用户的 <ClientId, Tag>
相同的时候,总是踢掉较早登录的设备,这时候较早登录设备端会收到被云端下线(CONFLICT
)的事件通知:
- Unity
- Android
- iOS
tom.OnClose = (code, detail) => {
};
public class AVImClientManager extends LCIMClientEventHandler {
/**
* 实现本方法以处理当前登录被踢下线的情况
*
*
* @param client
* @param code 状态码说明被踢下线的具体原因
*/
@Override
public void onClientOffline(LCIMClient avimClient, int i) {
if(i == 4111){
// 适当地弹出友好提示,告知当前用户的 clientId 在其他设备上登录了
}
}
}
// 自定义实现的 LCIMClientEventHandler 需要注册到 SDK 后,SDK 才会通过回调 onClientOffline 来通知开发者
LCIMClient.setClientEventHandler(new AVImClientManager());
- (void)imClientClosed:(LCIMClient *)imClient error:(NSError * _Nullable)error
{
if ([error.domain isEqualToString:kLeanCloudErrorDomain] &&
error.code == 4111) {
// 适当的弹出友好提示,告知当前用户的 clientId 在其他设备上登录了
}
}
如上述代码中,被动下线的时候,云端会告知原因,因此客户端在做展现的时候也可以做出类似于 QQ 一样友好的通知。
以上提到的登录均指用户主动进行登录操作。 已登录用户在应用启动、网络中断等场景下,SDK 会自动重新登录。 这种情况下,如果触发登录冲突,云端并不会踢掉较早登录的设备,自动重新登录的设备则会收到登录冲突的报错,登录失败。
相应地,应用开发者如果希望在用户主动登录触发冲突时,不踢掉较早登录的设备,而提示用户登录失败,可以在登录时传入参数指明这一点:
- Unity
- Android
- iOS
await tom.Open(false);
LCIMClientOpenOption openOption = new LCIMClientOpenOption();
openOption.setReconnect(true);
LCIMClient currentClient = LCIMClient.getInstance(clientId, "Mobile");
currentClient.open(openOption, new LCIMClientCallback() {
@Override
public void done(LCIMClient avimClient, LCIMException e) {
if(e == null){
// 与云端建立连接成功
}
}
});
NSError *err;
LCIMClient *currentClient = [[LCIMClient alloc] initWithClientId:@"Tom" tag:@"Mobile" error:&err];
if (err) {
NSLog(@"init failed with error: %@", err);
} else {
[currentClient openWithOption:LCIMClientOpenOptionReopen callback:^(BOOL succeeded, NSError * _Nullable error) {
if ([error.domain isEqualToString:kLeanCloudErrorDomain] &&
error.code == 4111) {
// 冲突时登录失败,不会踢掉较早登录的设备
}
}];
}
扩展自己的消息类型
尽管即时通讯服务默认已经包含了丰富的消息类型,但是我们依然支持开发者根据业务需要扩展自己的消息类型,例如允许用户之间发送名片、红包等等。这里「名片」和「红包」就可以是应用层定义的自己的消息类型。
自定义消息属性
即时通讯 SDK 默认提供了多种消息类型用来满足常见的需求:
TextMessage
文本消息ImageMessage
图像消息AudioMessage
音频消息VideoMessage
视频消息FileMessage
普通文件消息(.txt/.doc/.md 等各种)LocationMessage
地理位置消息
这些消息类型还支持应用层设置若干 key-value 自定义属性来实现扩展。譬如有一条文本消息需要附带城市信息,这时候开发者使用消息类中预留的 attributes
属性就可以保存额外信息了。
- Unity
- Android
- iOS
LCIMTextMessage messageWithCity = new LCIMTextMessage("天气太冷了");
messageWithCity["city"] = "北京";
LCIMTextMessage messageWithCity = new LCIMTextMessage();
messageWithCity.setText("天气太冷了");
HashMap<String,Object> attr = new HashMap<String,Object>();
attr.put("city", "北京");
messageWithCity.setAttrs(attr);
NSDictionary *attributes = @{ @"city": @"北京" };
LCIMTextMessage *messageWithCity = [LCIMTextMessage messageWithText:@"天气太冷了" attributes:attributes];
自定义消息类型
在默认的消息类型完全无法满足需求的时候,可以实现和使用自定义的消息类型。
- Unity
- Android
- iOS
继承于 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());
继承于 LCIMTypedMessage
,开发者也可以扩展自己的富媒体消息。其要求和步骤是:
- 实现新的消息类型,继承自
LCIMTypedMessage
。这里需要注意:- 在 class 上增加一个
@LCIMMessageType(type=123)
的 Annotation
具体消息类型的值(这里是123
)由开发者自己决定。内建消息类型使用负数,所有正数都预留给开发者扩展使用。 - 在消息内部声明字段属性时,要增加
@LCIMMessageField(name="")
的 Annotationname
为可选字段,同时自定义的字段要有对应的 getter/setter 方法。 - 请不要遗漏空的构造方法(参考下面的示例代码),否则会造成类型转换失败。
- 在 class 上增加一个
- 调用
LCIMMessageManager.registerLCIMMessageType()
函数进行类型注册。 - 调用
LCIMMessageManager.registerMessageHandler()
函数进行消息处理 handler 注册。
注意:如果你是使用 Kotlin 来开发,由于 Kotlin 对反射的处理方式与 Java 有细微差异,导致 LCIMMessageField
注释不能产生作用,所以 SDK 实际发送的自定义消息数据不全。我们已经在 6.4.4
版本的 SDK 中对这一问题进行了优化,请 Kotlin 开发者升级到 6.4.4 及其后续版本来定制子类化消息。
@LCIMMessageType(type = 123)
public class CustomMessage extends LCIMTypedMessage {
// 空的构造方法,不可遗漏
public CustomMessage() {
}
@LCIMMessageField(name = "_lctext")
String text;
@LCIMMessageField(name = "_lcattrs")
Map<String, Object> attrs;
public String getText() {
return this.text;
}
public void setText(String text) {
this.text = text;
}
public Map<String, Object> getAttrs() {
return this.attrs;
}
public void setAttrs(Map<String, Object> attr) {
this.attrs = attr;
}
}
// 注册自定义类型
LCIMMessageManager.registerLCIMMessageType(CustomMessage.class);
继承于 LCIMTypedMessage
,开发者也可以扩展自己的富媒体消息。其要求和步骤是:
- 实现
LCIMTypedMessageSubclassing
协议; - 子类将自身类型进行注册,一般可在子类的
+load
方法或者UIApplication
的-application:didFinishLaunchingWithOptions:
方法里面调用[YourClass registerSubclass]
。
// 定义
@interface CustomMessage : LCIMTypedMessage <LCIMTypedMessageSubclassing>
+ (LCIMMessageMediaType)classMediaType;
@end
@implementation CustomMessage
+ (LCIMMessageMediaType)classMediaType {
return 123;
}
@end
// 注册子类
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
[CustomMessage registerSubclass];
}
自定义消息的接收,可以参看即时通讯开发指南第一篇的《再谈接收消息》。
进一步阅读
- 即时通讯开发指南第三篇《安全与签名、黑名单和权限管理、玩转直播聊天室和临时对话》。
- 即时通讯开发指南第四篇详解消息 hook 与系统对话。