有赞 APP IM SDK 组件架构设计
本文主要以 Android 客户端为例,记录了有赞旗下 App 中使用自研 IM SDK 设计思路,由有赞移动开发组 IM SDK 团队共同讨论完成。
背景
在有赞产品中,存在大量需要交易双方沟通交流的场景,比如,客户咨询商家产品信息,售前售后简单的答疑和维权等。另外,有赞业务还存在一些特殊的复杂场景,如供应商,分销商,客户三方之间需要同步沟通,会同时存在多种沟通角色。此时需要较为完善的即时通信(IM)解决方案,但是由于有赞针对不同的商户和使用场景有多个 APP,APP 自行实现 IM 功能代价较大,且维护起来人力分散,于是,IM SDK 项目便应运而生了,APP 通过接入此 SDK,可以快速实现 IM 基本功能。
设计目标
- IM 主流程稳定可用:消息传输具有高可靠性。
- UI 组件直接集成进入 SDK,并支持可定制化。
- 富媒体发送集成进入 SDK,并可按需定制需要的富媒体类型。
- 实现消息传输层 SDK,与带有 UI 的 SDK 的功能分离,业务调用方既可以使用消息传输 SDK,处理消息,然后自行处理 UI,也可以使用带有 UI 组件的 SDK,一步实现较为完备的 IM 功能。
整体结构
下图中简要描述了有赞客户端中 IM 系统的基本结构
- 消息通道层:维护 Socket 长连接作为消息通道,消息收发流程主要在这一层中完成。
- 持久化层:主要将消息存入数据库中,富媒体文件存入文件缓存中,方便第二次展示消息时候,从本地加载,而不是网络层获取。
- 逻辑处理层:完成各种消息相关的逻辑处理,如排序,富媒体文件的预处理等。
- UI 显示层:将数据在 UI 上进行呈现。
设计要点
此章节中主要描述了,IM SDK 设计中一些重要流程。Socket 长连接的创建与维护
IM SDK 所有数据收发流程,均通过 Socket 长连接完成,如何维护一个稳定 Socket 通道,是 IM 系统是否稳定的重要一环。 下面描述下 Socket 通道几个重要的流程-
创建流程(连接)
如图所示,当 IM SDK 初始化后,业务调用连接请求接口,会开始连接的创建过程,创建成功后,会完成鉴权操作,当创建和鉴权都完成后,会开启消息收发线程,为了维持长连接,会有心跳机制,特别的,会开启一个心跳轮询线程。 -
心跳
心跳机制,是 IM 系统设计中的常见概念,简单的解释就是每隔若干时间发送一个固定信息给服务端,服务端收到后及时回复一个固定信息,如果服务端若干时间内没有收到客户端心跳信息则视客户端断开,同理如果客户端若干时间没有收到服务端心跳回值则视服务端断开。
当长连接创建成功后,会开启一个轮询线程,每隔一段时间发送心跳消息给服务器端,以维持长连接。 -
重连流程
重连被触发时,如果该次连接成功,退出重连。反之重连失败后,会判断当前重连的次数是否超过预期值(这里设为 6 次),并对重连次数计数,如果超过就会退出重连,反之休眠预设的时间后再次进行重连操作。
重连触发条件分为三种: - 主动连接不成功(主动连接 Socket,如果连接失败,会触发重连机制 )
- 网络被主动断开(正常建立连接,操作过程中,网络被断开,通过系统广播触发重连)
- 服务器没响应,心跳没回值(服务端心跳预设时间内没回值,客户端认为服务端已经断开,触发重连)
-
网络状态判断
TCP API 并没有提供一个可靠的方法判断当前长连接通道状态,isConnected()和 isClosed() 仅仅告诉你当前的 Socket 状态,不是是长连接断开是一回事。
isConnected()告诉你是否 Socket 与 Romote host 保持连接,isClosed() 告诉你是否 Socket 被关闭。
假如你判断长连接通道是否被关闭,只能通过和流操作相关的以下方法:- read() return -1
- readLine() return null
- readXXX() throw EOPException for any other XXX
- write 将抛出 IOException: Broken pipe(通道被关闭)
所以 SDK 封装 isConnected()方法的时候,是根据这几种情况综合判断当前的通道状态,而不是仅仅通过 Socket.isConnected()或者 Socket.isClosed()。
消息发送流程
 消息发送流程主要有两大类,一类是 IM 相关数据的请求,例如:历史消息列表,会话列表等,另一类是 IM 消息的发送,主要是文字消息。(富媒体消息发送,会将富媒体文件先上传服务器后,拿到文件 URL, 通过文字消息,将此 URL 发给接收方,接收方下载后进行 UI 展示)。 此两类消息发送,均使用上图的流程进行发送,可通过发送回调感知请求的结果。如图所示,消息发送流程,需要先封装消息请求,在通过发送队列发送至服务器,发送前,在将请求 id 和对应回调存入本地 Map 数据结构中。
