接到一个需求,需要在我们的客户端里实现类似QQ的社交功能,以方便玩家之间的沟通互动。我们的客户端是C++实现的,在开会讨论考虑到成本和时间问题,实现这个功能的任务交个了前端。为了简化说明,我将实现的功能简化成了消息列表、聊天对话框、聊天室三大功能,并且只有基础功能,界面如下的原型图。
由于后端方面确定用第三方IM SDK实现核心即时通讯的通讯功能,于是我和后端开始收集第三方IM SDK提供商,初步收集下来有三个(网易云信、融云、腾讯云云信)。除了融云,网易、腾讯都是有背景的,网易背靠游戏、腾讯背靠QQ、微信。为什么最后选了融云呢?总结下来主要基于以下几点:
确定使用融云SDK后,开始跑他们的Demo,开始对技术可行性方面的验证和提前采坑。后来发现在技术实现上面确实有一个坎,需要我们去铺平。我们是在客户端里开发,IM的消息列表、对话框、聊天室等这些窗口都是单独独立的。客户端方面给定这些窗口,然后加载我们的前端界面,等于说每个窗口都是一个独立的浏览器。这就存在一个问题,是选择在主窗口保持一个IM连接然后再消息通知到其他窗口,还是每个窗口都保持一个连接(即多页面连接)?于是做了一下对比:
多页面连接是最优解吗?对两种方案继续深入思考下去时,我发现了多页面连接比较致命的问题。
于是选择了主窗口保持一个连接的方案,并考虑选择前端相关技术栈。考虑使用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}
* @constructor
*/
function rpc(windowId: string, body: { ctx: string, method?: string }, ...args: any[]): Promise {
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}
* @constructor
*/
function executeRpc(windowId: string, ctx: any, rpc: any) {
let promise: Promise
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实现一个,岂不美滋滋?
文章来自:天玑博客·前端月刊 github|web-monthly