二,消息收发的更多方式,离线推送与消息同步,多设备登录
本章导读
在前一章从简单的单聊、群聊、收发图文消息开始里面,我们说明了如何在产品中增加一个基本的单聊/群聊页面,并响应服务端实时事件通知。接下来,在本篇文档中我们会讲解如何实现一些更复杂的业务需求,例如:
- 支持消息被接收和被阅读的状态回执,实现「Ding」一下的效果
- 发送带有成员提醒的消息(@ 某人),在超多用户群聊的场合提升目标用户的响应积极性
- 支持消息的撤回和修改
- 解决成员离线状态下的推送通知与重新上线后的消息同步,确保不丢消息
- 支持多设备登录,或者强制用户单点登录
- 扩展新的消息类型
消息收发的更多方式
在一个偏重工作协作或社交沟通的产品里,除了简单的消息收发之外,我们还会碰到更多需求,例如:
- 在消息中能否直接提醒某人,类似于很多 IM 工具中提供的 @ 消息,这样接收方能更明确地知道哪些消息需要及时响应;
- 消息发出去之后才发现内容不对,这时候能否修改或者撤回?
- 除了普通的聊天内容之外,是否支持发送类似于「XX 正在输入」这样的状态消息?
- 消息是否被其他人接收、读取,这样的状态能否反馈给发送者?
- 客户端掉线一段时间之后,可能会错过一批消息,能否提醒并同步一下未读消息?
等等,所有这些需求都可以通过即时通讯服务解决,下面我们来逐一看看具体的做法。
@ 成员提醒消息
在一些多人聊天群里面,因为消息量较大,很容易就导致某一条重要的消息没有被目标用户看到就被刷下去了,所以在消息中「@成员」是一种提醒接收者注意的有效办法。在微信这样的聊天工具里面,甚至会在对话列表页对有提醒的消息进行特别展示,用来通知消息目标高优先级查看和处理。
一般提醒消息都使用「@ + 人名」来表示目标用户,但是这里「人名」是一个由应用层决定的属性,可能有的产品使用全名,有的使用昵称,并且这个名字和即时通讯服务里面标识一个用户使用的 clientId
可能根本不一样(毕竟一个是给人看的,一个是给机器读的)。使用「人名」来圈定用户,也存在一种例外,就是聊天群组里面的用户名是可以改变的,如果消息发送的时候「王五」还叫「王五」,等发送出来之后他恰好同步改成了「王大麻子」,这时候接收方的处理就比较麻烦了。还有第三个原因,就是「提醒全部成员」的表示方式,可能「@all」、「@group」、「@所有人」都会被选择,这是一个完全依赖应用层 UI 的选项。
所以「@ 成员」提醒消息并不能简单在文本消息中加入「@ + 人名」,解决方案是给普通消息(LCIMMessage
)增加两个额外的属性:
mentionList
,是一个字符串的数组,用来单独记录被提醒的clientId
列表;mentionAll
,是一个Bool
型的标志位,用来表示是否要提醒全部成员。
带有提醒信息的消息,有可能既有提醒全部成员的标志,也还单独设置了 mentionList
,这由应用层去控制。发送方在发送「@ 成员」提醒消息的时候,如何输入、选择成员名称,这是业务方 UI 层面需要解决的问题,即时通讯 SDK 不关心其实现逻辑,SDK 只要求开发者在发送一条「@ 成员」消息的时候,调用 mentionList
和 mentionAll
的 setter 方法,设置正确的成员列表即可。示例代码如下:
- C#
- Java
- Objective-C
- JavaScript
- Swift
- Flutter
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 的消息已发出 */
}];
const message = new TextMessage(`@Tom 早点回家`).setMentionList(["Tom"]);
conversation
.send(message)
.then(function (message) {
console.log("发送成功!");
})
.catch(console.error);
do {
let message = IMTextMessage(text: "@Tom 早点回家")
message.mentionedMembers = ["Tom"]
try conversation.send(message: message, completion: { (result) in
switch result {
case .success:
break
case .failure(error: let error):
print(error)
}
})
} catch {
print(error)
}
try {
TextMessage message = TextMessage();
message.text = '@Tom 早点回家';
message.mentionMembers = ['Tom'];
await conversation.send(message: message);
} catch (e) {
print(e);
}
或者也可以通过设置 mentionAll
属性值提醒所有人:
- C#
- Java
- Objective-C
- JavaScript
- Swift
- Flutter
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) {
/* 一条提及所有用户的消息已发出 */
}];
const message = new TextMessage(`@all`).mentionAll();
conversation
.send(message)
.then(function (message) {
console.log("发送成功!");
})
.catch(console.error);
do {
let message = IMTextMessage(text: "@all")
message.isAllMembersMentioned = true
try conversation.send(message: message, completion: { (result) in
switch result {
case .success:
break
case .failure(error: let error):
print(error)
}
})
} catch {
print(error)
}
try {
TextMessage message = TextMessage();
message.text = 'content';
message.mentionAll = true;
await conversation.send(message: message);
} catch (e) {
print(e);
}
对于消息的接收方来说,可以通过调用 mentionList
和 mentionAll
的 getter 方法来获得提醒目标用户的信息,示例代码如下:
- C#
- Java
- Objective-C
- JavaScript
- Swift
- Flutter
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;
}
client.on(Event.MESSAGE, function messageEventHandler(message, conversation) {
var mentionList = receivedMessage.getMentionList();
});
func client(_ client: IMClient, conversation: IMConversation, event: IMConversationEvent) {
switch event {
case .message(event: let messageEvent):
switch messageEvent {
case .received(message: let message):
if let mentionedMembers = message.mentionedMembers {
print(mentionedMembers)
}
if let isAllMembersMentioned = message.isAllMembersMentioned {
print(isAllMembersMentioned)
}
default:
break
}
default:
break
}
}
jerry.onMessage = ({
Client client,
Conversation conversation,
Message message,
}) {
List mentionList = message.mentionMembers;
};
此外,为了方便应用层 UI 展现,我们特意为 LCIMMessage
增加了两个标识位,用来显示被提醒的状态:
- 一个是
mentionedAll
标识位,用来表示该消息是否提醒了当前对话的全体成员。只有mentionAll
属性为true
,这个标识位才为true
,否则就为false
。 - 另一个是
mentioned
标识位,用来快速判断该消息是否提醒了当前登录用户。如果mentionList
属性列表中包含有当前登录用户的clientId
,或者mentionAll
属性为true
,那么mentioned
方法都会返回true
,否则返回false
。
调用示例如下:
- C#
- Java
- Objective-C
- JavaScript
- Swift
- Flutter
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;
}
client.on(Event.MESSAGE, function messageEventHandler(message, conversation) {
var mentionedAll = receivedMessage.mentionedAll;
var mentionedMe = receivedMessage.mentioned;
});
func client(_ client: IMClient, conversation: IMConversation, event: IMConversationEvent) {
switch event {
case .message(event: let messageEvent):
switch messageEvent {
case .received(message: let message):
print(message.isCurrentClientMentioned)
default:
break
}
default:
break
}
}
// 暂不支持
修改消息
在 开发者中心 > 你的游戏 > 游戏服务 > 云服务 > 即时通讯 > 设置 > 即时通讯设置 启用「允许通过 SDK 编辑消息」后,终端用户可以对自己已经发送的消息进行修改(Conversation#updateMessage
方法)。目前即时通讯服务端并没有在时效性上进行限制,不过只允许用户修改自己发出去的消息,不允许修改别人的消息。
修改已经发送的消息,并不是直接在老的消息对象上修改,而是像发新消息一样创建一个消息实例,然后调用 Conversation#updateMessage(oldMessage, newMessage)
方法来向云端提交请求,示例代码如下:
- C#
- Java
- Objective-C
- JavaScript
- Swift
- Flutter
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(@"消息已被修改。");
}
}];
var newMessage = new TextMessage("new message");
conversation
.update(oldMessage, newMessage)
.then(function () {
// 修改成功
})
.catch(function (error) {
// 异常处理
});
do {
let newMessage = IMTextMessage(text: "Just a new message")
try conversation.update(oldMessage: oldMessage, to: newMessage, completion: { (result) in
switch result {
case .success:
break
case .failure(error: let error):
print(error)
}
})
} catch {
print(error)
}
try {
Message updatedMessage = await conversation.updateMessage(
oldMessage: oldMessage,
newMessage: newMessage,
);
} catch (e) {
print(e);
}
消息修改成功之后,对话内的其他成员会立刻接收到 MESSAGE_UPDATE
事件:
- C#
- Java
- Objective-C
- JavaScript
- Swift
- Flutter
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 {
/* 有消息被修改 */
}
var { Event } = require("leancloud-realtime");
conversation.on(Event.MESSAGE_UPDATE, function (newMessage, reason) {
// newMessage 为修改后的消息
// 在视图层可以通过消息的 ID 找到原来的消息并用 newMessage 替换
// reason (可选)对象表示消息修改的原因,
// reason 不存在表示发送者主动修改。
// reason 的 code 属性为正数时,表示因触发云引擎 hook 而导致消息修改
// (具体数值由开发者在 hook 函数定义中自行指定),
// reason 的 code 属性为负数时,表示因触发系统内置机制而导致消息修改,
// 例如 -4408 表示因敏感词过滤被修改。
// reason 的 detail 属性是一个字符串,指明具体的修改原因。
});
func client(_ client: IMClient, conversation: IMConversation, event: IMConversationEvent) {
switch event {
case .message(event: let messageEvent):
switch messageEvent {
case let .updated(updatedMessage: updatedMessage, reason: _):
print(updatedMessage)
default:
break
}
default:
break
}
}
tom.onMessageUpdated = ({
Client client,
Conversation conversation,
Message updatedMessage,
int patchCode,
String patchReason,
}) {
// updatedMessage 即为被修改的消息
};
对于 Android 和 iOS SDK 来说,如果开启了消息缓存的选项的话(默认开启),SDK 内部会先从缓存中修改这条消息记录,然后再通知应用层。所以对于开发者来说,收到这条通知之后刷新一下目标聊天页面,让消息列表更新即可(这时候消息列表会出现内容变化)。
如果系统修改了消息(例如触发了内置的敏感词过滤功能,或者云引擎的 hook 函数),发送者会收到 MESSAGE_UPDATE
事件,其他对话成员接收到的是修改过的消息。
撤回消息
除了修改消息,终端用户还可以撤回一条自己之前发送过的消息。 和修改消息类似,这一功能需要在控制台启用(开发者中心 > 你的游戏 > 游戏服务 > 云服务 > 即时通讯 > 设置 > 即时通讯设置 启用「允许通过 SDK 撤回消息」)。 同样,即时通讯服务端并没有在时效性上进行限制,不过只允许用户撤回自己发出去的消息,不允许撤回别人的消息。
撤回消息调用 Conversation#recallMessage
方法,示例代码如下:
- C#
- Java
- Objective-C
- JavaScript
- Swift
- Flutter
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(@"消息已被撤回。");
}
}];
conversation
.recall(oldMessage)
.then(function (recalledMessage) {
// 撤回成功
// recalledMessage 是一个 RecalledMessage
})
.catch(function (error) {
// 异常处理
});
do {
try conversation.recall(message: oldMessage, completion: { (result) in
switch result {
case .success(value: let recalledMessage):
print(recalledMessage)
case .failure(error: let error):
print(error)
}
})
} catch {
print(error)
}
try {
RecalledMessage recalledMessage = await conversation.recallMessage(
message: oldMessage,
);
} catch (e) {
print(e);
}
成功撤回消息后,对话内的其他成员会接收到 MESSAGE_RECALL
的事件:
- C#
- Java
- Objective-C
- JavaScript
- Swift
- Flutter
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 {
/* 有消息被撤回 */
}
var { Event } = require("leancloud-realtime");
conversation.on(Event.MESSAGE_RECALL, function (recalledMessage, reason) {
// recalledMessage 为已撤回的消息
// 在视图层可以通过消息的 ID 找到原来的消息并用 recalledMessage 替换
// reason (可选) 为撤回消息的原因,详见下文修改消息部分的说明。
});
func client(_ client: IMClient, conversation: IMConversation, event: IMConversationEvent) {
switch event {
case .message(event: let messageEvent):
switch messageEvent {
case let .updated(updatedMessage: updatedMessage, reason: _):
if let recalledMessage = updatedMessage as? IMRecalledMessage {
print(recalledMessage)
}
default:
break
}
default:
break
}
}
tom.onMessageRecalled = ({
Client client,
Conversation conversation,
RecalledMessage recalledMessage,
}) {
// recalledMessage 即为被撤回的消息
};
对于 Android 和 iOS SDK 来说,如果开启了消息缓存的选项的话(默认开启),SDK 内部需要保证数据的一致性,所以会先从缓存中删除这条消息记录,然后再通知应用层。对于开发者来说,收到这条通知之后刷新一下目标聊天页面,让消息列表更新即可(此时消息列表中的消息会直接变少,或者显示撤回提示)。
暂态消息
有时候我们需要发送一些特殊的消息,譬如聊天过程中「某某正在输入…」这样的实时状态信息,或者当群聊的名称修改以后给该群成员发送「群名称被某某修改为 XX」这样的通知信息。这类消息与终端用户发送的消息不一样,发送者不要求把它保存到历史记录里,也不要求一定会被送达(如果成员不在线或者现在网络异常,那么没有下发下去也无所谓),这种需求可以使用「暂态消息」来实现。
「暂态消息」是一种特殊的消息,与普通消息相比有以下几点不同:
- 它不会被自动保存到云端,以后在历史消息中无法找到它
- 只发送给当时在线的成员,不支持延迟接收,离线用户更不会收到推送通知
- 对当时在线成员也不保证百分百送达,如果因为当时网络原因导致下发失败,服务端不会重试
我们可以用「暂态消息」发送一些实时的、频繁变化的状态信息,或者用来实现简单的控制协议。
暂态消息的数据和构造方式与普通消息是一样的,只是其发送方式与普通消息有一些区别。到目前为止,我们演示的 LCIMConversation
发送消息接口都是这样的:
- C#
- Java
- Objective-C
- JavaScript
- Swift
- Flutter
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;
/**
* 发送消息
* @param {Message} message 消息,Message 及其子类的实例
* @return {Promise.<Message>} 发送的消息
*/
async send(message)
/// Send Message.
///
/// - Parameters:
/// - message: The message to be sent.
/// - options: @see `MessageSendOptions`.
/// - priority: @see `IMChatRoom.MessagePriority`.
/// - pushData: The push data of APNs.
/// - progress: The file uploading progress.
/// - completion: callback.
public func send(message: IMMessage, options: MessageSendOptions = .default, priority: IMChatRoom.MessagePriority? = nil, pushData: [String : Any]? = nil, progress: ((Double) -> Void)? = nil, completion: @escaping (LCBooleanResult) -> Void) throws
Future<Message> send({
Message message,
}) async {}
其实即时通讯 SDK 还允许在发送一条消息的时候,指定额外的参数 LCIMMessageOption
,LCIMConversation
完整的消息发送接口如下:
- C#
- Java
- Objective-C
- JavaScript
- Swift
- Flutter
/// <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;
/**
* 发送消息
* @param {Message} message 消息,Message 及其子类的实例
* @param {Object} [options] since v3.3.0,发送选项
* @param {Boolean} [options.transient] since v3.3.1,是否作为暂态消息发送
* @param {Boolean} [options.receipt] 是否需要回执,仅在普通对话中有效
* @param {Boolean} [options.will] since v3.4.0,是否指定该消息作为「遗愿消息」发送,
* 「遗愿消息」会延迟到当前用户掉线后发送,常用来实现「下线通知」功能
* @param {MessagePriority} [options.priority] 消息优先级,仅在聊天室中有效,
* see: {@link module:leancloud-realtime.MessagePriority MessagePriority}
* @param {Object} [options.pushData] 消息对应的离线推送内容,如果消息接收方不在线,会推送指定的内容。其结构说明参见: {@link https://url.leanapp.cn/pushData 推送消息内容 }
* @return {Promise.<Message>} 发送的消息
*/
async send(message, options)
/// Message Sending Option
public struct MessageSendOptions: OptionSet {
/// Get Receipt when other client received message or read message.
public static let needReceipt = MessageSendOptions(rawValue: 1 << 0)
/// Indicates whether this message is transient.
public static let isTransient = MessageSendOptions(rawValue: 1 << 1)
/// Indicates whether this message will be auto delivering to other client when this client disconnected.
public static let isAutoDeliveringWhenOffline = MessageSendOptions(rawValue: 1 << 2)
}
/// Send Message.
///
/// - Parameters:
/// - message: The message to be sent.
/// - options: @see `MessageSendOptions`.
/// - priority: @see `IMChatRoom.MessagePriority`.
/// - pushData: The push data of APNs.
/// - progress: The file uploading progress.
/// - completion: callback.
public func send(message: IMMessage, options: MessageSendOptions = .default, priority: IMChatRoom.MessagePriority? = nil, pushData: [String : Any]? = nil, progress: ((Double) -> Void)? = nil, completion: @escaping (LCBooleanResult) -> Void) throws
Future<Message> send({
Message message,
bool transient,
bool receipt,
bool will,
MessagePriority priority,
Map pushData,
}) async {}
通过 LCIMMessageOption
参数我们可以指定:
- 是否作为暂态消息发送(设置
transient
属性); - 服务端是否需要通知该消息的接收状态(设置
receipt
属性,消息回执,后续章节会进行说明); - 消息的优先级(设置
priority
属性,后续章节会说明); - 是否为「遗愿消息」(设置
will
属性,后续章节会说明); - 消息对应的离线推送内容(设置
pushData
属性,后续章节会说明),如果消息接收方不在线,会推送指定的内容。
如果我们需要让 Tom 在聊天页面的输入框获得焦点的时候,给群内成员同步一条「Tom 正在输入…」的状态信息,可以使用如下代码:
- C#
- Java
- Objective-C
- JavaScript
- Swift
- Flutter
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) {
/* 一条暂态消息已发出 */
}];
const message = new TextMessage("Tom 正在输入…");
conversation.send(message, { transient: true });
do {
let message = IMTextMessage(text: "Tom 正在输入…")
try conversation.send(message: message, options: [.isTransient], completion: { (result) in
switch result {
case .success:
break
case .failure(error: let error):
print(error)
}
})
} catch {
print(error)
}
try {
TextMessage message = TextMessage();
message.text = 'Tom 正在输入…';
// 发送一条暂态消息
await conversation.send(message: message, transient: true);
} catch (e) {
print(e);
}
暂态消息的接收逻辑和普通消息一样,开发者可以按照消息类型进行判断和处理,这里不再赘述。上面使用了内建的文本消息只是一种示例,从展现端来说,我们如果使用特定的类型来表示「暂态消息」,是一种更好的方案。即时通讯 SDK 并没有提供固定的「暂态消息」类型,可以由开发者根据自己的业务需要来实现专门的自定义,具体可以参考后述章节:扩展自己的消息类型。
消息回执
即时通讯服务端在进行消息投递的时候,会按照消息上行的时间先后顺序下发(先收到的消息先下发,保证顺序性),且内部协议上会要求 SDK 对收到的每一条消息进行确认(ack)。如果 SDK 收到了消息,但是在发送 ack 的过程中出现网络丢包,即时通讯服务端还是会认为消息没有投递下去,之后会再次投递,直到收到 SDK 的应答确认为止。与之对应,SDK 内部也进行了消息去重处理,保证在上面这种异常条件下应用层也不会收到重复的消息。所以我们的消息系统从协议上是可以保证不丢任何一条消息的。
不过,有些业务场景会对消息投递的细节有更高的要求,例如消息的发送方要能知道什么时候接收方收到了这条消息,什么时候 ta 又点开阅读了这条消息。有一些偏重工作写作或者私密沟通的产品,消息发送者在发送一条消息之后,还希望能看到消息被送达和阅读的实时状态,甚至还要提醒未读成员。这样「苛刻」的需求,就依赖于我们的「消息回执」功能来实现。
与上一节「暂态消息」的发送类似,要使用消息回执功能,需要在发送消息时在 LCIMMessageOption
参数中标记「需要回执」选项:
- C#
- Java
- Objective-C
- JavaScript
- Swift
- Flutter
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(@"发送成功!需要回执。");
}
}];
var message = new TextMessage("一条非常重要的消息。");
conversation.send(message, {
receipt: true,
});
do {
let message = IMTextMessage(text: "一条非常重要的消息。")
try conversation.send(message: message, options: [.needReceipt], completion: { (result) in
switch result {
case .success:
break
case .failure(error: let error):
print(error)
}
})
} catch {
print(error)
}
try {
TextMessage message = TextMessage();
message.text = '一条非常重要的消息。';
await conversation.send(message: message, receipt: true);
} catch (e) {
print(e);
}
注意:
只有在发送时设置了「需要回执」的标记,云端才会发送回执,默认不发送回执,且目前消息回执只支持单聊对话(成员不超过 2 人)。
那么发送方后续该如何响应回执的通知消息呢?
送达回执
当接收方收到消息之后,云端会向发送方发出一个回执通知,表明消息已经送达。请注意与「已读回执」区别开。
- C#
- Java
- Objective-C
- JavaScript
- Swift
- Flutter
// 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(@"%@", @"消息已送达。"); // 打印消息
}
var { Event } = require("leancloud-realtime");
conversation.on(Event.LAST_DELIVERED_AT_UPDATE, function () {
console.log(conversation.lastDeliveredAt);
// 在 UI 中将早于 lastDeliveredAt 的消息都标记为「已送达」
});
func client(_ client: IMClient, conversation: IMConversation, event: IMConversationEvent) {
switch event {
case .message(event: let messageEvent):
switch messageEvent {
case let .delivered(toClientID: toClientID, messageID: messageID, deliveredTimestamp: deliveredTimestamp):
if messageID == message.ID {
message.deliveredTimestamp = deliveredTimestamp
}
default:
break
}
default:
break
}
}
tom.onMessageDelivered = ({
Client client,
Conversation conversation,
String messageID,
String toClientID,
DateTime atDate,
}) {
// 消息已送达,在这里可以书写消息送达之后的业务逻辑代码
};
请注意这里送达回执的内容,不是某一条具体的消息,而是当前对话内最后一次送达消息的时间戳(lastDeliveredAt
)。最开始我们有过解释,服务端在下发消息的时候,是能够保证顺序的,所以在送达回执的通知里面,我们不需要对逐条消息进行确认,只给出当前确认送达的最新消息的时间戳,那么在这之前的所有消息就都是已经送达的状态。在 UI 层展示的时候,可以将早于 lastDeliveredAt
的消息都标记为「已送达」。
已读回执
消息送达只是即时通讯服务端和客户端之间的投递行为完成了,可能终端用户并没有进入对话聊天页面,或者根本没有激活应用(Android 平台应用在后台也是可以收到消息的),所以「送达」并不等于终端用户真正「看到」了这条消息。
即时通讯服务还支持「已读」消息的回执,不过这首先需要接收方显式完成消息「已读」的确认。
由于即时通讯服务端是顺序下发新消息的,客户端不需要对每一条消息单独进行「已读」确认。我们设想的场景如下图所示:
用户在进入一个对话的时候,一次性清除当前对话的所有未读消息即可。Conversation
的清除接口如下:
- C#
- Java
- Objective-C
- JavaScript
- Swift
- Flutter
/// <summary>
/// Mark the last message of this conversation as read.
/// </summary>
/// <returns></returns>
public Task Read();
/**
* 清除未读消息
*/
public void read();
/*!
将对话标记为已读。
该方法将本地对话中其他成员发出的最新消息标记为已读,该消息的发送者会收到已读通知。
*/
- (void)readInBackground;
/**
* 将该会话标记为已读
* @return {Promise.<this>} self
*/
async read();
/// Clear unread messages that its sent timestamp less than the sent timestamp of the parameter message.
///
/// - Parameter message: The default is the last message.
public func read(message: IMMessage? = nil)
await conversation.read();
对方「阅读」了消息之后,云端会向发送方发出一个回执通知,表明消息已被阅读。
Tom 和 Jerry 聊天,Tom 想及时知道 Jerry 是否阅读了自己发去的消息,这时候双方的处理流程是这样的:
Tom 向 Jerry 发送一条消息,且标记为「需要回执」:
- C#
- Java
- Objective-C
- JavaScript
- Swift
- Flutter
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) {
/* 发送成功 */
}
}];var message = new TextMessage("一条非常重要的消息。");
conversation.send(message, {
receipt: true,
});do {
let message = IMTextMessage(text: "Hello, Jerry!")
try conversation.send(message: message, options: [.needReceipt], completion: { (result) in
switch result {
case .success:
break
case .failure(error: let error):
print(error)
}
})
} catch {
print(error)
}try {
TextMessage message = TextMessage();
message.text = '一条非常重要的消息。';
await conversation.send(message: message, receipt: true);
} catch (e) {
print(e);
}Jerry 阅读 Tom 发的消息后,调用对话上的
read
方法把「对话中最近的消息」标记为已读:- C#
- Java
- Objective-C
- JavaScript
- Swift
- Flutter
await conversation.Read();
conversation.read();
[conversation readInBackground];
conversation
.read()
.then(function (conversation) {})
.catch(console.error.bind(console));conversation.read()
await conversation.read();
Tom 将收到一个已读回执,对话的
lastReadAt
属性会更新。此时可以更新 UI,把时间戳小于lastReadAt
的消息都标记为已读:- C#
- Java
- Objective-C
- JavaScript
- Swift
- Flutter
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 的消息都标记为已读。 */
}
}var { Event } = require("leancloud-realtime");
conversation.on(Event.LAST_READ_AT_UPDATE, function () {
console.log(conversation.lastReadAt);
// 在 UI 中将早于 lastReadAt 的消息都标记为「已读」
});func client(_ client: IMClient, conversation: IMConversation, event: IMConversationEvent) {
switch event {
case .message(event: let messageEvent):
switch messageEvent {
case let .read(byClientID: byClientID, messageID: messageID, readTimestamp: readTimestamp):
if messageID == message.ID {
message.readTimestamp = readTimestamp
}
default:
break
}
default:
break
}
}jerry.onLastReadAtUpdated = ({
Client client,
Conversation conversation,
}) {
// 在 UI 中将早于 lastReadAt 的消息都标记为「已读」
};
注意:
要使用已读回执,应用需要在初始化的时候开启 未读消息数更新通知 选项。
消息免打扰
假如某一用户不想再收到某对话的消息提醒,但又不想直接退出对话,可以使用静音操作,即开启「免打扰模式」。具体可以参考即时通讯开发指南第三篇的《消息免打扰》一节。
Will(遗愿)消息
即时通讯服务还支持一类比较特殊的消息:Will(遗愿)消息。「Will 消息」是在一个用户突然掉线之后,系统自动通知对话的其他成员关于该成员已掉线的消息,好似在掉线后要给对话中的其他成员一个妥善的交待,所以被戏称为「遗愿」消息,如下图中的「Tom 已断线,无法收到消息」:
要发送 Will 消息,用户需要设定好消息内容发给云端,云端并不会将其马上发送给对话的成员,而是缓存下来,一旦检测到该用户掉线,云端立即将这条遗愿消息发送出去。开发者可以利用它来构建自己的断线通知的逻辑。
- C#
- Java
- Objective-C
- JavaScript
- Swift
- Flutter
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(@"遗愿消息已发出。");
}
}];
var message = new TextMessage(
"我是一条遗愿消息,当发送者意外下线的时候,我会被下发给对话里面的其他成员。"
);
conversation
.send(message, { will: true })
.then(function () {
// 发送成功,当前 client 掉线的时候,这条消息会被下发给对话里面的其他成员
})
.catch(function (error) {
// 异常处理
});
do {
let message = IMTextMessage(text: "我是一条遗愿消息,当发送者意外下线的时候,我会被下发给对话里面的其他成员。")
try conversation.send(message: message, options: [.isAutoDeliveringWhenOffline], completion: { (result) in
switch result {
case .success:
break
case .failure(error: let error):
print(error)
}
})
} catch {
print(error)
}
try {
TextMessage message = TextMessage();
message.text = '我是一条遗愿消息,当发送者意外下线的时候,我会被下发给对话里面的其他成员。';
await conversation.send(message: message, will: true);
} catch (e) {
print(e);
}
客户端发送完毕之后就完全不用再关心这条消息了,云端会自动在发送方异常掉线后通知其他成员,接收端则根据自己的需求来做 UI 的展现。
Will 消息有 如下限制:
- Will 消息是与当前用户绑定的,并且只对最后一次设置的「对话 + 消息」生效。如果用户在多个对话中设置了 Will 消息,那么只有最后一次设置有效;如果用户在同一个对话中设置了多条 Will 消息,也只有最后一次设置有效。
- Will 消息不会进入目标对话的消息历史记录。
- 当用户主动退出即时通讯服务时,系统会认为这是计划性下线,不会下发 Will 消息(如有)。
消息内容过滤
对于多人参与的聊天群组来说,内容的审核和实时过滤是产品运营上的基本要求。我们即时通讯服务默认提供了敏感词过滤的功能,具体请参考即时通讯开发指南第三篇的《消息内容的实时过滤》一节。
本地发送失败的消息
有时你可能需要将发送失败的消息临时保存到客户端本地的缓存中,等到合适时机再进行处理。例如,将由于网络突然中断而发送失败的消息先保留下来,在消息列表中展示这种消息时,额外添加出错的提示符号和重发按钮,待网络恢复后再由用户选择是否重发。
即时通讯 Android 和 iOS SDK 默认提供了消息本地缓存的功能,消息缓存中保存的都是已经成功上行到云端的消息,并且能够保证和云端的数据同步。为了方便开发者,SDK 也支持将一时失败的消息加入到缓存中。
将消息加入缓存的代码如下:
- C#
- Java
- Objective-C
- JavaScript
- Swift
- Flutter
// 暂不支持
conversation.addToLocalCache(message);
[conversation addMessageToCache:message];
// 暂不支持
do {
try conversation.insertFailedMessageToCache(failedMessage) { (result) in
switch result {
case .success:
break
case .failure(error: let error):
print(error)
}
}
} catch {
print(error)
}
// 暂不支持
将消息从缓存中删除:
- C#
- Java
- Objective-C
- JavaScript
- Swift
- Flutter
// 暂不支持
conversation.removeFromLocalCache(message);
[conversation removeMessageFromCache:message];
// 暂不支持
do {
try conversation.removeFailedMessageFromCache(failedMessage) { (result) in
switch result {
case .success:
break
case .failure(error: let error):
print(error)
}
}
} catch {
print(error)
}
// 暂不支持
从缓存中取出来的消息,在 UI 展示的时候可以根据 message.status
的属性值来做不同的处理,status
属性为 LCIMMessageStatusFailed
时即表示是发送失败了的本地消息,这时可以在消息旁边显示一个重新发送的按钮。通过将失败消息加入到 SDK 缓存中,还有一个好处就是,消息从缓存中取出来再次发送,不会造成服务端消息重复,因为 SDK 有做专门的去重处理。
离线推送通知
对于移动设备来说,在聊天的过程中部分客户端难免会临时下线,如何保证离线用户也能及时收到消息,是我们需要考虑的重要问题。即时通讯云端会在用户下线的时候,主动通过「Push Notification」这种外部方式来通知客户端新消息到达事件,以促使用户尽快打开应用查看新消息。
iOS 和 Android 分别提供了内置的离线消息推送通知服务,但是使用的前提是按照推送文档配置 iOS 的推送证书和开启 Android 推送的开关,详细请阅读如下文档:
云端会将用户的即时通讯 clientId
与推送服务的设备数据 _Installation
自动进行关联。当用户 A 发出消息后,如果对话中部分成员当前不在线,而且这些成员使用的是 iOS 设备,或者是成功开通混合推送功能的 Android 设备的话,云端会自动将即时通讯消息转成特定的推送通知发送至客户端,同时我们也提供扩展机制,允许开发者对接第三方的消息推送服务。
要有效使用本功能,关键在于 自定义推送的内容。我们提供三种方式允许开发者来指定推送内容:
静态配置提醒消息
用户可以在控制台中为应用设置一个全局的静态 JSON 字符串,指定固定内容来发送通知。例如,我们进入 开发者中心 > 你的游戏 > 游戏服务 > 云服务 > 即时通讯 > 设置 > 离线推送,填入:
{ "alert": "您有新的消息", "badge": "Increment" }
那么在有新消息到达的时候,符合条件的离线用户会收到一条「您有新的消息」的通知栏消息。
注意,这里
badge
参数为 iOS 设备专用,且Increment
大小写敏感,表示自动增加应用 badge 上的数字计数。 通常需要在打开或退出应用时,通过设置 Installation 的 badge 字段清零 badge 计数。此外,对于 iOS 设备您还可以设置声音等推送属性,具体的字段可以参考 推送 REST API 使用指南 的《消息内容参数》一节。
客户端发送消息的时候额外指定推送信息
第一种方法虽然发出去了通知,但是因为通知文本与实际消息内容完全无关,存在一些不足。有没有办法让推送消息的内容与即时通讯消息动态相关呢?
还记得我们发送「暂态消息」时的
LCIMMessageOption
参数吗?即时通讯 SDK 允许客户端在发送消息的时候,指定附加的推送信息(在LCIMMessageOption
中设置pushData
属性),这样在需要离线推送的时候我们就会使用这里设置的内容来发出推送通知。示例代码如下:
- C#
- Java
- Objective-C
- JavaScript
- Swift
- Flutter
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 仅是举例,可随意替换"}
}
};
LCIMTextMessage msg = new LCIMTextMessage();
msg.setText("Jerry,今晚有比赛,我约了 Kate,咱们仨一起去酒吧看比赛啊?!");
LCIMMessageOption messageOption = new LCIMMessageOption();
String pushMessage = "{\"alert\":\"您有一条未读的消息\", \"category\":\"消息\","
+ "\"badge\":1,\"sound\":\"message.mp3\","
+ "\"custom-key\":\"由用户添加的自定义属性,custom-key 仅是举例,可随意替换\"}";
messageOption.setPushData(pushMessage);
conv.sendMessage(msg, messageOption, new LCIMConversationCallback() {
@Override
public void done(LCIMException e) {
if (e == null) {
// 发送成功
}
}
});
LCIMMessageOption *option = [[LCIMMessageOption alloc] init];
option.pushData = @{@"alert" : @"您有一条未读消息", @"sound" : @"message.mp3", @"badge" : @1, @"custom-key" : @"由用户添加的自定义属性,custom-key 仅是举例,可随意替换"};
[conversation sendMessage:[LCIMTextMessage messageWithText:@"Jerry,今晚有比赛,我约了 Kate,咱们仨一起去酒吧看比赛啊?!" attributes:nil] option:option callback:^(BOOL succeeded, NSError * _Nullable error) {
// 在这里处理发送失败或者成功之后的逻辑
}];
const message = new TextMessage('Jerry,今晚有比赛,我约了 Kate,咱们仨一起去酒吧看比赛啊?!');
conversation.send(message), {
pushData: {
"alert": "您有一条未读的消息",
"category": "消息",
"badge": 1,
"sound": "message.mp3", // 声音文件名,前提在应用里存在
"custom-key": "由用户添加的自定义属性,custom-key 仅是举例,可随意替换"
}
});
do {
let message = IMTextMessage(text: "Jerry,今晚有比赛,我约了 Kate,咱们仨一起去酒吧看比赛啊?!")
let pushData: [String: Any] = [
"alert": "您有一条未读的消息",
"category": "消息",
"badge": 1,
"sound": "message.mp3",
"custom-key": "由用户添加的自定义属性,custom-key 仅是举例,可随意替换"
]
try conversation.send(message: message, pushData: pushData, completion: { (result) in
switch result {
case .success:
break
case .failure(error: let error):
print(error)
}
})
} catch {
print(error)
}
try {
TextMessage message = TextMessage();
message.text = 'Jerry,今晚有比赛,我约了 Kate,咱们仨一起去酒吧看比赛啊?!';
await conversation.send(message: message, pushData: {
"alert": "您有一条未读的消息",
"category": "消息",
"badge": 1,
"sound": "message.mp3", // 声音文件名,前提在应用里存在
"custom-key": "由用户添加的自定义属性,custom-key 仅是举例,可随意替换"
});
} catch (e) {
print(e);
}
服务端动态生成通知内容
第二种方法虽然动态,但是需要在客户端发送消息的时候提前准备好推送内容,这对于开发阶段的要求比较高,并且在灵活性上有比较大的限制,所以看上去也不够完美。
我们还提供了第三种方式,让开发者在推送动态内容的时候,也不失实现上的灵活性。这种方式需要使用即时通讯 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
属性为推送服务内部使用,均不会实际推送。
指定附加推送信息时,支持为不同种类的设备(比如 ios
、android
)附加不同的推送信息,需要特别注意的是,_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
事件,在对话列表界面上更新这些对话的未读消息数量。建议开发者在应用层面对未读计数的结果进行持久化缓存,如果同一个对话有两个不同的未读数,则使用新数据直接覆盖老数据,这样对话列表里面展示的未读数会比较准确。
- C#
- Java
- Objective-C
- JavaScript
- Swift
- Flutter
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,或者拉取对话。 */
}
}
var { Event } = require("leancloud-realtime");
client.on(Event.UNREAD_MESSAGES_COUNT_UPDATE, function (conversations) {
for (let conv of conversations) {
console.log(conv.id, conv.name, conv.unreadMessagesCount);
}
});
func client(_ client: IMClient, conversation: IMConversation, event: IMConversationEvent) {
switch event {
case .unreadMessageCountUpdated:
print(conversation.unreadMessageCount)
default:
break
}
}
tom.onUnreadMessageCountUpdated = ({
Client client,
Conversation conversation,
}) {
// conversation.unreadMessageCount 即该 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
不发生冲突,那么云端会把他们当成独立的设备进行处理,应该下发给该用户的消息会分别下发给所有设备,不同设备上的未读消息计数则是合并在一起的(各端之间消息状态是同步的);该用户在单个设备上发出来的上行消息,云端也会默认同步到其他设备。
基于以上机制,即时通讯可以支持应用实现多种业务需求:
- 无限制的多端登录:不设置
tag
,默认对用户的多端登录不作限制。用户可以在多个设备上登录,比如在手机和平板上同时登录,甚至在两台不同的手机上登录,多个设备可以同时接收和回复消息。 - 单设备登录:在所有客户端都设置同一个
tag
,限制用户只能在一台设备上登录。 - 有限制的多端登录:通过设置不同的
tag
,允许用户在多台不同类型的设备上登录。例如,我们可以设计三种tag
:Mobile
、Pad
、Web
,分别对应三种类型的设备:手机、平板和电脑,那么用户分别在三种设备上登录就都是允许的,但是却不能同时在两台电脑上登录。详见下面的代码示例。
设置登录标记
按照上面的方案,以手机端登录为例,在创建 IMClient
实例的时候,我们增加 tag: Mobile
这样的标记:
- C#
- Java
- Objective-C
- JavaScript
- Swift
- Flutter
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) {
// 与云端建立连接成功
}
}];
}
realtime.createIMClient("Tom", { tag: "Mobile" }).then(function (tom) {
console.log("Tom 登录");
});
do {
let client = try IMClient(ID: "CLIENT_ID", tag: "Mobile")
client.open { (result) in
switch result {
case .success:
break
case .failure(error: let error):
print(error)
}
}
} catch {
print(error)
}
try {
Client tom = Client(id: 'Tom', tag: 'Mobile');
await tom.open();
} catch (e) {
print(e);
}
之后如果同一个用户在另一个手机上再次登录,则较早前登录系统的客户端会被强制下线。
处理登录冲突
即时通讯云端在登录用户的 <ClientId, Tag>
相同的时候,总是踢掉较早登录的设备,这时候较早登录设备端会收到被云端下线(CONFLICT
)的事件通知:
- C#
- Java
- Objective-C
- JavaScript
- Swift
- Flutter
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 在其他设备上登录了
}
}
var { Event } = require("leancloud-realtime");
tom.on(Event.CONFLICT, function () {
// 弹出提示,告知当前用户的 clientId 在其他设备上登录了
});
func client(_ client: IMClient, event: IMClientEvent) {
switch event {
case .sessionDidClose(error: let error):
if error.code == 4111 {
// 弹出提示,告知当前用户的 clientId 在其他设备上登录了
}
default:
break
}
}
tom.onClosed = ({
Client client,
RTMException exception,
}) {
if (exception.code == '4111') {
// 适当的弹出友好提示,告知当前用户的 clientId 在其他设备上登录了
}
};
如上述代码中,被动下线的时候,云端会告知原因,因此客户端在做展现的时候也可以做出类似于 QQ 一样友好的通知。
以上提到的登录均指用户主动进行登录操作。已登录用户在应用启动、网络中断等场景下,SDK 会自动重新登录。这种情况下,如果触发登录冲突,云端并不会踢掉较早登录的设备,自动重新登录的设备则会收到登录冲突的报错,登录失败。
相应地,应用开发者如果希望在用户主动登录触发冲突时,不踢掉较早登录的设备,而提示用户登录失败,可以在登录时传入参数指明这一点:
- C#
- Java
- Objective-C
- JavaScript
- Swift
- Flutter
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) {
// 冲突时登录失败,不会踢掉较早登录的设备
}
}];
}
realtime
.createIMClient("Tom", { tag: "Mobile", isReconnect: true })
.then(function (tom) {
console.log("冲突时登录失败,不会踢掉较早登录的设备");
});
do {
let client = try IMClient(ID: "Tom", tag: "Mobile")
client.open(options: [.reconnect]) { (result) in
switch result {
case .success:
break
case .failure(error: let error):
if error.code == 4111 {
// 冲突时登录失败,不会踢掉较早登录的设备
}
}
}
} catch {
print(error)
}
try {
Client tom = Client(id: 'Tom', tag: 'Mobile');
// 冲突时登录失败,不会踢掉较早登录的设备
await tom.open(reconnect: true);
} catch (e) {
print(e);
}
扩展自己的消息类型
尽管即时通讯服务默认已经包含了丰富的消息类型,但是我们依然支持开发者根据业务需要扩展自己的消息类型,例如允许用户之间发送名片、红包等等。这里「名片」和「红包」就可以是应用层定义的自己的消息类型。
自定义消息属性
即时通讯 SDK 默认提供了多种消息类型用来满足常见的需求:
TextMessage
文本消息ImageMessage
图像消息AudioMessage
音频消息VideoMessage
视频消息FileMessage
普通文件消息(.txt/.doc/.md 等各种)LocationMessage
地理位置消息
这些消息类型还支持应用层设置若干 key-value 自定义属性来实现扩展。譬如有一条文本消息需要附带城市信息,这时候开发者使用消息类中预留的 attributes
属性就可以保存额外信息了。
- C#
- Java
- Objective-C
- JavaScript
- Swift
- Flutter
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];
var messageWithCity = new TextMessage("天气太冷了");
messageWithCity.setAttributes({ city: "北京" });
let messageWithCity = IMTextMessage(text: "天气太冷了")
messageWithCity.attributes = ["city": "北京"];
TextMessage message = TextMessage();
message.text = '天气太冷了';
message.attributes = {'city': '北京'};
自定义消息类型
在默认的消息类型完全无法满足需求的时候,可以实现和使用自定义的消息类型。
- C#
- Java
- Objective-C
- JavaScript
- Swift
- Flutter
继承于 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];
}
通过继承 TypedMessage
,开发者也可以扩展自己的富媒体消息。其要求和步骤是:
- 申明新的消息类型,继承自
TypedMessage
或其子类,然后:- 对 class 使用
messageType(123)
装饰器,具体消息类型的值(这里是123
)由开发者自己决定(内建消息类型使用负数,所有正数都预留给开发者扩展使用)。 - 对 class 使用
messageField(['fieldName'])
装饰器来声明需要发送的字段。
- 对 class 使用
- 调用
Realtime#register()
函数注册这个消息类型。
举个例子,实现一个在 暂态消息 中提出的 OperationMessage
:
// TypedMessage, messageType, messageField 都是由 leancloud-realtime 这个包提供的
// 在浏览器中则是 var { TypedMessage, messageType, messageField } = AV;
var { TypedMessage, messageType, messageField } = require("leancloud-realtime");
// 定义 OperationMessage 类,用于发送和接收所有的用户操作消息
export class OperationMessage extends TypedMessage {}
// 指定 type 类型,可以根据实际换成其他正整数
messageType(1)(OperationMessage);
// 声明需要发送 op 字段
messageField("op")(OperationMessage);
// 注册消息类,否则收到消息时无法自动解析为 OperationMessage
realtime.register(OperationMessage);
继承于 IMCategorizedMessage
,开发者也可以扩展自己的富媒体消息。其要求和步骤是:
- 实现
IMMessageCategorizing
协议; - 子类将自身类型进行注册,一般可在
AppDelegate
的application(_:didFinishLaunchingWithOptions:)
方法里面调用try CustomMessage.register()
。
// 定义 CustomMessage 类
class CustomMessage: IMCategorizedMessage {
// 指定 type 类型,可以根据实际换成其他正整数
class override var messageType: MessageType {
return 1
}
}
// 注册消息类型
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
do {
try CustomMessage.register()
} catch {
print(error)
return false
}
return true
}
继承于 TypedMessage
,开发者也可以扩展自己的富媒体消息。步骤是:
// 自定义消息类型 CustomMessage
class CustomMessage extends TypedMessage {
int get type => 123;
CustomMessage() : super();
CustomMessage.from({
String text,
//...
}) {
this.text = text;
}
}
TypedMessage.register(() => CustomMessage());
自定义消息的接收,可以参看即时通讯开发指南第一篇的《再谈接收消息》。
进一步阅读
- 《即时通讯开发指南》第三篇安全与签名、玩转聊天室和临时对话
- 《即时通讯开发指南》第四篇详解消息 hook 与系统对话