if (requestCallBack != null) {
mCallBackMap.put(requestId, requestCallBack);
}
之后接收服务器推送消息(此消息带有发送请求时的请求 id),在本地的 Map 数据找到请求 id 对应的回调,然后通过回调返回服务器推送过来的数据。
请求可以通过泛型指定返回值类型,SDK 中会自行解析服务器数据返回的数据,直接返回给业务调用方 model 对象,方便使用。(目前支持 json 格式的数据解析)
private void IMResponseOnSuccess(String requestid, String response) {
if (mCallBackMap != null) {
IMCallBack callBack = mCallBackMap.get(requestid);
if (callBack == null) {
return;
}
if (callBack instanceof JsonResultCallback) {
final JsonResultCallback resultCallback = (JsonResultCallback) callBack;
if (resultCallback.mType == String.class) {
callBack.onResponse(response);
} else {
Object object = new Gson().fromJson(response, resultCallback.mType);
callBack.onResponse(object);
}
removeCallBack(requestid);
}
}
}
如下的示例中,展示了一个获取会话列表的请求,可以看出目前的请求封装,和一些第三方的的网络库类似,使用起来较为方便。
RequestApi requestApi = new RequestApi(IMConstant.REQ_TYPE_GET_CONVERSATION_LIST, EnumsManager.IMType.IM_TYPE_WSC.getRequestChannel());
requestApi.addRequestParams("limit", 100);
requestApi.addRequestParams("offset", 0);
IMEngine.getInstance().request(requestApi, new JsonResultCallback<List<ConversationEntity>>() {
@Override
public void onResponse(List<ConversationEntity> response) {
mSwipeRefreshLayout.setRefreshing(false);
mAdapter.mDataset.clear();
mAdapter.mDataset.addAll(response);
mAdapter.notifyDataSetChanged();
}
@Override
public void onError(int statusCode) {
//do something
}
});
可以看出,该请求直接返回了一个会话类型的 List 集合,业务方可直接使用。
消息接收流程
消息的监听流程主要使用了一个全局监听的方式来进行,需要先注册监听器,监听器中有默认的回调。
public interface IMListener {
/**
* 连接成功
*/
void connectSuccess();
/**
* 连接失败
*/
void connectFailure(EnumsManager.DisconnectType type);
/**
* 鉴权成功
*/
void authorSuccess();
/**
* 鉴权失败
*/
void authorFailure();
/**
* 接收数据成功
*/
void receiveSuccess(int reqType, String msgId, String requestChannel, String message, int statusCode);
/**
* 接收数据失败
*/
void receiveError(int reqType, String msgId, String requestChannel, int statusCode);
}
该监听器中可以接收如下类型的消息:
- Socket 连接状态的返回结果。
- 鉴权状态的返回结果,(鉴权流程因有赞业务需要)。
- 接收的 IM 消息,或者其他类型的返回消息。可根据消息类型进行后续的分发处理
业务如需使用此全局监听器,需要自行实现此接口,并在业务初始化时,注册此监听器即可。SDK 中会根据注册的监听器,在读取到服务器推送消息后,直接通过监听器到回调进行分发。
private void distributeData(IMEntity imEntity) {
if (mIMListener != null && imEntity != null) {
// 省略部分逻辑代码
……
if (status == Response.SUCCESS) {
switch (responseModel.reqType) {
case IMConstant.REQ_TYPE_AUTH: // 鉴权成功
mIMListener.authorSuccess();
return;
case IMConstant.REQ_TYPE_OFFLINE: // 服务端踢客户端下线
mIMListener.connectFailure(EnumsManager.DisconnectType.SERVER);
break;
case IMConstant.REQ_TYPE_HEARTBEAT: // 心跳成功
case IMConstant.REQ_TYPE_RECEIVER_MSG: // 收到回调消息
handleMessageID(responseModel.body);
break;
default:
break;
}
mIMListener.receiveSuccess(responseModel.reqType, msgId, responseModel
.requestChannel, responseModel.body, 0);
} else {
mIMListener.receiveError(responseModel.reqType, msgId, responseModel
.requestChannel, status);
}
}
}
部分接收消息,如心跳,多端登录时被踢下线通知等,sdk 内部会自行处理,业务基本无感知。
可定制化的 UI
随着公司规模的扩大与业务线的快速迭代,可能新的业务也需要 IM 这个功能,众所周知,IM UI 功能的嵌入会占据大量的开发与调试时间, 为了解决这个痛点,决定将 IM UI 部分抽成一个 Library,实现可定制与单独维护,做到真正的敏捷开发与快速迭代。UIKit 设计
 IM UIKit 暴露相应的 api 接口,业务方注入相应的功能定制项,针对 UI 的点击回调通过 EventBus 总线 post 分发,减少了业务方与 UIKit 的耦合,底层业务方通过 MVP 模式对 View 与 Model 进行解耦。定制项一般通过如下几种方式:- XML(定制业务信息,资源信息,显示条数,各个业务功能开关等)
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="limit">
<!--每屏展示的条数-->
<item name="swiplimit">5</item>
......
</style>
......
......
<style name="itembox">
<item name="showvoice">true</item>
......
......
<item name="more" show="true">
<more>
<icon style="mipmap">im_plus_image</icon>
<itemname>测试</itemname>
<callback>false</callback>
</more>
......
......
<more>
<icon style="mipmap">ic_launcher</icon>
<itemname>测试</itemname>
<callback>true</callback>
</more>
</item>
......
......
</style>
</resources>
- Style(定制 UI 背景,气泡颜色,字体大小等)
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--im 聊天背景-->
<style name="imui_background">
<item name="android:background">@android:color/holo_red_dark</item>
</style>
......
......
<!--气泡背景-->
<style name="bubble_background">
<item name="android:background">@mipmap/bubble_right_green</item>
</style>
<!--背景和和字段颜色定制-->
<style name="bg_and_textcolor" parent="bubble_background">
<item name="android:textColor">@android:color/holo_red_dark</item>
</style>
......
......
</resources>
- Model 定制(传入预设的定制 Model 模板填入相应参数,UIKit 里面做相应解析)
public class Entity {
public String action1;
public String action2;
public String aciton3;
......
}
UIKit 支持的富媒体类型
除了文字消息之外,现在主流的 IM 系统中也支持各种富媒体发送,在有赞 IM SDK UIKit 中,目前也支持几种富媒体发送。 以下是发送流程图和两类常见富媒体消息简介。- 语音消息
语音消息,除了使用常见的录制和解码播放的技术之外。还利用了AudioManager
中requestAudioFocus
,abandonAudioFocus
相关方法,实现了录制和播放语音消息,如果有第三方播放音乐,会自动暂停,录制和播放语音消息结束后,声音会自动播放。 - 图片消息
图片消息,通过七牛服务器设置了缩略图,接收方收到消息后,会先下载缩略图,当用户再点击进入图片详情页时,会下载大图,Andorid 客户端使用 Picasso 加载库加载图片,并做本地缓存。
UI 中聊天会话数据加载策略
参考业界主流的 IM 系统方案,用户聊天时,需要将已经发送和接收到的聊天信息保存到本地。而不是每次都拉取历史数据。以达到节约流量和无网络状态下也查看数据的效果。为此 IM SDK 持久化层的数据库中,也实现了简单存储加载机制,下面描述典型的数据加载场景。-
IM 会话首次请求数据流程
-
IM 下拉获取历史数据流程
-
IM 单条消息发送持久化方案
-
IM 单条数据重发流程
设计不足之处
-
消息回执
当前的设计方案中,没有消息回执的机制,也就是说接受方收到消息后,不会返回服务器收到消息的通知,服务器无法判断消息是否推送成功,这样在突然断网,网络模式切换,或者弱网环境下,会影响消息的到达率。
一种可行的设计方式是,发送方增加已送到和未送达的状态,接收方收到消息后,给服务器返回已收到消息的通知,服务器再推送给发送方该状态,如果没有收到接收方回执,服务器可尝试重新推送。发送方接受到接收方的收到回执后,更新发送状态已发送,如果未收到,则显示未送达。为了防止接收方回执丢失,接收方接收消息时候,可维护本地去重队列。 -
本地请求超时的判断
本地发起的请求,没有用定时器,完全依赖服务器返回或者出现 Socket 通道异常后上抛的通知作为超时判断,部分场景可能覆盖不到,需要对请求增加固定的超时处理机制,固定时候未收到请求,即认为超时。
未来发展方向
- 增加对群聊的支持。
- 弥补设计中的不足之处,提升消息的到达率和系统稳定性。
- 提供有赞 App 更方便的接入方式,提供封装性更好的接口。