一、概述

接到一个需求,需要在我们的客户端里实现类似QQ的社交功能,以方便玩家之间的沟通互动。我们的客户端是C++实现的,在开会讨论考虑到成本和时间问题,实现这个功能的任务交个了前端。为了简化说明,我将实现的功能简化成了消息列表、聊天对话框、聊天室三大功能,并且只有基础功能,界面如下的原型图。
IM消息会话列表
IM聊天室

二、技术调研

由于后端方面确定用第三方IM SDK实现核心即时通讯的通讯功能,于是我和后端开始收集第三方IM SDK提供商,初步收集下来有三个(网易云信、融云、腾讯云云信)。除了融云,网易、腾讯都是有背景的,网易背靠游戏、腾讯背靠QQ、微信。为什么最后选了融云呢?总结下来主要基于以下几点:

  1. 从结构上看,融云功能模块比较独立,好友关系、群关系自己维护,刚好符合我们部分定制的需要。
  2. 相对于网易云信,融云价格方面更有优势。
  3. 腾讯云信由于业务竞争关系,直接被Boss否定。但我们还是了解了一下,文档写得不是很清晰,给人感觉这个业务没得到重视,估计在后续接入中遇到坑可能得不到很好的支持。

确定使用融云SDK后,开始跑他们的Demo,开始对技术可行性方面的验证和提前采坑。后来发现在技术实现上面确实有一个坎,需要我们去铺平。我们是在客户端里开发,IM的消息列表、对话框、聊天室等这些窗口都是单独独立的。客户端方面给定这些窗口,然后加载我们的前端界面,等于说每个窗口都是一个独立的浏览器。这就存在一个问题,是选择在主窗口保持一个IM连接然后再消息通知到其他窗口,还是每个窗口都保持一个连接(即多页面连接)?于是做了一下对比:

  1. 主窗口保持一个连接,当接收到消息时会转发到其它窗口,需要实现窗口之间的通讯;当其它窗口执行IM SDK方法(比如对话框发送消息)需要发消息到主窗口执行,然后主窗口把执行的结果返回给发起窗口,这里面的过程比较长,增加了开发负担并且出了问题并不好调试。另外对连接保活要求很高,连接一断IM直接收不到消息。
  2. 多页面连接,每个窗口都有个连接,意味着窗口的消息实现了自治,IM SDK方法也得以在当前窗口执行直接得到结果,开发简单。

多页面连接是最优解吗?对两种方案继续深入思考下去时,我发现了多页面连接比较致命的问题。

  1. 多窗口连接下的在离线问题:如果消息列表窗口、聊天对话框窗口、聊天室窗口都有一个自有连接,当关闭聊天室时,聊天室窗口的连接断开,按道理同端断开用户状态就该下线了。同样当从消息列表窗口打开聊天对话框,新建了一个聊天对话框窗口连接,按道理连接即用户上线,再提示一次用户在线了?如果要改变这样正常的业务逻辑代价是非常大的,特别是在使用第三方IM SDK情况下。
  2. 多窗口连接下的用户会话已读未读消息条数问题:融云的已读未读消息状态是存在本地的,假设消息列表窗口、对话窗口、聊天窗口各一个连接,当有一条未读消息收到时,连接随机分别先后收到这条消息,先收到消息会话未读数+1。由于消息id是一致的,后面两个连接会话在处理时同样会分别+1,这样存在本地的消息会话未读数其实是3。这个问题也是不好解决的。
  3. 其它细碎小问题。

于是选择了主窗口保持一个连接的方案,并考虑选择前端相关技术栈。考虑使用react或vue,其实效用方面区别不是很大,选择了公司大家更熟悉的vue。另外融云IM SDK支持typescript,给使用ts加了个条件。于是前端使用的技术栈是vue多页面+ts。

三、核心实现

项目开始后,着重设计并实现了两个核心库,一个是基于IM SDK封装的库im.ts,一个是对事件封装的event.ts。

im.ts起承上启下的作用,对上把融云IM SDK方法里的回调方案改成更易理解维护的promise方案,设计考虑到公司自己实现IM服务或更换其它IM SDK有个基本的支撑,对下页面在使用im.ts方法时有固定输入和输出的数据结构,以方便使用和维护。对监听连接状态、监听消息接收处理都通过event.ts提供的事件方法提交事件给外面窗口页面处理。最后统一对错误处理,让错误提示文案对用户更友好,也是通过事件方法提交事件给窗口页面处理。

