有赞APP IM SDK 组件架构设计
yanmingwu
8年前
<p>本文主要以Android客户端为例,记录了有赞旗下 App 中使用自研 IM SDK 设计思路,由有赞移动开发组 IM SDK 团队共同讨论完成。</p> <h2>背景</h2> <p>在有赞产品中,存在大量需要交易双方沟通交流的场景,比如,客户咨询商家产品信息,售前售后简单的答疑和维权等。另外,有赞业务还存在一些特殊的复杂场景,如供应商,分销商,客户三方之间需要同步沟通,会同时存在多种沟通角色。此时需要较为完善的即时通信(IM)解决方案,但是由于有赞针对不同的商户和使用场景有多个APP,APP自行实现IM功能代价较大,且维护起来人力分散,于是,IM SDK 项目便应运而生了,APP 通过接入此SDK,可以快速实现IM基本功能。</p> <h2>设计目标</h2> <ul> <li>IM 主流程稳定可用:消息传输具有高可靠性。</li> <li>UI 组件直接集成进入SDK,并支持可定制化。</li> <li>富媒体发送集成进入SDK,并可按需定制需要的富媒体类型。</li> <li>实现消息传输层SDK,与带有UI的SDK的功能分离,业务调用方既可以使用消息传输SDK,处理消息,然后自行处理UI,也可以使用带有UI组件的SDK,一步实现较为完备的IM功能。</li> </ul> <h2>整体结构</h2> <p>下图中简要描述了有赞客户端中IM系统的基本结构<img alt="有赞APP IM SDK 组件架构设计" src="https://simg.open-open.com/show/7f4520709193a4614f4133f9c8c61373.png" width="811" height="864"></p> <ul> <li>消息通道层:维护Socket长连接作为消息通道,消息收发流程主要在这一层中完成。</li> <li>持久化层:主要将消息存入数据库中,富媒体文件存入文件缓存中,方便第二次展示消息时候,从本地加载,而不是网络层获取。</li> <li>逻辑处理层:完成各种消息相关的逻辑处理,如排序,富媒体文件的预处理等。</li> <li>UI显示层:将数据在UI上进行呈现。</li> </ul> <p>设计要点</p> <p>此章节中主要描述了,IM SDK设计中一些重要流程。</p> <h2>Socket长连接的创建与维护</h2> <p>IM SDK 所有数据收发流程,均通过Socket长连接完成,如何维护一个稳定Socket通道,是IM系统是否稳定的重要一环。 <br> 下面描述下Socket通道几个重要的流程</p> <ul> <li> <p>创建流程(连接)<img alt="有赞APP IM SDK 组件架构设计" src="https://simg.open-open.com/show/644af431f04870d2abd6ef1555df54d2.png" width="480" height="350">如图所示,当IM SDK初始化后,业务调用连接请求接口,会开始连接的创建过程,创建成功后,会完成鉴权操作,当创建和鉴权都完成后,会开启消息收发线程,为了维持长连接,会有心跳机制,特别的,会开启一个心跳轮询线程。</p> </li> <li> <p>心跳 <br> 心跳机制,是IM系统设计中的常见概念,简单的解释就是每隔若干时间发送一个固定信息给服务端,服务端收到后及时回复一个固定信息,如果服务端若干时间内没有收到客户端心跳信息则视客户端断开,同理如果客户端若干时间没有收到服务端心跳回值则视服务端断开。 <br> <img alt="有赞APP IM SDK 组件架构设计" src="https://simg.open-open.com/show/c1bcd38376a21e9c70da8c131599995f.png" width="770" height="390">当长连接创建成功后,会开启一个轮询线程,每隔一段时间发送心跳消息给服务器端,以维持长连接。</p> </li> <li> <p>重连流程 <br> <img alt="有赞APP IM SDK 组件架构设计" src="https://simg.open-open.com/show/8dd80a87e4d69fb53bf8c089b52e5601.png" width="770" height="317">重连被触发时,如果该次连接成功,退出重连。反之重连失败后,会判断当前重连的次数是否超过预期值(这里设为6次),并对重连次数计数,如果超过就会退出重连,反之休眠预设的时间后再次进行重连操作。 <br> 重连触发条件分为三种:</p> <ul> <li>主动连接不成功(主动连接Socket,如果连接失败,会触发重连机制)</li> <li>网络被主动断开(正常建立连接,操作过程中,网络被断开,通过系统广播触发重连)</li> <li>服务器没响应,心跳没回值(服务端心跳预设时间内没回值,客户端认为服务端已经断开,触发重连)</li> </ul> </li> <li> <p>网络状态判断 <br> TCP API并没有提供一个可靠的方法判断当前长连接通道状态,isConnected()和isClosed()仅仅告诉你当前的Socket状态,不是是长连接断开是一回事。 isConnected()告诉你是否Socket与Romote host保持连接,isClosed()告诉你是否Socket被关闭。 <br> 假如你判断长连接通道是否被关闭,只能通过和流操作相关的以下方法:</p> <ol> <li>read() return -1</li> <li>readLine() return null</li> <li>readXXX() throw EOPException for any other XXX</li> <li>write 将抛出IOException: Broken pipe(通道被关闭)</li> </ol> </li> </ul> <p>所以SDK封装isConnected()方法的时候,是根据这几种情况综合判断当前的通道状态,而不是仅仅通过Socket.isConnected()或者Socket.isClosed()。</p> <h2>消息发送流程</h2> <p><img alt="有赞APP IM SDK 组件架构设计" src="https://simg.open-open.com/show/46486fb37722c2b593f4a71188ad1e31.png" width="871" height="584">消息发送流程主要有两大类,一类是IM相关数据的请求,例如:历史消息列表,会话列表等,另一类是IM消息的发送,主要是文字消息。(富媒体消息发送,会将富媒体文件先上传服务器后,拿到文件URL, 通过文字消息,将此URL发给接收方,接收方下载后进行UI展示)。 此两类消息发送,均使用上图的流程进行发送,可通过发送回调感知请求的结果。</p> <p>如图所示,消息发送流程,需要先封装消息请求,在通过发送队列发送至服务器,发送前,在将请求id和对应回调存入本地Map数据结构中。</p> <pre> <code class="language-java">if (requestCallBack != null) { mCallBackMap.put(requestId, requestCallBack); } </code></pre> <p>之后接收服务器推送消息(此消息带有发送请求时的请求id),在本地的Map数据找到请求id对应的回调,然后通过回调返回服务器推送过来的数据。 <br> 请求可以通过泛型指定返回值类型,SDK中会自行解析服务器数据返回的数据,直接返回给业务调用方model对象,方便使用。(目前支持json格式的数据解析)</p> <pre> <code class="language-java">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); } } } </code></pre> <p>如下的示例中,展示了一个获取会话列表的请求,可以看出目前的请求封装,和一些第三方的的网络库类似,使用起来较为方便。</p> <pre> <code class="language-java">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 } }); </code></pre> <p>可以看出,该请求直接返回了一个会话类型的List集合,业务方可直接使用。</p> <h2>消息接收流程</h2> <p><img alt="有赞APP IM SDK 组件架构设计" src="https://simg.open-open.com/show/347639d32e35ded9431ebd93283e845c.png" width="786" height="258"></p> <p>消息的监听流程主要使用了一个全局监听的方式来进行,需要先注册监听器,监听器中有默认的回调。</p> <pre> <code class="language-java">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); } </code></pre> <p>该监听器中可以接收如下类型的消息:</p> <ul> <li>Socket连接状态的返回结果。</li> <li>鉴权状态的返回结果,(鉴权流程因有赞业务需要)。</li> <li>接收的IM消息,或者其他类型的返回消息。可根据消息类型进行后续的分发处理</li> </ul> <p>业务如需使用此全局监听器,需要自行实现此接口,并在业务初始化时,注册此监听器即可。SDK中会根据注册的监听器,在读取到服务器推送消息后,直接通过监听器到回调进行分发。</p> <pre> <code class="language-java">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); } } } </code></pre> <p>部分接收消息,如心跳,多端登录时被踢下线通知等,sdk内部会自行处理,业务基本无感知。</p> <h2>可定制化的UI</h2> <p>随着公司规模的扩大与业务线的快速迭代,可能新的业务也需要 IM 这个功能,众所周知,IM UI 功能的嵌入会占据大量的开发与调试时间, 为了解决这个痛点,决定将 IM UI 部分抽成一个 Library,实现可定制与单独维护,做到真正的敏捷开发与快速迭代。</p> <h3>UIKit设计</h3> <p><img alt="有赞APP IM SDK 组件架构设计" src="https://simg.open-open.com/show/1c527293bea0cd05e242562a55726075.png" width="490" height="505">IM UIKit暴露相应的api接口,业务方注入相应的功能定制项,针对UI的点击回调通过EventBus总线post分发,减少了业务方与UIKit的耦合,底层业务方通过MVP模式对View与Model进行解耦。定制项一般通过如下几种方式:</p> <ul> <li>XML(定制业务信息,资源信息,显示条数,各个业务功能开关等)</li> </ul> <pre> <code class="language-java"> <?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> </code></pre> <ul> <li>Style(定制UI背景,气泡颜色,字体大小等)</li> </ul> <pre> <code class="language-java"> <?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> </code></pre> <ul> <li>Model定制(传入预设的定制Model模板填入相应参数,UIKit里面做相应解析)</li> </ul> <pre> <code class="language-java"> public class Entity { public String action1; public String action2; public String aciton3; ...... } </code></pre> <h3>UIKit 支持的富媒体类型</h3> <p>除了文字消息之外,现在主流的IM系统中也支持各种富媒体发送,在有赞IM SDK UIKit中,目前也支持几种富媒体发送。 以下是发送流程图和两类常见富媒体消息简介。</p> <ul> <li>语音消息 语音消息,除了使用常见的录制和解码播放的技术之外。还利用了<code>AudioManager</code> 中 <code>requestAudioFocus</code>,<code>abandonAudioFocus</code> 相关方法,实现了录制和播放语音消息,如果有第三方播放音乐,会自动暂停,录制和播放语音消息结束后,声音会自动播放。</li> <li>图片消息 图片消息,通过七牛服务器设置了缩略图,接收方收到消息后,会先下载缩略图,当用户再点击进入图片详情页时,会下载大图,Andorid客户端使用Picasso加载库加载图片,并做本地缓存。</li> </ul> <h2>UI 中聊天会话数据加载策略</h2> <p>参考业界主流的IM系统方案,用户聊天时,需要将已经发送和接收到的聊天信息保存到本地。而不是每次都拉取历史数据。以达到节约流量和无网络状态下也查看数据的效果。为此IM SDK持久化层的数据库中,也实现了简单存储加载机制,下面描述典型的数据加载场景。</p> <ul> <li> <p>IM会话首次请求数据流程<img alt="有赞APP IM SDK 组件架构设计" src="https://simg.open-open.com/show/864bd7bd55c34e3de7288994f60b95d2.png" width="790" height="297"></p> </li> <li> <p>IM下拉获取历史数据流程<img alt="有赞APP IM SDK 组件架构设计" src="https://simg.open-open.com/show/c378c9d92906a3745ed0e0be73b263d2.png" width="768" height="720"></p> </li> <li> <p>IM单条消息发送持久化方案<img alt="有赞APP IM SDK 组件架构设计" src="https://simg.open-open.com/show/899954c7a417d9db7a3f2228dfe3790b.png" width="1020" height="462"></p> </li> <li> <p>IM单条数据重发流程<img alt="有赞APP IM SDK 组件架构设计" src="https://simg.open-open.com/show/d851b8c8e8f85f3abcca44b40656db0e.png" width="690" height="432"></p> </li> </ul> <p>设计不足之处</p> <ul> <li> <p>消息回执 <br> 当前的设计方案中,没有消息回执的机制,也就是说接受方收到消息后,不会返回服务器收到消息的通知,服务器无法判断消息是否推送成功,这样在突然断网,网络模式切换,或者弱网环境下,会影响消息的到达率。 <br> 一种可行的设计方式是,发送方增加已送到和未送达的状态,接收方收到消息后,给服务器返回已收到消息的通知,服务器再推送给发送方该状态,如果没有收到接收方回执,服务器可尝试重新推送。发送方接受到接收方的收到回执后,更新发送状态已发送,如果未收到,则显示未送达。为了防止接收方回执丢失,接收方接收消息时候,可维护本地去重队列。</p> </li> <li> <p>本地请求超时的判断 <br> 本地发起的请求,没有用定时器,完全依赖服务器返回或者出现Socket通道异常后上抛的通知作为超时判断,部分场景可能覆盖不到,需要对请求增加固定的超时处理机制,固定时候未收到请求,即认为超时。</p> </li> </ul> <p>未来发展方向</p> <ul> <li>增加对群聊的支持。</li> <li>弥补设计中的不足之处,提升消息的到达率和系统稳定性。</li> <li>提供有赞App更方便的接入方式,提供封装性更好的接口。</li> </ul> <p>来源:http://tech.youzan.com/you-zan-im-sdk-ke-hu-duan-she-ji/ </p>