// 定义一个常驻内存的全局变量
var tom: IMClient
// 初始化,然后登陆
do {
tom = try IMClient(ID: "Tom")
tom.open { (result) in
switch result {
case .success:
break
case .failure(error: let error):
print(error)
}
}
} catch {
print(error)
}
// Tom 创建了一个 client,用自己的名字作为 clientId 登录
Client tom = Client(id: 'Tom');
// Tom 登录
await tom.open();
var AV = require('leancloud-storage');
// 以 AVUser 的用户名和密码登录即时通讯服务
AV.User.logIn('username', 'password').then(function(user) {
return realtime.createIMClient(user);
}).catch(console.error.bind(console));
// 定义一个常驻内存的全局变量
var client: IMClient
// 登陆 User,然后使用登陆成功的 User 初始化 Client 并登陆
LCUser.logIn(username: USER_NAME, password: PASSWORD) { (result) in
switch result {
case .success(object: let user):
do {
client = try IMClient(user: user)
client.open { (result) in
// handle result
}
} catch {
print(error)
}
case .failure(error: let error):
print(error)
}
}
// 创建与 Jerry 之间的对话
tom.createConversation({ // tom 是一个 IMClient 实例
// 指定对话的成员除了当前用户 Tom(SDK 会默认把当前用户当做对话成员)之外,还有 Jerry
members: ['Jerry'],
// 对话名称
name: 'Tom & Jerry',
unique: true
}).then(/* 略 */);
do {
try tom.createConversation(clientIDs: ["Jerry"], name: "Tom & Jerry", isUnique: true, completion: { (result) in
switch result {
case .success(value: let conversation):
print(conversation)
case .failure(error: let error):
print(error)
}
})
} catch {
print(error)
}
/// <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);
}
/**
* 创建或查询一个已有 conversation
*
* @param members 对话的成员
* @param name 对话的名字
* @param attributes 对话的额外属性
* @param isTransient 是否是聊天室
* @param isUnique 如果已经存在符合条件的会话,是否返回已有回话
* 为 false 时,则一直为创建新的回话
* 为 true 时,则先查询,如果已有符合条件的回话,则返回已有的,否则,创建新的并返回
* 为 true 时,仅 members 为有效查询条件
* @param callback 结果回调函数
*/
public void createConversation(final List<String> members, final String name,
final Map<String, Object> attributes, final boolean isTransient, final boolean isUnique,
final LCIMConversationCreatedCallback callback);
/**
* 创建一个聊天对话
*
* @param members 对话参与者
* @param attributes 对话的额外属性
* @param isTransient 是否为聊天室
* @param callback 结果回调函数
*/
public void createConversation(final List<String> members, final String name,
final Map<String, Object> attributes, final boolean isTransient,
final LCIMConversationCreatedCallback callback);
/**
* 创建一个聊天对话
*
* @param conversationMembers 对话参与者
* @param name 对话名称
* @param attributes 对话属性
* @param callback 结果回调函数
* @since 3.0
*/
public void createConversation(final List<String> conversationMembers, String name,
final Map<String, Object> attributes, final LCIMConversationCreatedCallback callback);
/**
* 创建一个聊天对话
*
* @param conversationMembers 对话参与者
* @param attributes 对话属性
* @param callback 结果回调函数
* @since 3.0
*/
public void createConversation(final List<String> conversationMembers,
final Map<String, Object> attributes, final LCIMConversationCreatedCallback callback);
/// The option of conversation creation.
@interface LCIMConversationCreationOption : NSObject
/// The name of the conversation.
@property (nonatomic, nullable) NSString *name;
/// The attributes of the conversation.
@property (nonatomic, nullable) NSDictionary *attributes;
/// Create or get an unique conversation, default is `true`.
@property (nonatomic) BOOL isUnique;
/// The time interval for the life of the temporary conversation.
@property (nonatomic) NSUInteger timeToLive;
@end
/// Create a Normal Conversation. Default is a Normal Unique Conversation.
/// @param clientIds The set of client ID. it's the members of the conversation which will be created. the initialized members always contains current client's ID. if the created conversation is unique, and server has one unique conversation with the same members, that unique conversation will be returned.
/// @param callback Result callback.
- (void)createConversationWithClientIds:(NSArray<NSString *> *)clientIds
callback:(void (^)(LCIMConversation * _Nullable conversation, NSError * _Nullable error))callback;
/// Create a Normal Conversation. Default is a Normal Unique Conversation.
/// @param clientIds The set of client ID. it's the members of the conversation which will be created. the initialized members always contains current client's ID. if the created conversation is unique, and server has one unique conversation with the same members, that unique conversation will be returned.
/// @param option See `LCIMConversationCreationOption`.
/// @param callback Result callback.
- (void)createConversationWithClientIds:(NSArray<NSString *> *)clientIds
option:(LCIMConversationCreationOption * _Nullable)option
callback:(void (^)(LCIMConversation * _Nullable conversation, NSError * _Nullable error))callback;
/// Create a Chat Room.
/// @param callback Result callback.
- (void)createChatRoomWithCallback:(void (^)(LCIMChatRoom * _Nullable chatRoom, NSError * _Nullable error))callback;
/// Create a Chat Room.
/// @param option See `LCIMConversationCreationOption`.
/// @param callback Result callback.
- (void)createChatRoomWithOption:(LCIMConversationCreationOption * _Nullable)option
callback:(void (^)(LCIMChatRoom * _Nullable chatRoom, NSError * _Nullable error))callback;
/// Create a Temporary Conversation. Temporary Conversation is unique in it's Life Cycle.
/// @param clientIds The set of client ID. it's the members of the conversation which will be created. the initialized members always contains this client's ID.
/// @param callback Result callback.
- (void)createTemporaryConversationWithClientIds:(NSArray<NSString *> *)clientIds
callback:(void (^)(LCIMTemporaryConversation * _Nullable temporaryConversation, NSError * _Nullable error))callback;
/// Create a Temporary Conversation. Temporary Conversation is unique in it's Life Cycle.
/// @param clientIds The set of client ID. it's the members of the conversation which will be created. the initialized members always contains this client's ID.
/// @param option See `LCIMConversationCreationOption`.
/// @param callback Result callback.
- (void)createTemporaryConversationWithClientIds:(NSArray<NSString *> *)clientIds
option:(LCIMConversationCreationOption * _Nullable)option
callback:(void (^)(LCIMTemporaryConversation * _Nullable temporaryConversation, NSError * _Nullable error))callback;
/// Create a Normal Conversation. Default is a Unique Conversation.
///
/// - Parameters:
/// - clientIDs: The set of client ID. it's the members of the conversation which will be created. the initialized members always contains current client's ID. if the created conversation is unique, and server has one unique conversation with the same members, that unique conversation will be returned.
/// - name: The name of the conversation.
/// - attributes: The attributes of the conversation.
/// - isUnique: True means create or get a unique conversation, default is true.
/// - completion: callback.
public func createConversation(clientIDs: Set<String>, name: String? = nil, attributes: [String : Any]? = nil, isUnique: Bool = true, completion: @escaping (LCGenericResult<IMConversation>) -> Void) throws
/// Create a Chat Room.
///
/// - Parameters:
/// - name: The name of the chat room.
/// - attributes: The attributes of the chat room.
/// - completion: callback.
public func createChatRoom(name: String? = nil, attributes: [String : Any]? = nil, completion: @escaping (LCGenericResult<IMChatRoom>) -> Void) throws
/// Create a Temporary Conversation. Temporary Conversation is unique in it's Life Cycle.
///
/// - Parameters:
/// - clientIDs: The set of client ID. it's the members of the conversation which will be created. the initialized members always contains this client's ID.
/// - timeToLive: The time interval for the life of the temporary conversation.
/// - completion: callback.
public func createTemporaryConversation(clientIDs: Set<String>, timeToLive: Int32, completion: @escaping (LCGenericResult<IMTemporaryConversation>) -> Void) throws
/// To create a normal [Conversation].
///
/// [isUnique] is a special parameter, default is `true`, it affects the creation behavior and property [Conversation.isUnique].
/// * When it is `true` and the relevant unique [Conversation] not exists in the server, this method will create a new unique [Conversation].
/// * When it is `true` and the relevant unique [Conversation] exists in the server, this method will return that existing unique [Conversation].
/// * When it is `false`, this method always create a new non-unique [Conversation].
///
/// [members] is the [Conversation.members].
/// [name] is the [Conversation.name].
/// [attributes] is the [Conversation.attributes].
///
/// Returns an instance of [Conversation].
Future<Conversation> createConversation({
bool isUnique = true,
Set<String> members,
String name,
Map<String, dynamic> attributes,
}) async {}
/// To create a new [ChatRoom].
///
/// [name] is the [Conversation.name].
/// [attributes] is the [Conversation.attributes].
///
/// Returns an instance of [ChatRoom].
Future<ChatRoom> createChatRoom({
String name,
Map<String, dynamic> attributes,
}) async {}
/// To create a new [TemporaryConversation].
///
/// [members] is the [Conversation.members].
/// [timeToLive] is the [TemporaryConversation.timeToLive].
///
/// Returns an instance of [TemporaryConversation].
Future<TemporaryConversation> createTemporaryConversation({
Set<String> members,
int timeToLive,
}) async {}
do {
let textMessage = IMTextMessage(text: "Jerry,起床了!")
try conversation.send(message: textMessage) { (result) in
switch result {
case .success:
break
case .failure(error: let error):
print(error)
}
}
} catch {
print(error)
}
var { Event } = require('leancloud-realtime');
// Jerry 登录
realtime.createIMClient('Jerry').then(function(jerry) {
}).catch(console.error);
do {
let jerry = try IMClient(ID: "Jerry")
jerry.open { (result) in
switch result {
case .success:
break
case .failure(error: let error):
print(error)
}
}
} catch {
print(error)
}
Client jerry = Client(id: 'Jerry');
await jerry.open();
Jerry 作为消息的被动接收方,他不需要主动创建与 Tom 的对话,可能也无法知道 Tom 创建好的对话信息,Jerry 端需要通过设置即时通讯客户端事件的回调函数,才能获取到 Tom 那边操作的通知。
即时通讯客户端事件回调能处理多种服务端通知,这里我们先关注这里会出现的两个事件:
用户被邀请进入某个对话的通知事件。Tom 在创建和 Jerry 的单聊对话的时候,Jerry 这边就能立刻收到一条通知,获知到类似于「Tom 邀请你加入了一个对话」的信息。
已加入对话中新消息到达的通知。在 Tom 发出「Jerry,起床了!」这条消息之后,Jerry 这边也能立刻收到一条新消息到达的通知,通知中带有消息具体数据以及对话、发送者等上下文信息。
do {
let conversationQuery = client.conversationQuery
try conversationQuery.getConversation(by: "CONVERSATION_ID") { (result) in
switch result {
case .success(value: let conversation):
do {
try conversation.add(members: ["Mary"], completion: { (result) in
switch result {
case .allSucceeded:
break
case .failure(error: let error):
print(error)
case let .slicing(success: succeededIDs, failure: failures):
if let succeededIDs = succeededIDs {
print(succeededIDs)
}
for (failedIDs, error) in failures {
print(failedIDs)
print(error)
}
}
})
} catch {
print(error)
}
case .failure(error: let error):
print(error)
}
}
} catch {
print(error)
}
tom.createConversation({
// 创建的时候直接指定 Jerry 和 Mary 一起加入多人群聊,当然根据需求可以添加更多成员
members: ['Jerry','Mary'],
// 对话名称
name: 'Tom & Jerry & friends',
unique: true,
}).catch(console.error);
do {
try tom.createConversation(clientIDs: ["Jerry", "Mary"], name: "Tom & Jerry & friends", isUnique: true, completion: { (result) in
switch result {
case .success(value: let conversation):
print(conversation)
case .failure(error: let error):
print(error)
}
})
} catch {
print(error)
}
do {
let textMessage = IMTextMessage(text: "大家好,欢迎来到我们的群聊对话!")
try conversation.send(message: textMessage, completion: { (result) in
switch result {
case .success:
break
case .failure(error: let error):
print(error)
}
})
} catch {
print(error)
}
do {
try conversation.remove(members: ["Mary"], completion: { (result) in
switch result {
case .allSucceeded:
break
case .failure(error: let error):
print(error)
case let .slicing(success: succeededIDs, failure: failures):
if let succeededIDs = succeededIDs {
print(succeededIDs)
}
for (failedIDs, error) in failures {
print(failedIDs)
print(error)
}
}
})
} catch {
print(error)
}
do {
let conversationQuery = client.conversationQuery
try conversationQuery.getConversation(by: "CONVERSATION_ID") { (result) in
switch result {
case .success(value: let conversation):
do {
try conversation.join(completion: { (result) in
switch result {
case .success:
break
case .failure(error: let error):
print(error)
}
})
} catch {
print(error)
}
case .failure(error: let error):
print(error)
}
}
} catch {
print(error)
}
do {
try conversation.leave(completion: { (result) in
switch result {
case .success:
break
case .failure(error: let error):
print(error)
}
})
} catch {
print(error)
}
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);
LCFile file = LCFile.withAbsoluteLocalPath("San_Francisco.png", Environment.getExternalStorageDirectory() + "/San_Francisco.png");
// 创建一条图像消息
LCIMImageMessage m = new LCIMImageMessage(file);
m.setText("发自我的小米手机");
conv.sendMessage(m, new LCIMConversationCallback() {
@Override
public void done(LCIMException e) {
if (e == null) {
// 发送成功
}
}
});
// 图像消息等富媒体消息依赖存储 SDK 和富媒体消息插件,
// 具体的引用和初始化步骤请参考《SDK 安装指南》
var fileUploadControl = $('#photoFileUpload')[0];
var file = new AV.File('avatar.jpg', fileUploadControl.files[0]);
file.save().then(function() {
var message = new ImageMessage(file);
message.setText('发自我的 Ins');
message.setAttributes({ location: '旧金山' });
return conversation.send(message);
}).then(function() {
console.log('发送成功');
}).catch(console.error.bind(console));
do {
if let imageFilePath = Bundle.main.url(forResource: "image", withExtension: "jpg")?.path {
let imageMessage = IMImageMessage(filePath: imageFilePath, format: "jpg")
try conversation.send(message: imageMessage, completion: { (result) in
switch result {
case .success:
break
case .failure(error: let error):
print(error)
}
})
}
} catch {
print(error)
}
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);
LCFile file = new LCFile("萌妹子","http://ww3.sinaimg.cn/bmiddle/596b0666gw1ed70eavm5tg20bq06m7wi.gif", null);
LCIMImageMessage m = new LCIMImageMessage(file);
m.setText("萌妹子一枚");
// 创建一条图像消息
conv.sendMessage(m, new LCIMConversationCallback() {
@Override
public void done(LCIMException e) {
if (e == null) {
// 发送成功
}
}
});
// Tom 发了一张图片给 Jerry
LCFile *file = [LCFile fileWithURL:[self @"http://ww3.sinaimg.cn/bmiddle/596b0666gw1ed70eavm5tg20bq06m7wi.gif"]];
LCIMImageMessage *message = [LCIMImageMessage messageWithText:@"萌妹子一枚" file:file attributes:nil];
[conversation sendMessage:message callback:^(BOOL succeeded, NSError *error) {
if (succeeded) {
NSLog(@"发送成功!");
}
}];
var AV = require('leancloud-storage');
var { ImageMessage } = initPlugin(AV, IM);
// 从网络链接直接构建一个图像消息
var file = new AV.File.withURL('萌妹子', 'http://pic2.zhimg.com/6c10e6053c739ed0ce676a0aff15cf1c.gif');
file.save().then(function() {
var message = new ImageMessage(file);
message.setText('萌妹子一枚');
return conversation.send(message);
}).then(function() {
console.log('发送成功');
}).catch(console.error.bind(console));
do {
if let url = URL(string: "http://ww3.sinaimg.cn/bmiddle/596b0666gw1ed70eavm5tg20bq06m7wi.gif") {
let imageMessage = IMImageMessage(url: url, format: "gif")
try conversation.send(message: imageMessage, completion: { (result) in
switch result {
case .success:
break
case .failure(error: let error):
print(error)
}
})
}
} catch {
print(error)
}
var { Event, TextMessage } = require('leancloud-realtime');
var { ImageMessage } = initPlugin(AV, IM);
client.on(Event.MESSAGE, function messageEventHandler(message, conversation) {
var file;
switch (message.type) {
case ImageMessage.TYPE:
file = message.getFile();
console.log('收到图像消息,URL:' + file.url());
break;
}
}
func client(_ client: IMClient, conversation: IMConversation, event: IMConversationEvent) {
switch event {
case .message(event: let messageEvent):
switch messageEvent {
case .received(message: let message):
switch message {
case let imageMessage as IMImageMessage:
print(imageMessage)
default:
break
}
default:
break
}
default:
break
}
}
lient.onMessage = ({
Client client,
Conversation conversation,
Message message,
}) {
if (message is ImageMessage) {
print('收到图像消息,URL:${message.url}');
}
};
发送音频消息/视频/文件
发送流程
对于图像、音频、视频和文件这四种类型的消息,SDK 均采取如下的发送流程:
如果文件是从 客户端 API 读取的数据流(Stream),步骤为:
从本地构造 LCFile
调用 LCFile 的上传方法将文件上传到云端,并获取文件元信息(metaData)
把 LCFile 的 objectId、URL、文件元信息都封装在消息体内
调用接口发送消息
如果文件是 外部链接的 URL,则:
直接将 URL 封装在消息体内,不获取元信息(例如,音频消息的时长),不包含 objectId
调用接口发送消息
以发送音频消息为例,基本流程是:读取音频文件(或者录制音频)> 构建音频消息 > 消息发送。
var audio = new LCFile("tante.mp3", Path.Combine(Application.persistentDataPath, "tante.mp3"));
var audioMessage = new LCIMAudioMessage(audio);
audioMessage.Text = "听听人类的神曲";
await conversation.Send(audioMessage);
LCFile file = LCFile.withAbsoluteLocalPath("忐忑.mp3",localFilePath);
LCIMAudioMessage m = new LCIMAudioMessage(file);
m.setText("听听人类的神曲");
// 创建一条音频消息
conv.sendMessage(m, new LCIMConversationCallback() {
@Override
public void done(LCIMException e) {
if (e == null) {
// 发送成功
}
}
});
var AV = require('leancloud-storage');
var { AudioMessage } = initPlugin(AV, IM);
var fileUploadControl = $('#musicFileUpload')[0];
var file = new AV.File('忐忑.mp3', fileUploadControl.files[0]);
file.save().then(function() {
var message = new AudioMessage(file);
message.setText('听听人类的神曲');
return conversation.send(message);
}).then(function() {
console.log('发送成功');
}).catch(console.error.bind(console));
do {
if let filePath = Bundle.main.url(forResource: "audio", withExtension: "mp3")?.path {
let audioMessage = IMAudioMessage(filePath: filePath, format: "mp3")
audioMessage.text = "听听人类的神曲"
try conversation.send(message: audioMessage, completion: { (result) in
switch result {
case .success:
break
case .failure(error: let error):
print(error)
}
})
}
} catch {
print(error)
}
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);
LCFile file = new LCFile("apple.aac", "https://some.website.com/apple.aac", null);
LCIMAudioMessage m = new LCIMAudioMessage(file);
m.setText("来自苹果发布会现场的录音");
conv.sendMessage(m, new LCIMConversationCallback() {
@Override
public void done(LCIMException e) {
if (e == null) {
// 发送成功
}
}
});
var AV = require('leancloud-storage');
var { AudioMessage } = initPlugin(AV, IM);
var file = new AV.File.withURL('apple.aac', 'https://some.website.com/apple.aac');
file.save().then(function() {
var message = new AudioMessage(file);
message.setText('来自苹果发布会现场的录音');
return conversation.send(message);
}).then(function() {
console.log('发送成功');
}).catch(console.error.bind(console));
do {
if let url = URL(string: "https://some.website.com/apple.aac") {
let audioMessage = IMAudioMessage(url: url, format: "aac")
audioMessage.text = "来自苹果发布会现场的录音"
try conversation.send(message: audioMessage, completion: { (result) in
switch result {
case .success:
break
case .failure(error: let error):
print(error)
}
})
}
} catch {
print(error)
}
var location = new LCGeoPoint(31.3753285, 120.9664658);
var locationMessage = new LCIMLocationMessage(location);
await conversation.Send(locationMessage);
final LCIMLocationMessage locationMessage = new LCIMLocationMessage();
// 开发者可以通过设备的 API 获取设备的具体地理位置,此处设置了 2 个经纬度常量作为演示
locationMessage.setLocation(new LCGeoPoint(31.3753285,120.9664658));
locationMessage.setText("蛋糕店的位置");
conversation.sendMessage(locationMessage, new LCIMConversationCallback() {
@Override
public void done(LCIMException e) {
if (null != e) {
e.printStackTrace();
} else {
// 发送成功
}
}
});
var AV = require('leancloud-storage');
var { LocationMessage } = initPlugin(AV, IM);
var location = new AV.GeoPoint(31.3753285, 120.9664658);
var message = new LocationMessage(location);
message.setText('蛋糕店的位置');
conversation.send(message).then(function() {
console.log('发送成功');
}).catch(console.error.bind(console));
do {
let locationMessage = IMLocationMessage(latitude: 31.3753285, longitude: 120.9664658)
try conversation.send(message: locationMessage, completion: { (result) in
switch result {
case .success:
break
case .failure(error: let error):
print(error)
}
})
} catch {
print(error)
}
// 处理默认类型消息
func client(_ client: IMClient, conversation: IMConversation, event: IMConversationEvent) {
switch event {
case .message(event: let messageEvent):
switch messageEvent {
case .received(message: let message):
if let categorizedMessage = message as? IMCategorizedMessage {
switch categorizedMessage {
case let textMessage as IMTextMessage:
print(textMessage)
case let imageMessage as IMImageMessage:
print(imageMessage)
case let audioMessage as IMAudioMessage:
print(audioMessage)
case let videoMessage as IMVideoMessage:
print(videoMessage)
case let fileMessage as IMFileMessage:
print(fileMessage)
case let locationMessage as IMLocationMessage:
print(locationMessage)
case let recalledMessage as IMRecalledMessage:
print(recalledMessage)
case let customMessage as CustomMessage:
print("customMessage 是自定义消息类型")
default:
break
} else {
// 未来可能添加新的自定义消息类型,新版 SDK 也可能添加新的消息类型。
// 因此别忘了在默认分支中处理未知类型,例如提示用户升级客户端至最新版本。
print("收到未知类型消息")
}
default:
break
}
default:
break
}
}
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}");
}
}
jerry.onMessage = ({
Client client,
Conversation conversation,
Message message,
}) {
if (message.binaryContent != null) {
print('收到二进制消息:${message.binaryContent.toString()}');
} else if (message is TextMessage) {
print('收到文本类型消息:${message.text}');
} else if (message is LocationMessage) {
print('收到地理位置消息,坐标:${message.latitude},${message.longitude}');
} else if (message is FileMessage) {
if (message is ImageMessage) {
print('收到图像消息,图像 URL:${message.url}');
} else if (message is AudioMessage) {
print('收到音频消息,消息时长:${message.duration}');
} else if (message is VideoMessage) {
print('收到视频消息,消息时长:${message.duration}');
} else {
print('收到.txt/.doc/.md 等各种类型的普通文件消息,URL:${message.url}');
}
} else if (message is CustomMessage) {
// CustomMessage 是自定义的消息类型
print('收到自定义类型消息');
} else {
// 这里可以继续添加自定义类型的判断条件
print('收到未知消息类型');
if (message.stringContent != null) {
print('收到普通消息:${message.stringContent}');
}
}
};
do {
try conversation.update(attribution: ["name": "聪明的喵星人"], completion: { (result) in
switch result {
case .success:
break
case .failure(error: let error):
print(error)
}
})
} catch {
print(error)
}
// 获取自定义属性
var type = conversation.get('attr.type');
// 为 pinned 属性设置新的值
conversation.set('attr.pinned',false);
// 保存
conversation.save();
do {
let type = conversation.attributes?["type"] as? String
try conversation.update(attribution: ["attr.pinned": false]) { (result) in
switch result {
case .success:
break
case .failure(error: let error):
print(error)
}
}
} catch {
print(error)
}
do {
try conversation.refresh { (result) in
switch result {
case .success:
if let members = conversation.members {
print(members)
}
case .failure(error: let error):
print(error)
}
}
} catch {
print(error)
}
do {
let conversationQuery = tom.conversationQuery
try conversationQuery.getConversation(by: "551260efe4b01608686c3e0f") { (result) in
switch result {
case .success(value: let conversation):
print(conversation)
case .failure(error: let error):
print(error)
}
}
} catch {
print(error)
}
do {
let conversationQuery = tom.conversationQuery
try conversationQuery.where("attr.type", .equalTo("private"))
try conversationQuery.findConversations { (result) in
switch result {
case .success(value: let conversations):
print(conversations)
case .failure(error: let error):
print(error)
}
}
} catch {
print(error)
}
client.getCachedConversation(ID: "CONVERSATION_ID") { (result) in
switch result {
case .success(value: let conversation):
print(conversation)
case .failure(error: let error):
print(error)
}
}
client.removeCachedConversation(IDs: ["CONVERSATION_ID"]) { (result) in
switch result {
case .success:
break
case .failure(error: let error):
print(error)
}
}
// Switch for Local Storage of IM Client
do {
// Client init with Local Storage feature
let clientWithLocalStorage = try IMClient(ID: "CLIENT_ID")
// Client init without Local Storage feature
var options = IMClient.Options.default
options.remove(.usingLocalStorage)
let clientWithoutLocalStorage = try IMClient(ID: "CLIENT_ID", options: options)
} catch {
print(error)
}
// Preparation for Local Storage of IM Client
do {
try client.prepareLocalStorage { (result) in
switch result {
case .success:
break
case .failure(error: let error):
print(error)
}
}
} catch {
print(error)
}
// Get and Load Stored Conversations to Memory
do {
try client.getAndLoadStoredConversations(completion: { (result) in
switch result {
case .success(value: let conversations):
print(conversations)
case .failure(error: let error):
print(error)
}
})
} catch {
print(error)
}
// Delete Stored Conversations and Messages belong to them
do {
try client.deleteStoredConversationAndMessages(IDs: ["CONVERSATION_ID"], completion: { (result) in
switch result {
case .success:
break
case .failure(error: let error):
print(error)
}
})
} catch {
print(error)
}
do {
try conversation.queryMessage(limit: 10) { (result) in
switch result {
case .success(value: let messages):
print(messages)
case .failure(error: let error):
print(error)
}
}
} catch {
print(error)
}
do {
try conversation.queryMessage(limit: 10, type: IMTextMessage.messageType, completion: { (result) in
switch result {
case .success(value: let messages):
print(messages)
case .failure(error: let error):
print(error)
}
})
} catch {
print(error)
}
do {
try conversation.queryMessage(direction: .oldToNew, limit: 10, completion: { (result) in
switch result {
case .success(value: let messages):
print(messages)
case .failure(error: let error):
print(error)
}
})
} catch {
print(error)
}
// Switch for Local Storage of IM Client
do {
// Client init with Local Storage feature
let clientWithLocalStorage = try IMClient(ID: "CLIENT_ID")
// Client init without Local Storage feature
var options = IMClient.Options.default
options.remove(.usingLocalStorage)
let clientWithoutLocalStorage = try IMClient(ID: "CLIENT_ID", options: options)
} catch {
print(error)
}
// Message Query Policy
enum MessageQueryPolicy {
case `default`
case onlyNetwork
case onlyCache
case cacheThenNetwork
}
do {
try conversation.queryMessage(policy: .default, completion: { (result) in
switch result {
case .success(value: let messages):
print(messages)
case .failure(error: let error):
print(error)
}
})
} catch {
print(error)
}
// 暂不支持
用户退出与网络状态变化
用户退出即时通讯服务
如果产品层面设计了用户退出登录或者切换账号的接口,对于即时通讯服务来说,也是需要完全注销当前用户的登录状态的。在 SDK 中,开发者可以通过调用 LCIMClient 的 close 系列方法完成即时通讯服务的「退出」:
func client(_ client: IMClient, event: IMClientEvent) {
switch event {
case .sessionDidOpen:
break
case .sessionDidPause(error: let error):
print(error)
case .sessionDidResume:
break
case .sessionDidClose(error: let error):
print(error)
}
}
一,从简单的单聊、群聊、收发图文消息开始
本章导读
在很多产品里面,都存在让用户实时沟通的需求,例如:
根据功能需求的层次性和技术实现的难易程度不同,我们分为多篇文档来一步步地讲解如何利用即时通讯服务实现不同业务场景需求:
希望开发者最终顺利完成产品开发的同时,也对即时通讯服务的体系结构有一个清晰的了解,以便于产品的长期维护和定制化扩展。
阅读准备
在阅读本章之前,如果您还不太了解即时通讯服务的总体架构,建议先阅读即时通讯服务总览。 另外,如果您还没有下载对应开发环境(语言)的 SDK,请参考 SDK 安装指南完成 SDK 安装与初始化。
一对一单聊
在开始讨论聊天之前,我们需要介绍一下在即时通讯 SDK 中的
IMClient
对象:具体可以参考《即时通讯服务总览》中《clientId、用户和登录》一节的说明。
创建
IMClient
假设我们产品中有一个叫「Tom」的用户,首先我们在 SDK 中创建出一个与之对应的
IMClient
实例: (创建实例前请确保已经成功初始化了 SDK)注意这里一个
IMClient
实例就代表一个终端用户,我们需要把它全局保存起来,因为后续该用户在即时通讯上的所有操作都需要直接或者间接使用这个实例。登录即时通讯服务器
创建好了「Tom」这个用户对应的
IMClient
实例之后,我们接下来需要让该实例「登录」即时通讯服务器。 只有登录成功之后客户端才能开始与其他用户聊天,也才能接收到云端下发的各种事件通知。这里需要说明一点,有些 SDK (比如 C# SDK) 在创建
IMClient
实例的同时会自动进行登录,另一些 SDK (比如 iOS 和 Android SDK)则需要调用开发者手动执行open
方法进行登录:使用
_User
登录除了应用层指定
clientId
登录之外,我们也支持直接使用_User
对象来创建IMClient
并登录。这种方式能直接利用云端内置的用户鉴权系统而省掉登录签名操作,更方便地将存储和即时通讯这两个模块结合起来使用。示例代码如下:创建对话
Conversation
用户登录之后,要开始与其他人聊天,需要先创建一个「对话」。
对话(
Conversation
)是消息的载体,所有消息都是发送给对话,即时通讯服务端会把消息下发给所有在对话中的成员。Tom 完成了登录之后,就可以选择用户聊天了。现在他要给 Jerry 发送消息,所以需要先创建一个只有他们两个成员的
Conversation
:createConversation
这个接口会直接创建一个对话,并且该对话会被存储在_Conversation
表内,可以打开 云服务控制台 > 数据存储 > 结构化数据 查看数据。不同 SDK 提供的创建对话接口如下:虽然不同语言/平台接口声明有所不同,但是支持的参数是基本一致的。在创建一个对话的时候,我们主要可以指定:
members
:必要参数,包含对话的初始成员列表,请注意当前用户作为对话的创建者,是默认包含在成员里面的,所以members
数组中可以不包含当前用户的clientId
。name
:对话名字,可选参数,上面代码指定为了「Tom & Jerry」。attributes
:对话的自定义属性,可选。上面示例代码没有指定额外属性,开发者如果指定了额外属性的话,以后其他成员可以通过LCIMConversation
的接口获取到这些属性值。附加属性在_Conversation
表中被保存在attr
列中。unique
/isUnique
或者是LCIMConversationOptionUnique
:唯一对话标志位,可选。unique
标志为假,那么每次调用createConversation
接口都会创建一个新的对话。unique
时,SDK 默认值为真。对话类型的其他标志,可选参数,例如
transient
/isTransient
表示「聊天室」,tempConv
/tempConvTTL
和LCIMConversationOptionTemporary
用来创建「临时对话」等等。什么都不指定就表示创建普通对话,对于这些标志位的含义我们先不管,以后会有说明。创建对话之后,可以获取对话的内置属性,云端会为每一个对话生成一个全局唯一的 ID 属性:
Conversation.id
,它是其他用户查询对话时常用的匹配字段。发送消息
对话已经创建成功了,接下来 Tom 可以在这个对话中发出第一条文本消息了:
Conversation#send
接口实现的功能就是向对话中发送一条消息,同一对话中其他在线成员会立刻收到此消息。现在 Tom 发出了消息,那么接收者 Jerry 他要在界面上展示出来这一条新消息,该怎么来处理呢?
接收消息
在另一个设备上,我们用
Jerry
作为clientId
来创建一个IMClient
并登录即时通讯服务(与前两节 Tom 的处理流程一样):Jerry 作为消息的被动接收方,他不需要主动创建与 Tom 的对话,可能也无法知道 Tom 创建好的对话信息,Jerry 端需要通过设置即时通讯客户端事件的回调函数,才能获取到 Tom 那边操作的通知。
即时通讯客户端事件回调能处理多种服务端通知,这里我们先关注这里会出现的两个事件:
现在,我们看看具体应该如何响应服务端发过来的通知。Jerry 端会分别处理「加入对话」的事件通知和「新消息到达」的事件通知:
Jerry 端实现了上面两个事件通知函数之后,就顺利收到 Tom 发送的消息了。之后 Jerry 也可以回复消息给 Tom,而 Tom 端实现类似的接收流程,那么他们俩就可以开始愉快的聊天了。
我们现在可以回顾一下 Tom 和 Jerry 发送第一条消息的过程中,两方完整的处理时序:
在聊天过程中,接收方除了响应新消息到达通知之外,还需要响应多种对话成员变动通知,例如「新用户 XX 被 XX 邀请加入了对话」、「用户 XX 主动退出了对话」、「用户 XX 被管理员剔除出对话」,等等。 云端会实时下发这些事件通知给客户端,具体细节可以参考后续章节:成员变更的事件通知总结。
多人群聊
上面我们讨论了一对一单聊的实现流程,假设我们还需要实现一个「朋友群」的多人聊天,接下来我们就看看怎么完成这一功能。
从即时通讯云端来看,多人群聊与单聊的流程十分接近,主要差别在于对话内成员数量的多少。群聊对话支持在创建对话的时候一次性指定全部成员,也允许在创建之后通过邀请的方式来增加新的成员。
创建多人群聊对话
在 Tom 和 Jerry 的对话中(假设对话 ID 为
CONVERSATION_ID
,这只是一个示例,并不代表实际数据),后来 Tom 又希望把 Mary 也拉进来,他可以使用如下的办法:而 Jerry 端增加「新成员加入」的事件通知处理函数,就可以及时获知 Mary 被 Tom 邀请加入当前对话了:
其中
payload
参数包含如下内容:members
:字符串数组,被添加的用户clientId
列表invitedBy
:字符串,邀请者clientId
其中
AVIMOnInvitedEventArgs
参数包含如下内容:InvitedBy
:该操作的发起者JoinedMembers
:此次加入对话的包含的成员列表ConversationId
:被操作的对话这一流程的时序图如下:
而 Mary 端如果要能加入到 Tom 和 Jerry 的对话中来,Ta 可以参照 一对一单聊 中 Jerry 侧的做法监听
INVITED
事件,就可以自己被邀请到了一个对话当中。而 重新创建一个对话,并在创建的时候指定全部成员 的方式如下:
群发消息
多人群聊中一个成员发送的消息,会实时同步到所有其他在线成员,其处理流程与单聊中 Jerry 接收消息的过程是一样的。
例如,Tom 向好友群发送了一条欢迎消息:
而 Jerry 和 Mary 端都会有
Event.MESSAGE
事件触发,利用它来接收群聊消息,并更新产品 UI。将他人踢出对话
三个好友的群其乐融融不久,后来 Mary 出言不逊,惹恼了群主 Tom,Tom 直接把 Mary 踢出了对话群。Tom 端想要踢人,该怎么实现呢?
Tom 端执行了这段代码之后会触发如下流程:
这里出现了两个新的事件:当前用户被踢出对话
KICKED
(Mary 收到的),成员 XX 被踢出对话MEMBERS_LEFT
(Jerry 和 Tom 收到的)。其处理方式与邀请人的流程类似:用户主动加入对话
把 Mary 踢走之后,Tom 嫌人少不好玩,所以他找到了 William,说他和 Jerry 有一个很好玩的聊天群,并且把群的 ID(或名称)告知给了 William。William 也很想进入这个群看看他们究竟在聊什么,他自己主动加入了对话:
执行了这段代码之后会触发如下流程:
其他人则通过订阅
MEMBERS_JOINED
来接收 William 加入对话的通知:用户主动退出对话
随着 Tom 邀请进来的人越来越多,Jerry 觉得跟这些人都说不到一块去,他不想继续呆在这个对话里面了,所以选择自己主动退出对话,这时候可以调用
Conversation#quit
方法完成退群的操作:执行了这段代码 Jerry 就离开了这个聊天群,此后群里所有的事件 Jerry 都不会再知晓。各个成员接收到的事件通知流程如下:
而其他人需要通过订阅
MEMBERS_LEFT
来接收 Jerry 离开对话的事件通知:成员变更的事件通知总结
前面的时序图和代码针对成员变更的操作做了逐步的分析和阐述,为了确保开发者能够准确的使用事件通知,如下表格做了一个统一的归类和划分:
假设 Tom 和 Jerry 已经在对话内了:
MEMBERS_JOINED
MEMBERS_JOINED
INVITED
MEMBERS_LEFT
MEMBERS_LEFT
KICKED
MEMBERS_JOINED
MEMBERS_JOINED
MEMBERS_JOINED
MEMBERS_LEFT
MEMBERS_SELF_LEFT
MEMBERS_LEFT
注意,除了命名风格的不同,不同语言 SDK 在事件的实现上略有参差。 比如「本人主动退出」,Objc SDK 下本人收到的事件是
kickedByClientId
(主动退出视作自己剔除自己),其他 SDK 下本人收到的事件就是离开。文本之外的聊天消息
上面的示例都是发送文本消息,但是实际上可能图片、视频、位置等消息也是非常常见的消息格式,接下来我们就看看如何发送这些富媒体类型的消息。
即时通讯服务默认支持文本、文件、图像、音频、视频、位置、二进制等不同格式的消息,除了二进制消息之外,普通消息的收发接口都是字符串,但是文本消息和文件、图像、音视频消息有一点区别:
AVFile
接口,将图像的二进制文件上传到存储服务云端,再把图像下载的 URL 放入即时通讯消息结构体中,所以 图像消息不过是包含了图像下载链接的固定格式文本消息。图像等二进制数据不随即时通讯消息直接下发的主要原因在于,文件存储服务默认都是开通了 CDN 加速选项的,通过文件下载对于终端用户来说可以有更快的展现速度,同时对于开发者来说也能获得更低的存储成本。
默认消息类型
即时通讯服务内置了多种结构化消息用来满足常见的需求:
TextMessage
文本消息ImageMessage
图像消息AudioMessage
音频消息VideoMessage
视频消息FileMessage
普通文件消息(.txt/.doc/.md 等各种)LocationMessage
地理位置消息所有消息均派生自
LCIMMessage
,每种消息实例都具备如下属性:from
String
clientId
。cid
String
id
String
timestamp
Date
deliveredAt
Date
status
Symbol
MessageStatus
的成员之一:MessageStatus.NONE
(未知)MessageStatus.SENDING
(发送中)MessageStatus.SENT
(发送成功)MessageStatus.DELIVERED
(已送达)MessageStatus.FAILED
(失败)content
IMMessage.Content
String
和Data
两种格式。fromClientID
String
clientId
。currentClientID
String
clientId
。conversationID
String
ID
String
sentTimestamp
int64_t
deliveredTimestamp
int64_t
readTimestamp
int64_t
patchedTimestamp
int64_t
isAllMembersMentioned
Bool
mentionedMembers
[String]
isCurrentClientMentioned
Bool
Client
是否被 @。status
IMMessage.Status
none
(无状态)sending
(发送中)sent
(发送成功)delivered
(已被接收)read
(已被读)failed
(发送失败)ioType
IMMessage.IOType
in
(当前用户接收到的)out
(由当前用户发出的)content
NSString
clientId
NSString
clientId
。conversationId
NSString
messageId
NSString
sendTimestamp
int64_t
deliveredTimestamp
int64_t
status
AVIMMessageStatus
枚举LCIMMessageStatusNone
(未知)LCIMMessageStatusSending
(发送中)LCIMMessageStatusSent
(发送成功)LCIMMessageStatusDelivered
(被接收)LCIMMessageStatusFailed
(失败)ioType
LCIMMessageIOType
枚举LCIMMessageIOTypeIn
(发给当前用户)LCIMMessageIOTypeOut
(由当前用户发出)content
String
clientId
String
clientId
。conversationId
String
messageId
String
timestamp
long
receiptTimestamp
long
status
MessageStatus
枚举StatusNone
(未知)StatusSending
(发送中)StatusSent
(发送成功)StatusReceipt
(被接收)StatusFailed
(失败)ioType
MessageIOType
枚举TypeIn
(发给当前用户)TypeOut
(由当前用户发出)content
String
clientId
String
clientId
。conversationId
String
messageId
String
timestamp
long
receiptTimestamp
long
status
AVIMMessageStatus
枚举AVIMMessageStatusNone
(未知)AVIMMessageStatusSending
(发送中)AVIMMessageStatusSent
(发送成功)AVIMMessageStatusReceipt
(被接收)AVIMMessageStatusFailed
(失败)ioType
AVIMMessageIOType
枚举AVIMMessageIOTypeIn
(发给当前用户)AVIMMessageIOTypeOut
(由当前用户发出)我们为每一种富媒体消息定义了一个消息类型,即时通讯 SDK 自身使用的类型是负数(如下面列表所示),所有正数留给开发者自定义扩展类型使用,
0
作为「没有类型」被保留起来。-1
-2
-3
-4
-5
-6
图像消息
发送图像文件
即时通讯 SDK 支持直接通过二进制数据,或者本地图像文件的路径,来构造一个图像消息并发送到云端。其流程如下:
图解:
localStorage
/camera
,表示图像的来源可以是本地存储例如 iPhone 手机的媒体库或者直接调用相机 API 实时地拍照获取的照片。LCFile
是云服务提供的文件存储对象。对应的代码并没有时序图那样复杂,因为调用
send
接口的时候,SDK 会自动上传图像,不需要开发者再去关心这一步:发送图像链接
除了上述这种从本地直接发送图片文件的消息之外,在很多时候,用户可能从网络上或者别的应用中拷贝了一个图像的网络连接地址,当做一条图像消息发送到对话中,这种需求可以用如下代码来实现:
接收图像消息
图像消息的接收机制和之前是一样的,只需要修改一下接收消息的事件回调逻辑,根据消息类型来做不同的 UI 展现即可,例如:
发送音频消息/视频/文件
发送流程
对于图像、音频、视频和文件这四种类型的消息,SDK 均采取如下的发送流程:
如果文件是从 客户端 API 读取的数据流(Stream),步骤为:
LCFile
LCFile
的上传方法将文件上传到云端,并获取文件元信息(metaData
)LCFile
的objectId
、URL、文件元信息都封装在消息体内如果文件是 外部链接的 URL,则:
objectId
以发送音频消息为例,基本流程是:读取音频文件(或者录制音频)> 构建音频消息 > 消息发送。
与图像消息类似,音频消息也支持从 URL 构建:
发送地理位置消息
地理位置消息构建方式如下:
再谈接收消息
不管消息类型如何,JavaScript SDK 都是是通过
IMClient
上的Event.MESSAGE
事件回调来通知新消息的,应用层只需要在一个地方,统一对不同类型的消息使用不同方式来处理即可。Swift SDK 是通过实现
IMClientDelegate
代理来响应新消息到达通知的:Objective-C SDK 是通过实现
LCIMClientDelegate
代理来响应新消息到达通知的,并且,分别使用了两个方法来分别处理普通的LCIMMessage
消息和内建的多媒体消息LCIMTypedMessage
(包括应用层由此派生的自定义消息:Java/Android SDK 中定义了
LCIMMessageHandler
接口来通知应用层新消息到达事件发生,开发者通过调用LCIMMessageManager#registerDefaultMessageHandler
方法来注册自己的消息处理函数。LCIMMessageManager
提供了两个不同的方法来注册默认的消息处理函数,或特定类型的消息处理函数:消息处理函数需要在应用初始化时完成设置,理论上我们支持为每一种消息(包括应用层自定义的消息)分别注册不同的消息处理函数,并且也支持取消注册。
多次调用
LCIMMessageManager
的registerDefaultMessageHandler
,只有最后一次调用有效;而通过registerMessageHandler
注册的LCIMMessageHandler
,则是可以同存的。当客户端收到一条消息的时候,SDK 内部的处理流程为:
onMessage
函数。defaultHandler
处理。这样一来,在开发者为
AVIMTypedMessage
(及其子类)指定了专门的 handler,也指定了全局的defaultHandler
了的时候,如果发送端发送的是通用的LCIMMessage
消息,那么接收端就是LCIMMessageManager#registerDefaultMessageHandler()
中指定的 handler 被调用;如果发送的是LCIMTypedMessage
(及其子类)的消息,那么接收端就是LCIMMessageManager#registerMessageHandler()
中指定的 handler 被调用。C# SDK 也是通过类似
OnMessage
事件回调来通知新消息的:示例代码如下:
上面的代码示例中涉及到接受自定义消息。 我们将在《即时通讯开发指南》第二篇的《自定义消息类型》一节介绍。
扩展对话:支持自定义属性
「对话(
Conversation
)」是即时通讯的核心逻辑对象,它有一些内置的常用的属性,与控制台中_Conversation
表是一一对应的。默认提供的 内置 属性的对应关系如下:Conversation
属性名_Conversation
字段createdAt
createdAt
creator
c
id
objectId
lastDeliveredAt
lastMessage
lastMessageAt
lm
lastReadAt
members
m
muted
mutedMembers
mu
name
name
system
sys
transient
tr
unreadMessagesCount
updatedAt
updatedAt
IMConversation
属性名_Conversation
字段client
Client
ID
objectId
ID
clientID
Client
的ID
isUnique
unique
Unique Conversation
uniqueID
uniqueId
Unique Conversation
全局唯一的ID
name
name
creator
c
createdAt
createdAt
updatedAt
updatedAt
attributes
attr
members
m
isMuted
isOutdated
lastMessage
unreadMessageCount
isUnreadMessageContainMention
Client
memberInfoTable
LCIMConversation
属性名_Conversation
字段clientID
Client
的ID
conversationId
objectId
creator
c
createdAt
createdAt
updatedAt
updatedAt
lastMessage
lastMessageAt
lm
lastReadAt
lastDeliveredAt
unreadMessagesCount
unreadMessageContainMention
Client
name
name
members
m
attributes
attr
uniqueId
uniqueId
Unique Conversation
全局唯一的ID
unique
unique
Unique Conversation
transient
tr
system
sys
temporary
_Conversation
表中 )temporaryTTL
muted
imClient
LCIMClient
对象LCIMConversation
get 方法名_Conversation
字段getAttributes
attr
getConversationId
objectId
getCreatedAt
createdAt
getCreator
c
getLastDeliveredAt
getLastMessage
getLastMessageAt
lm
getLastReadAt
getMembers
m
getName
name
getTemporaryExpiredat
getUniqueId
uniqueId
Unique Conversation
全局唯一的ID
getUnreadMessagesCount
getUpdatedAt
updatedAt
isSystem
sys
isTemporary
_Conversation
表中 )isTransient
tr
isUnique
unique
Unique Conversation
unreadMessagesMentioned
Client
LCIMConversation
属性名_Conversation
字段CurrentClient
AVIMClient
对象ConversationId
objectId
Name
name
MemberIds
m
MuteMemberIds
mu
Creator
c
IsTransient
tr
IsSystem
sys
IsUnique
unique
IsTemporary
_Conversation
表中 )CreatedAt
createdAt
UpdatedAt
updatedAt
LastMessageAt
lm
Conversation
属性名_Conversation
字段attributes
attr
client
Client
对象createdAt
createdAt
creator
c
id
objectId
isMuted
isUnique
unique
Unique Conversation
lastDeliveredAt
lastMessage
lastReadAt
members
m
name
name
uniqueID
uniqueId
Unique Conversation
的唯一 IDunreadMessagesCount
unreadMessagesMentioned
Client
updatedAt
updatedAt
不过,我们不建议直接对
_Conversation
进行写操作,因为:_Conversation
表,不会更新即时通讯服务器的缓存,这就带来了缓存不一致问题。_Conversation
表的情况下,即时通讯服务器不会下发相应的事件通知客户端,客户端自然也就无从响应。_Conversation
表不会触发这些 hook。如有管理需求,我们推荐调用专门的即时通讯 REST API 接口。
另外,我们可以通过「自定义属性」来在「对话」中保存更多业务层数据。
创建自定义属性
在最开始介绍 创建单聊对话 的时候,我们提到过
IMClient#createConversation
接口支持附加自定义属性,现在我们就来演示一下如何使用自定义属性。假如在创建对话的时候,我们需要添加两个额外的属性值对
{ "type": "private", "pinned": true }
,那么在调用IMClient#createConversation
方法时可以把附加属性传进去:自定义属性在 SDK 级别是对所有成员可见的。我们也支持通过自定义属性来查询对话,请参见 使用复杂条件来查询对话。
修改和使用属性
在
Conversation
对象中,系统默认提供的属性,例如对话的名字(name
),如果业务层没有限制的话,所有成员都是可以修改的,示例代码如下:而
Conversation
对象中自定义的属性,即时通讯服务也是允许对话内其他成员来读取、使用和修改的,示例代码如下:对自定义属性名的说明
在
IMClient#createConversation
接口中指定的自定义属性,会被存入_Conversation
表的attr
字段,所以在之后对这些属性进行读取或修改的时候,属性名需要指定完整的路径,例如上面的attr.type
,这一点需要特别注意。对话属性同步
对话的名字以及应用层附加的其他属性,一般都是需要全员共享的,一旦有人对这些数据进行了修改,那么就需要及时通知到全部成员。在前一个例子中,有一个用户对话名称改为了「聪明的喵星人」,那其他成员怎么能知道这件事情呢?
即时通讯云端提供了实时同步的通知机制,会把单个用户对「对话」的修改同步下发到所有在线成员(对于非在线的成员,他们下次登录上线之后,自然会拉取到最新的完整的对话数据)。对话属性更新的通知事件声明如下:
使用提示:
应用层在该事件的响应函数中,可以获知当前什么属性被修改了,也可以直接从 SDK 的
Conversation
实例中获取最新的合并之后的属性值,然后依据需要来更新产品 UI。获取群内成员列表
群内成员列表是作为对话的属性持久化保存在云端的,所以要获取一个
Conversation
对象的成员列表,我们可以在调用这个对象的更新方法之后,直接获取成员属性即可。使用提示:
成员列表是对 普通对话 而言的,对于像「聊天室」「系统对话」这样的特殊对话,并不存在「成员列表」属性。
使用复杂条件来查询对话
除了在事件通知接口中获得
Conversation
实例之外,开发者也可以根据不同的属性和条件来查询Conversation
对象。例如有些产品允许终端用户根据名字或地理位置来匹配感兴趣聊天室,也有些业务场景允许查询成员列表中包含特定用户的所有对话,这些都可以通过对话查询的接口实现。根据 ID 查询
ID 对应就是
_Conversation
表中的objectId
的字段值,这是一种最简单也最高效的查询(因为云端会对 ID 建立索引):基础的条件查询
即时通讯 SDK 提供了丰富的条件查询方式,可以满足各种复杂的业务需求。
我们首先从最简单的
equalTo
开始。例如查询所有自定义属性type
(字符串类型)为private
的对话,需要如下代码:熟悉数据存储服务的开发者可以更容易理解对话的查询构建,因为对话查询和数据存储服务的对象查询在接口上是十分接近的:
find
获取当前结果页数据count
获取结果数first
获取第一个结果skip
和limit
对结果进行分页与
equalTo
类似,针对Number
和Date
类型的属性还可以使用大于、大于等于、小于、小于等于等,详见下表:ConversationQuery
方法equalTo
notEqualTo
greaterThan
greaterThanOrEqualTo
lessThan
lessThanOrEqualTo
IMConversationQuery
的Constraint
equalTo
notEqualTo
greaterThan
greaterThanOrEqualTo
lessThan
lessThanOrEqualTo
LCIMConversationQuery
方法equalTo
notEqualTo
greaterThan
greaterThanOrEqualTo
lessThan
lessThanOrEqualTo
LCIMConversationsQuery
方法whereEqualTo
whereNotEqualsTo
whereGreaterThan
whereGreaterThanOrEqualsTo
whereLessThan
whereLessThanOrEqualsTo
LCIMConversationQuery
方法WhereEqualTo
WhereNotEqualsTo
WhereGreaterThan
WhereGreaterThanOrEqualsTo
WhereLessThan
WhereLessThanOrEqualsTo
使用注意:默认查询条件
为了防止用户无意间拉取到所有的对话数据,在客户端不指定任何
where
条件的时候,ConversationQuery
会默认查询包含当前用户的对话。如果客户端添加了任一where
条件,那么ConversationQuery
会忽略默认条件而严格按照指定的条件来查询。如果客户端要查询包含某一个clientId
的对话,那么使用下面的 数组查询 语法对m
属性列和clientId
值进行查询即可,不会和默认查询条件冲突。正则匹配查询
ConversationsQuery
也支持在查询条件中使用正则表达式来匹配数据。比如要查询所有language
是中文的对话:字符串查询
前缀查询 类似于 SQL 的
LIKE 'keyword%'
条件。例如查询名字以「教育」开头的对话:包含查询 类似于 SQL 的
LIKE '%keyword%'
条件。 例如查询名字中包含「教育」的对话:不包含查询 则可以使用 正则匹配查询 来实现。 例如查询名字中不包含「教育」的对话:
数组查询
可以使用
containsAll
、containedIn
、notContainedIn
来对数组进行查询。例如查询成员中包含「Tom」的对话:空值查询
空值查询是指查询相关列是否为空值的方法,例如要查询
lm
列为空值的对话:反过来,如果要查询
lm
列不为空的对话,则替换为如下条件即可:组合查询
查询年龄小于 18 岁,并且关键字包含「教育」的对话:
另外一种组合的方式是,两个查询采用
or
或者and
的方式构建一个新的查询。查询年龄小于 18 或者关键字包含「教育」的对话:
结果排序
可以指定查询结果按照部分属性值的升序或降序来返回。例如:
不带成员信息的精简模式
普通对话最多可以容纳 500 个成员,在有些业务逻辑不需要对话的成员列表的情况下,可以使用「精简模式」进行查询,这样返回结果中不会包含成员列表(
members
字段为空数组),有助于提升应用的性能同时减少流量消耗。让查询结果附带一条最新消息
对于一个聊天应用,一个典型的需求是在对话的列表界面显示最后一条消息,默认情况下,针对对话的查询结果是不带最后一条消息的,需要单独打开相关选项:
需要注意的是,这个选项真正的意义是「刷新对话的最后一条消息」,这意味着由于 SDK 缓存机制的存在,将这个选项设置为
false
查询得到的对话也还是有可能会存在最后一条消息的。查询缓存
JavaScript SDK 会对按照对话 ID 对对话进行内存字典缓存,但不会进行持久化的缓存。
Swift SDK 提供了会话的缓存功能,包括内存缓存和持久化缓存。
会话的内存缓存:
会话的持久化缓存。注意,使用「查询持久存储会话」以及「删除持久存储会话」的功能前,需调用
prepareLocalStorage
方法且回调结果为成功;prepareLocalStorage
方法只需要调用一次(返回成功),一般在IMClient.init()
和IMClient.open()
之间调用:注意:
通常,将查询结果缓存到磁盘上是一种行之有效的方法,这样就算设备离线,应用刚刚打开,网络请求尚未完成时,数据也能显示出来。或者为了节省用户流量,在应用打开的第一次查询走网络,之后的查询可优先走本地缓存。
值得注意的是,默认的策略是先走本地缓存的再走网络的,缓存时间是一小时。
LCIMConversationQuery
中有如下方法:有时你希望先走网络查询,发生网络错误的时候,再从本地查询,可以这样:
各种查询缓存策略的行为可以参考 存储指南 · LCQuery 缓存查询 一节。
通常,将查询结果缓存到磁盘上是一种行之有效的方法,这样就算设备离线,应用刚刚打开,网络请求尚未完成时,数据也能显示出来。或者为了节省用户流量,在应用打开的第一次查询走网络,之后的查询可优先走本地缓存。
值得注意的是,默认的策略是先走本地缓存的再走网络的,缓存时间是一小时。
LCIMConversationsQuery
中有如下方法:有时你希望先走网络查询,发生网络错误的时候,再从本地查询,可以这样:
.NET SDK 暂不支持缓存功能。
Flutter SDK 暂不支持缓存功能。
性能优化建议
Conversation
数据是存储在云端数据库中的,与存储服务中的对象查询类似,我们需要尽可能利用索引来提升查询效率,这里有一些优化查询的建议:Conversation
的objectId
、updatedAt
、createdAt
等属性上是默认建了索引的,所以通过这些条件来查询会比较快。skip
搭配limit
的方式可以翻页,但是在结果集较大的时候不建议使用,因为数据库端计算翻页距离是一个非常低效的操作,取而代之的是尽量通过updatedAt
或lastMessageAt
等属性来限定返回结果集大小,并以此进行翻页。m
列的contains
查询来查找包含某人的对话时,也尽量使用默认的limit
大小 10,再配合updatedAt
或者lastMessageAt
来做条件约束,性能会提升较大。聊天记录查询
消息记录默认会在云端保存 180 天, 开发者可以通过额外付费来延长这一期限(有需要的用户请提工单联系技术支持),也可以通过 REST API 将聊天记录同步到自己的服务器上。
SDK 提供了多种方式来拉取历史记录,iOS 和 Android SDK 还提供了内置的消息缓存机制,以减少客户端对云端消息记录的查询次数,并且在设备离线情况下,也能展示出部分数据保障产品体验不会中断。
从新到旧获取对话的消息记录
在终端用户进入一个对话的时候,最常见的需求就是由新到旧、以翻页的方式拉取并展示历史消息,这可以通过如下代码实现:
queryMessage
接口也是支持翻页的。 即时通讯云端通过消息的messageId
和发送时间戳来唯一定位一条消息,因此要从某条消息起拉取后续的 N 条记录,只需要指定起始消息的messageId
和发送时间戳作为锚定就可以了,示例代码如下:按照消息类型获取
除了按照时间先后顺序拉取历史消息之外,即时通讯服务云端也支持按照消息的类型来拉取历史消息,这一功能可能对某些产品来说非常有用,例如我们需要展现某一个聊天群组里面所有的图像。
queryMessage
接口还支持指定特殊的消息类型,其示例代码如下:如要获取更多图像消息,可以效仿前一章节中的示例代码,继续翻页查询即可。
从旧到新反向获取历史消息
即时通讯云端支持的历史消息查询方式是非常多的,除了上面列举的两个最常见需求之外,还可以支持按照由旧到新的方向进行查询。如下代码演示从对话创建的时间点开始,从前往后查询消息记录:
这种情况下要实现翻页,接口会稍微复杂一点,请继续阅读下一节。
从某一时间戳往某一方向查询
即时通讯服务云端支持以某一条消息的 ID 和时间戳为准,往一个方向查:
这样我们就可以在不同方向上实现消息翻页了。
获取指定区间内的消息
除了顺序查找之外,我们也支持获取特定时间区间内的消息。假设已知 2 条消息,这 2 条消息以较早的一条为起始点,而较晚的一条为终点,这个区间内产生的消息可以用如下方式查询:
注意:每次查询也有 100 条限制,如果想要查询区间内所有产生的消息,替换区间起始点的参数即可。
客户端消息缓存
iOS 和 Android SDK 针对移动设备的特殊性,实现了客户端消息的缓存。开发者无需进行特殊设置,只要接收或者查询到的新消息,默认都会进入被缓存起来,该机制给开发者提供了如下便利:
客户端缓存是默认开启的,如果开发者有特殊的需求,SDK 也支持关闭缓存功能。例如有些产品在应用层进行了统一的消息缓存,无需 SDK 层再进行冗余存储,可以通过如下接口来关闭消息缓存:
用户退出与网络状态变化
用户退出即时通讯服务
如果产品层面设计了用户退出登录或者切换账号的接口,对于即时通讯服务来说,也是需要完全注销当前用户的登录状态的。在 SDK 中,开发者可以通过调用
LCIMClient
的close
系列方法完成即时通讯服务的「退出」:调用该接口之后,客户端就与即时通讯服务云端断开连接了,从云端查询前一
clientId
的状态,会显示「离线」状态。客户端事件与网络状态响应
即时通讯服务与终端设备的网络连接状态休戚相关,如果网络中断,那么所有的消息收发和对话操作都会失败,这时候产品层面需要在 UI 上给予用户足够的提示,以免影响使用体验。
我们的 SDK 内部和即时通讯云端会维持一个「心跳」机制,能够及时感知到客户端的网络变化,同时将底层网络变化事件通知到应用层。具体来讲,当网络连接出现中断、恢复等状态变化时,SDK 会派发以下事件:
DISCONNECT
:与服务端连接断开,此时聊天服务不可用。OFFLINE
:网络不可用。ONLINE
:网络恢复。SCHEDULE
:计划在一段时间后尝试重连,此时聊天服务仍不可用。RETRY
:正在重连。RECONNECT
:与服务端连接恢复,此时聊天服务可用。在
IMClientDelegate
的IMClientDelegate.client(_:event:)
函数里,可以接收到如下所示的事件通知:sessionDidOpen
:连接自动恢复了sessionDidPause
:连接断开了;该事件被触发的常见场景:网络无法访问、应用进入后台sessionDidResume
:正在重新建立连接sessionDidClose
:连接关闭,且不会自动重连;该事件被触发的常见场景:单设备登陆冲突、后台主动把该 client 下线在
LCIMClientDelegate
里,可以接收到如下所示的事件通知:imClientResumed
:连接自动恢复了imClientPaused
:连接断开了;该事件被触发的常见场景:网络无法访问、应用进入后台imClientResuming
:正在重新建立连接imClientClosed
:连接关闭,且不会自动重连;该事件被触发的常见场景:单设备登陆冲突、后台主动把该 client 下线LCIMClientEventHandler
上会有如下事件通知:onConnectionPaused()
指网络连接断开事件发生,此时聊天服务不可用。onConnectionResume()
指网络连接恢复正常,此时聊天服务变得可用。onClientOffline()
指单点登录被踢下线的事件。LCIMClient
上会有如下事件通知:OnPaused
指网络连接断开事件发生,此时聊天服务不可用OnResume
指网络连接恢复正常,此时聊天服务变得可用OnClose
指连接关闭,且不会自动重连Client
有如下事件通知:onOpened
用户登录即时通信的服务。onClosed
用户退出即时通信服务。onResuming
指网络正在尝试重连,此时聊天服务不可用。onDisconnected
指网络连接断开事件发生,此时聊天服务不可用。其他开发建议
如何根据活跃度来展示对话列表
不管是当前用户参与的「对话」列表,还是全局热门的开放聊天室列表展示出来了,我们下一步要考虑的就是如何把最活跃的对话展示在前面,这里我们把「活跃」定义为最近有新消息发出来。我们希望有最新消息的对话可以展示在对话列表的最前面,甚至可以把最新的那条消息也附带显示出来,这时候该怎么实现呢?
我们专门为
LCIMConversation
增加了一个动态的属性lastMessageAt
(对应_Conversation
表里的lm
字段),记录了对话中最后一条消息到达即时通讯云端的时间戳,这一数字是服务器端的时间(精确到秒),所以不用担心客户端时间对结果造成影响。另外,LCIMConversation
还提供了一个方法可以直接获取最新的一条消息。这样在界面展现的时候,开发者就可以自己决定展示内容与顺序了。自动重连
如果开发者没有明确调用退出登录的接口,但是客户端网络存在抖动或者切换(对于移动网络来说,这是比较常见的情况),我们 iOS 和 Android SDK 默认内置了断线重连的功能,会在网络恢复的时候自动建立连接,此时
IMClient
的网络状态可以通过底层的网络状态响应接口得到回调。更多「对话」类型
即时通讯服务提供的功能就是让一个客户端与其他客户端进行在线的消息互发,对应不同的使用场景,除了前两章节介绍的 一对一单聊 和 多人群聊 之外,我们也支持其他形式的「对话」模型:
开放聊天室,例如直播中的弹幕聊天室,它与普通的「多人群聊」的主要差别是允许的成员人数以及消息到达的保证程度不一样。有兴趣的开发者可以参考《即时通讯开发指南》第三篇的《玩转直播聊天室》一节。
临时对话,例如客服系统中用户和客服人员之间建立的临时通道,它与普通的「一对一单聊」的主要差别在于对话总是临时创建并且不会长期存在,在提升实现便利性的同时,还能降低服务使用成本(能有效减少存储空间方面的花费)。有兴趣的开发者可以参考《即时通讯开发指南》第三篇的《使用临时对话》一节。
系统对话,例如在微信里面常见的公众号/服务号,系统全局的广播账号,与普通「多人群聊」的主要差别,在于「服务号」是以订阅的形式加入的,也没有成员限制,并且订阅用户和服务号的消息交互是一对一的,一个用户的上行消息不会群发给其他订阅用户。有兴趣的开发者可以参考《即时通讯开发指南》第四篇《「系统对话」的使用》一节。
进一步阅读
《即时通讯开发指南》第二篇消息收发的更多方式,离线推送与消息同步,多设备登录