event.ts顾名思义是对各种事件的封装,对常用的提交事件emit、监听事件on的实现,还有基于客户端同事提供的跨窗口通讯方法封装的窗口通讯事件。由于在技术选型时选择的方案是主窗口保持一个连接,然后通过窗口通讯转发消息到其它窗口,因此event.ts基于跨窗口通讯方法实现了一套跨窗口调用方法。具体分为rpc()、executeRpc()两个方法,大概实现如下:

/**
 * 发起跨窗口执行方法
 * @param windowId 窗口的id
 * @param body {ctx:执行方法窗口上下文key, method: 执行方法, args: 执行方法参数 }
 * @param args 执行方法参数
 * @returns {Promise<any>}
 * @constructor
 */
function rpc(windowId: string, body: { ctx: string, method?: string }, ...args: any[]): Promise<any> {
  const data: any = {body, args}
  data._rpcRandomKey = `${Date.now()}${Math.floor(Math.random() * 1000)}`
  data._rpcReqKey = 'RPC:SYNC:REQ:' + data._rpcRandomKey
  const rpcResKey = 'RPC:SYNC:RES:' + data._rpcRandomKey
  // 发送给执行方法窗口,执行方法窗口监听接收到数据执行executeRpc方法

  Event.crossWinMessage(windowId, data)
  return new Promise((resolve, reject) => {
    let timeOut = setTimeout(() => {
      Event.off(rpcResKey)
      reject({info: '调用超时', code: ''})
    }, 5000)
    // 监听方法执行结果数据
    Event.on(rpcResKey, (response) => {
      Event.off(rpcResKey)
      if (response.result) {
        resolve(response.result)
      }
      if (response.error) {
        reject(response.error)
      }
    })
  })
}

/**
 * 处理跨窗口执行结果方法
 * @param windowId 接收执行结果窗口id
 * @param ctx 执行环境
 * @param rpc 发送过来的执行数据
 * @returns {Promise<any>}
 * @constructor
 */
function executeRpc(windowId: string, ctx: any, rpc: any) {
  let promise: Promise<any>
  let result: any
  try {
    if (rpc.body.method) {
      result = ctx[rpc.body.ctx][rpc.body.method](...rpc.args)
    } else {
      result = ctx[rpc.body.ctx]
    }
    promise = Promise.resolve(result)
  } catch (error) {
    promise = Promise.resolve(() => {
      throw error
    })
  }
  return promise.then((result: any) => {
    Event.crossWinMessage(windowId, {result})
  }).catch((error: any) => {
    Event.crossWinMessage(windowId, {error})
  })
}

跨窗口调用方法原理
注意每次请求发送监听的id或key一定要保证是唯一的,不然可能存在执行方法和执行结果对应不上的问题。另外主要处理执行方法报错和超时问题。

四、开发中的问题

开发中也会多多少少遇到一些问题,我找了两个比较印象深刻的问题。一是IM连接断开问题,二是时序问题。

IM连接在复杂的网络特别是弱网下容易断开,还有网络抖动也可能引起断开,断网也会断开,虽然融云的IM SDK有自己的重连机制,但是在某些情况下断了就连不上了。网络环境要比想象的复杂,因此建议给用户做友好提示,提示用户当前连接状态是什么,是正在连接?正在重连?对于网络断开引起的IM连接断开,需要用浏览器提供的网络在离线事件window.addEventListener('online', callback)、window.addEventListener('online', callback)去判断从断网到网络已连接后,重新刷新融云IM的token(注意长时间断网下token有可能失效),然后重连。对于其它网络环境引起的IM连接断开,监听断开一定时间段内尝试重连并给用户提示,多次重连失败建议给用户手动重连的入口。

时序问题是指webstock连接返回结果和接口返回结果的先后顺序问题,这里面极其容易引入一些未知的bug,因为有可能是webstock连接消息通知先返回结果,也可能是接口先返回结果,随机的顺序使程序处理困难和复杂。根据实践来说,遵循这个规范可以使这个问题很好的解决,即接口请求发起方既负责接口发起请求,返回结果后又负责对自己的消息通知处理。服务器端webstock消息通知则负责通知除发起请求的其它用户,这样就和先后顺序无关了。

五、展望

最后到这里希望通过总结和分享,能给大家有所启发和帮助,文章有不妥之处还望斧正。如果有时间并有机会,我希望用electron实现一个,岂不美滋滋?