TRTC 那点事儿
2020/05/18
整理一下最近几月使用到的 trtc 第三方包
业务需求 跟我们日常开发一样,先看需求,再讲技术。如何讲需求的核心快速的讲明白一直是个大问题。
讲的人滔滔不绝,听的人昏昏欲睡。稀里糊涂讲完了,底下人稀稀拉拉的鼓掌,然后散场,整个一黑色幽默……
在开发小程序过程中,我也想如何能让一个开发快速接手我的项目,如何更清晰的表明代码结构,以及开发时的意图。
直到我找见了这篇文章:
前端状态管理请三思
(很巧合,溯阳在这篇文章底部客串了哈哈哈哈
状态机 状态机这玩意儿确实是个老古董,在编译原理的第一个阶段词法分析时,总会遇到它,扯远了……
可以这样理解它,状态机由很多状态构成,状态之间可以流转,流转的条件是触发某些 “事件”。
比如我们坐电梯,在一楼时走进电梯,当前状态就是 floor_1_state
,当我们按了二楼时,click_goto_2
,触发状态流转,电梯停在二楼,此时状态变更为 floor_2_state
。电梯系统,就是一个典型的有限状态机(FSM)。有限状态机可以描述我们日常生活中的大多行为和状态,你可以大胆发挥想象。
构造一个状态机的好处,一是,状态流转很清晰,特定的事件触发特定的状态,一般配合状态转化图/表食用。二是,状态的流转是固定的,假设我们的状态流转是 A->B->C,那在用户行为触发的时候,绝对不会出现 B 状态转换到 A 状态的情况。
状态转换表 在我们构造一个状态机前,首先要构造状态转换表,以下是视频面试项目的状态转换表。
角色分为普通求职者、面试官 和 主持人(主持人算特殊的面试官),初始化状态是 角色为空状态
状态
行为
状态
角色为空状态
面试官进入
面试官面试未准备状态
角色为空状态
求职者进入
求职者面试未就绪状态
角色为空状态
主持人进入
主持人面试未就绪状态
求职者面试未就绪状态
面试官进入
求职者未准备
面试官面试未准备状态
面试官点击准备
面试官准备状态
面试官准备状态
面试官点击取消准备
面试官面试未准备状态
主持人面试未就绪状态
求职者进入
求职者未准备
求职者未准备
求职者点击准备面试
面试准备状态
面试准备状态
主持人点击面试开始
面试开始
面试开始
面试官点击结束面试
面试结束
面试结束
空
三列分别是,当前状态、当前状态接收的行为以及转换后的状态。假如当前状态为空,当面试官进入的时,状态就转换到了 面试官面试未就绪状态。
状态转化表构造好后,就该画转换图了,转换图是一种更清晰的描述状态的手段。
状态转换图
画出状态转换图后,就可以写代码了
状态转换函数 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 export default { currentState: 'roleVoid' , states: { 'roleVoid' : { 'interviewer_enter' : 'interviewer_un_ready' , 'hunter_enter' : 'hunter_un_ready' , 'host_enter' : 'host_enter_un_ready' , beforeEnter() { console .log('有人进入' ) } }, 'interviewer_un_ready' : { 'interviewer_click_ready' : 'meet_ready' , beforeEnter() { }, }, 'host_enter_un_ready' : { 'hunter_enter' : 'host_enter_ready' , beforeEnter() { }, }, 'host_enter_ready' : { 'hunter_click_ready' : 'meet_ready' , beforeEnter() { }, }, 'hunter_un_ready' : { 'interviewer_enter' : 'hunter_un_plan' , 'host_enter' : 'hunter_un_plan' , beforeEnter() { }, }, 'hunter_un_plan' : { 'hunter_click_ready' : 'meet_ready' , beforeEnter() { }, }, 'meet_ready' : { 'host_click_start' : 'meet_start' , 'click_leave' : 'leaved' , 'hunter_leave' : 'hunter_un_plan' , beforeEnter() {}, }, 'meet_start' : { 'host_click_end' : 'meet_end' , 'click_leave' : 'leaved' , beforeEnter() { }, }, 'leaved' : { beforeEnter() { } }, 'meet_end' : { afterEnter() { } } } }
用一个对象来管理所有的状态以及当前状态的下一状态。
状态转换方法就可以这样写:
1 2 3 4 5 6 7 8 9 10 11 function transformFsm (name ) { const { machine : { currentState }, machine } = this .data const nowState = machine.states[currentState] if (nowState[name]) { const stateObj = machine.states[nowState[name]] stateObj.beforeEnter && stateObj.beforeEnter.call(this ) machine.currentState = nowState[name] stateObj.afterEnter && stateObj.afterEnter.call(this ) console .warn('当前状态' ,currentState, name, nowState[name]) } }
状态转换可以这么写了,比如面试官进入房间了
1 transformFsm('interviewer_enter' )
状态流转到 interviewer_un_ready
面试官点击准备
1 transformFsm('interviewer_click_ready' )
状态流转到 meet_ready
使用状态机后,三个角色公用的按钮的逻辑代码可以这么写
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 tapBottomButton() { if (this .data.btnDisabled) { return } if (this .data.nowUserInfo.role === 0 ) { switch (this .data.machine.currentState) { case 'hunter_un_plan' : this .userInRoomChangeState({ stateInRoom: 1 }) return case 'meet_ready' : this .leaveMeet() return case 'meet_start' : this .leaveMeet() return } } if (this .data.nowUserInfo.isHost) { switch (this .data.machine.currentState) { case 'meet_ready' : this .startInterviewInCommon() return case 'meet_start' : this .closeMeet() return } } if (!this .data.nowUserInfo.isHost && this .data.nowUserInfo.role === 1 ) { switch (this .data.machine.currentState) { case 'interviewer_un_ready' : this .userInRoomChangeState({ stateInRoom: 1 }) return case 'meet_ready' : this .leaveMeet() return case 'meet_start' : this .leaveMeet() return } } },
一个逻辑比较复杂的提示弹层可以这么写
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 <view class ="toast" wx:if ="{{ !title && roomMsg.roomState !== 1 && nowUserInfo.isHost }}" > <text wx:if ="{{ currentState === 'host_enter_un_ready' }}" > 请耐心等待求职者进入</text > <text wx:if ="{{ currentState === 'host_enter_ready' }}" > 求职者{{ hunter.realName }}已进入</text > <text wx:if ="{{ currentState === 'meet_ready' }}" > 求职者已准备,请开始面试</text > </view > <view class ="toast" wx:if ="{{ !title && roomMsg.roomState !== 1 && !nowUserInfo.isHost && nowUserInfo.role === 1 }}" > <text wx:if ="{{ !hunter.userId }}" > 请耐心等待求职者进入</text > <text wx:if ="{{ hunter.userId && currentState === 'interviewer_un_ready' }}" > 若你已准备好,请点击「准备面试」</text > <text wx:if ="{{ hunter.userId && currentState === 'meet_ready' }}" > 等待面试官开始面试</text > </view > <view class ="toast" wx:if ="{{ !title && roomMsg.roomState !== 1 && nowUserInfo.role === 0 }}" > <text wx:if ="{{currentState === 'hunter_un_ready'}}" > 请耐心等待面试官进入</text > <text wx:if ="{{currentState === 'hunter_un_plan'}}" > 若你已准备好,请点击「准备面试」</text > <text wx:if ="{{currentState === 'meet_ready'}}" > 等待面试官开始面试</text > </view >
状态机到这里就结束了。接下来谈谈 TRTC
WEB TRTC SDK 我也不知道从何处讲起,就抱着 TRTC 快速集成的心态去整理吧,(一般来讲,第三方 SDK 的接入是很快的,官方都会宣称:X分钟接入 SDK。五分钟接入后,开发者一脸懵逼:我也不知道为啥,它就 run 起来了),但后面加上自己的业务场景之后,往往就会有很多问题,而这些问题总要和第三方团队沟通,各种扯皮甩锅,这个过程我们往往都不太喜欢……
简介 TRTC 底层封装了 WebRTC,WebRTC,名称源自网页即时通信(英语:Web Real-Time Communication)的缩写,是一个支持网页浏览器进行实时语音对话或视频对话的API。
WebRTC 原生网页支持,MDN WebRTC
如果你有兴趣,你也可以直接使用 WebRTC 来做实时音视频通话。
WebRTC 技术由 Google 最先提出,目前主要在桌面版 Chrome 浏览器、桌面版 Safari 浏览器以及移动版的 Safari 浏览器上有较为完整的支持,其他平台(例如 Android 平台的浏览器)支持情况均比较差。
在移动端推荐使用 小程序 解决方案,微信和手机 QQ 小程序均已支持,都是由各平台的 Native 技术实现,音视频性能更好,且针对主流手机品牌进行了定向适配。 如果您的应用场景主要为教育场景,那么教师端推荐使用稳定性更好的 Electron 解决方案,支持大小双路画面,更灵活的屏幕分享方案以及更强大而弱网络恢复能力。
基于这点,如果你们以后项目要用到视频通话,需要做技术选型的时候,使用 Electron桌面端 + 小程序手机端的方案。因为我们在开发 WEB 端,经常要和用户兼容性问题打交道,诸如:浏览器版本较低、浏览器厂商未做兼容之类的 非技术问题……
当然你也可以用官方给的 DEMO 去验证兼容问题:https://www.qcloudtrtc.com/webrtc-samples/abilitytest/index.html
或者使用 SDK 提供的 API checkSystemRequirements
去检测浏览器是否支持。实际测试,第二种方式是不准确的。
应用场景 我们先熟悉下 API
Client
创建 TRTC 实例
监听事件
加入房间 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 this .client_ = TRTC.createClient({ mode: 'videoCall' , sdkAppId: this .sdkAppId_, userId: this .userId_, userSig: this .userSig_ }) this .client_.on('error' , err => { console .error(err) rtcToast.error('客户端错误:' + err) alert('连接中断,请刷新页面重新进入面试' ) location.reload(); }) this .client_.on('client-banned' , err => {})this .client_.on('peer-join' , evt => {})this .client_.on('peer-leave' , evt => {})this .client_.on('stream-added' , evt => {})this .client_.on('stream-subscribed' , evt => {})this .client_.join({ roomId : this .roomId_ })
LocalStream LocalStream 即本地流,也就是你自己的流,它的源头是你的本地设备 麦克风 和 摄像头。
创建本地流
1 2 3 4 5 6 7 const localStream = TRTC.createStream({ audio : true , video : true });localStream.initialize().catch(error => { console .error('failed initialize localStream ' + error); }).then(() => { console .log('initialize localStream success' ); });
首次调用该方法后,浏览器左上角会弹授权框,允许后即可拿到本地流。
你也可以在本地流创建成功后,设置一些属性,比如视频高度和宽度,比如帧率和比特率等等……如果视频卡顿或者用户网络环境差时,可通过降低分辨率和帧率来优化……
1 2 3 4 5 6 7 localStream.setVideoProfile({ width: 360 , height: 360 , frameRate: 10 , bitrate: 400 });
那创建完本地流之后,我们如何在网页上看到本地摄像头呢?
首先我们得构造一个空的 div 容器
1 <div id ='local_stream' > </div >
然后调用本地流的 play 方法
1 this .localStream_.play('local_stream' )
这样,trtc
就会帮我们将 video
和 audio
标签渲染在 local_stream
节点上了。
当本地流创建后,我们可以将本地流推送到远端,可以调用 publish
方法
1 this .localStream_.publish()
RemoteStream RemoteStream
即远程流,也就是上一节最后,别人推送的本地流。
当有远程流被增加/删除时,我们在 Client
节订阅的事件会被回调。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 this .client_.on('stream-subscribed' , evt => { const remoteStream = evt.stream const id = remoteStream.getId() vm.videoId.push(id) this .$nextTrick(() => { remoteStream.play(id) }) }) this .client_.on('stream-removed' , evt => { const remoteStream = evt.stream const id = remoteStream.getId() const index = vm.videoId.findIndex(id) vm.videoId.splice(index, 1 ) })
注意事项 黑屏问题 原因一: 之前 v-for 的 key 用了数组下标,导致黑屏,猜测是 DOM 没有被复用,改成了唯一值 id 原因二: 原因一改正之后,还是会出现视频渲染不出来,原因是主屏用户挡住了小屏用户,所以 index 层级关系这个坑开发前要设计好。 原因三: 原因一和原因二修正之后,还是会出现偶现的黑屏,存在于当有人退出和进入时,原因猜测是 DOM 重排引起,彻底解决这个问题,采取了一个很 hack 的方案。
1 2 3 4 5 6 7 8 9 10 11 12 async updateView () { await this .$nextTick() this .eventBus.streams.forEach(stream => { stream.stop() }) this .eventBus.streams.forEach(stream => { try { stream.play(stream.id_) } catch (err) { console .error(err) } }) },
状态同步 状态同步即客户端状态之间的同步,主要是谁进/退出房间啦,谁静音/取消静音啦之类的事件。
我们在做 PC 端时,采用后端自建 socket 单点服务,不管是挂了/版本升级 时,肯定用不了……这个可能以后会优化。 采取的 socket
协议是 stompJs
。为什么不用原生 websocket
,因为原生 websocket
开发相当于原生 tcp
开发,请求和返回数据没有一个包装,开发体验会比较糟糕。
https://www.jobmd.net/online/interview.do#/pc/room?id=1471&token=91723745
STOMP即Simple (or Streaming) Text Orientated Messaging Protocol,简单(流)文本定向消息协议,它提供了一个可互操作的连接格式,允许STOMP客户端与任意STOMP消息代理(Broker)进行交互。STOMP协议由于设计简单,易于开发客户端,因此在多种语言和多种平台上得到广泛地应用。
与处在应用层的HTTP不同,WebSocket处在TCP上非常薄的一层,会将字节流转换为文本/二进制消息,因此,对于实际应用来说,WebSocket的通信形式层级过低,因此,可以在 WebSocket 之上使用 STOMP协议,来为浏览器 和 server间的 通信增加适当的消息语义。
如何理解 STOMP 与 WebSocket 的关系
1) HTTP协议解决了 web 浏览器发起请求以及 web 服务器响应请求的细节,假设 HTTP 协议 并不存在,只能使用 TCP 套接字来 编写 web 应用,你可能认为这是一件疯狂的事情; 2) 直接使用 WebSocket(SockJS) 就很类似于 使用 TCP 套接字来编写 web 应用,因为没有高层协议,就需要我们定义应用间所发送消息的语义,还需要确保连接的两端都能遵循这些语义; 3) 同 HTTP 在 TCP 套接字上添加请求-响应模型层一样,STOMP 在 WebSocket 之上提供了一个基于帧的线路格式层,用来定义消息语义;
STOMP协议与HTTP协议很相似,它基于TCP协议,使用了以下命令: CONNECT SEND SUBSCRIBE UNSUBSCRIBE BEGIN COMMIT ABORT ACK NACK DISCONNECT
我们主要用了
CONNECT SEND SUBSCRIBE
三种命令。
如何使用 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 self.stompClient = Stomp.over(() => { return new SockJS(self.host + '/jobmd-online-interview?token=' + self.token) }) self.stompClient.connect( '' , '' , function (frame ) { self.setConnected(true ) self.initSubscribe() self.timestamp = null successCallback() }, function (res ) { setTimeout(() => { self.connectAndReconnect(successCallback) if (!self.timestamp) { self.timestamp = new Date ().getTime() } }, 2000 ) }, close => { console .log('socket 关闭,尝试重连中' ) console .log(this .frontConnect) setTimeout(() => { self.connectAndReconnect(successCallback) if (!self.timestamp) { self.timestamp = new Date ().getTime() } }, 2000 ) } ) initSubscribe() { const self = this this .stompClient.subscribe('/topic/' + this .roomId, function (output ) { console .log('*******************************' + output + '*******************************' ) }) } this .stompClient.send( path, {}, JSON .stringify(data) )
小程序 SDK 简介 在做完 web 端后,我们受到了运营 客户 产品的三方抨击,
在 pc 端,有的客户没摄像头\有的客户没麦克风\有的客户用 ie 浏览器……
在 移动端,由于 web-rtc 天然属性:浏览器对它支持差。国内厂商对浏览器的实现各不相同,于是,ios 的 微信不能用/安卓的除微信外其他浏览器不能用/甚至我们的友好伙伴 chrome mobile 也不能用!
在受到无数的鄙视和吐槽后,产品决定开发更为稳定和兼容性更好的微信小程序!
如何使用 虽说都是 TRTC,但小程序 SDK 包的使用还是和浏览器有区别。
使用条件 必须在微信公众平台开通 接口设置 - 实时播放音视频流 功能
开通的小程序类目也是有限制滴:社交/教育/医疗/金融/汽车/政府/工具类才可以开通。 因此我们重新申请了专门的小程序做视频面试。
拉流和推流的载体是两个原生组件 <live-player>
<live-pusher>
当然我们不需要基于该组件做原生开发,TRTC 已经帮我们封装好了。
使用流程 我们使用的是 trtc 封装好的自定义组件 <trtc-room>
,
1 <trtc-room id ="trtc-component" config ="{{rtcConfig}}" bindtap ="switchHideBar" > </trtc-room >
进入房间-监听远程流
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 { enterRoom: async function (params ) { const trtcComponent = this .selectComponent('#trtc-component' ) this .template = params.template this .data.rtcConfig = { sdkAppID: params.sdkAppID, userID: params.userID, userSig: params.userSig, template: 'custom' , debugMode: false , beautyLevel: 0 , enableIM: false , videoWidth: '270' , videoHeight: '480' , maxBitrate: '350' , enableBackgroundMute: true } this .setData({ rtcConfig: this .data.rtcConfig, }, () => { this .trtcComponent.enterRoom({ roomID : params.roomID,enterRoom : 'live' }) }) }, bindTRTCRoomEvent() { const TRTC_EVENT = this .trtcComponent.EVENT this .timestamp = [] this .trtcComponent.on(TRTC_EVENT.LOCAL_JOIN, async (event)=>{ console .error('* room LOCAL_JOIN' , event) this .trtcComponent.publishLocalVideo() this .switchVideoSet() }) this .trtcComponent.on(TRTC_EVENT.LOCAL_LEAVE, (event)=>{ console .error('* room LOCAL_LEAVE' , event) }) this .trtcComponent.on(TRTC_EVENT.ERROR, (event)=>{ console .error('* room ERROR' , event) }) this .trtcComponent.on(TRTC_EVENT.REMOTE_USER_JOIN, (event)=>{ console .error('* room REMOTE_USER_JOIN' , event, this .trtcComponent.getRemoteUserList()) this .timestamp.push(new Date ()) this .switchVideoSet() }) this .trtcComponent.on(TRTC_EVENT.REMOTE_USER_LEAVE, (event)=>{ console .error('* room REMOTE_USER_LEAVE' , event, this .trtcComponent.getRemoteUserList()) this .switchVideoSet() }) this .trtcComponent.on(TRTC_EVENT.REMOTE_VIDEO_ADD, async (event)=>{ console .error('* room REMOTE_VIDEO_ADD' , event, ...this.trtcComponent.getRemoteUserList()) this .switchVideoSet() }) this .trtcComponent.on(TRTC_EVENT.REMOTE_VIDEO_REMOVE, (event)=>{ console .error('* room REMOTE_VIDEO_REMOVE' , event, this .trtcComponent.getRemoteUserList()) this .switchVideoSet() }) this .trtcComponent.on(TRTC_EVENT.REMOTE_AUDIO_ADD, (event)=>{ console .error('* room REMOTE_AUDIO_ADD' , event, this .trtcComponent.getRemoteUserList()) const data = event.data this .trtcComponent.subscribeRemoteAudio({ userID : data.userID }) }) this .trtcComponent.on(TRTC_EVENT.REMOTE_AUDIO_REMOVE, (event)=>{ console .error('* room REMOTE_AUDIO_REMOVE' , event, this .trtcComponent.getRemoteUserList()) }) }, }
下面的方法集成了:打开摄像头并推送视频
this.trtcComponent.publishLocalVideo()
这里引发了一个问题,后面再讲 那如何给流布局呢?官方提供了方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 const trtcComponent = this .selectComponent('#trtc-component' )const param = { userID: userId.toString(), xAxis: '0' , yAxis: '0' , width: `${screenWidth} px` , height: `${screenHeight} px` } trtcComponent.setViewRect({ ...param, streamType: 'main' }) trtcComponent.setViewVisible({ userID: userId.toString(), streamType: 'main' , isVisible: true }) trtcComponent.setViewZIndex({ userID: userId.toString(), streamType: 'main' , zIndex: 1 })
注意事项 在接入 sdk 时遇到了很多问题,这里大概描述下
远程流推送问题 上面说到:this.trtcComponent.publishLocalVideo()
方法集成了:打开摄像头并推送视频
这会有什么问题呢?我们的需求是,面试未开始时,要打开摄像头看到本地流。面试开始时,再展示远程流。 那如果打开摄像头和推送本地流集成在同一个方法里面,在方法调用后,面试还未开始时,其他人就收到了远程流…… 所以基于这点,我在开发时做了兼容,判断业务状态为未开始,则将远程流隐藏。
1 2 3 4 5 trtcComponent.setViewVisible({ userID: userId.toString(), streamType: 'main' , isVisible: false })
至于音频流,在面试开始时再推送就行了。
onHide onShow 问题 这其实是旧版 SDK 的一个bug,在小程序切到后台时,仍然能听到其他人的音频。 当时开发时想当然的选择了个解决方案, 在onHide 时,退出 trtc 房间,等 onShow 时,再重新加入房间,重新走一遍流程。 但又引发了一个新问题,onHide 时,退出房间方法不生效。
后来和官方人员反馈后,他们在文档里加了这么一句话:
https://cloud.tencent.com/document/product/647/17018#exitroom()
再经过一系列扯皮后,他们建议我们在 onHide 时,取消流的订阅,在 onShow 时,再重新订阅远程流
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 subscribeRemote() { if (!this .trtcComponent) { return } const userList = this .trtcComponent.getRemoteUserList() userList.forEach(item => { this .trtcComponent.subscribeRemoteAudio({ userID: item.userID }) this .trtcComponent.subscribeRemoteVideo({ userID: item.userID, streamType: 'main' }) }) }, unsubscribeRemote() { if (this .trtcComponent) { const userList = this .trtcComponent.getRemoteUserList() userList.forEach(item => { this .trtcComponent.unsubscribeRemoteAudio({ userID: item.userID }) this .trtcComponent.unsubscribeRemoteVideo({ userID: item.userID, streamType: 'main' }) }) } },
官方组件问题 在调用层级关系方法时,发现 z-index 属性不能生效,然后看了封装的 trtc-room 组件内部,数据绑定时大小写出了问题。 后来反馈给官方了,等官方修改会比较慢一点,因为是自定义组件,我自己将它改正确了。
https://gitlab.dxy.net/f2e/jobmd_video_interview_weapp/-/commit/401388b40b018bd117ac3b55b6724b0e4602f1d1#606f1ee2910ef3cccd0b51e10973f05baf3d9c3e
当然,组件里还有其他模版,如果你想缩小小程序包体积,可以自行删减。
状态同步 其实小程序官方有自己的配套长链接方案,即时通信 IM
而且还默认集成到了小程序 sdk 中。但由于我们 web 端使用了 stomp,这个默认的方案也就用不了了……
现在回首我们当初的设计,这一步也是之前没考虑周全的,如果当时预想到后面要做小程序,提前调研小程序技术,也许不会使用 stomp,也不会踩下面这些坑点……
stomp 包的问题 stomp 包的引入、使用、优化、和小程序集成都有很多坑点,让我们一起感受感受
兼容问题 在 web 端用了 stomp,那关于 stomp 的代码是不是可以直接复制过来,因为业务逻辑没变嘛,但在第一步引入的时候我就遇到了问题!!!
web 端使用的库是 @stomp/stompjs
,当我把它在小程序直接 install 然后引入后,竟然报错了!
原来是他在处理消息时,使用到了 TextEncoder
和 TextDecoder
方法。
而这个浏览器方法,坑爹的小程序没有。说到底,这两个玩意儿是干啥用的?实际上就是编解码用的,我们可以用 new TextEncoder().encode()
将字符串编码为字节流,如下
1 2 3 4 let encoder = new TextEncoder();let uint8Array = encoder.encode("Hello" );alert(uint8Array);
那怎么解码呢?
1 2 let uint8Array = new Uint8Array ([72 , 101 , 108 , 108 , 111 ]);alert( new TextDecoder().decode(uint8Array) );
这样我们又完成了字节流到字符串的解码。现在看看 stomp 里面是怎么处理数据的:
1 2 3 4 5 6 7 8 9 this ._webSocket.onmessage = function (evt ) { _this.debug('Received data' ); _this._lastServerActivityTS = Date .now(); if (_this.logRawCommunication) { var rawChunkAsString = (evt.data instanceof ArrayBuffer ) ? new TextDecoder().decode(evt.data) : evt.data; _this.debug("<<< " + rawChunkAsString); } parser.parseChunk(evt.data, _this.appendMissingNULLonIncoming); };
既然微信小程序没有,我给你加上不就行了,所以我尝试给全局加上 这两个对象。还真有这个包import {TextEncoder} from 'text-encoding'
但是如何把这两个对象注入全局难倒了我……而且这个包体积巨大,加上后就超小程序包限制,对于 @stomp/stompjs
它的集成到此为止。我意识到得找其他包了。
前面说了,stomp 只是一种协议,对于协议的实现是多种多样的,那有没有其他包也实现了该协议,找了找,还真的有,一个叫做 stomp-websocket 的包。
然后我就开心的用了起来。但它的 api 和 @stomp/stompjs
还是有些不同的。具体体现在 , connect 方法
1 2 3 client.connect(login, passcode, connectCallback); client.connect(login, passcode, connectCallback, errorCallback); client.connect(login, passcode, connectCallback, errorCallback, host);
集成小程序 小程序的 socket 对象没有在全局,而是挂载在 wx 下面。所以我们要对socket 进行封装,让它满足 stomp 的要求
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 socket(url) { let socketOpen = false let socketMsgQueue = [] function sendSocketMessage (msg ) { console .error('msg' , msg) if (socketOpen) { wx.sendSocketMessage({ data: msg }) } else { socketMsgQueue.push(msg) } } const ws = { send: sendSocketMessage, onopen: null , onclose: () => { }, onmessage: null , close: () => { console .error('ws的socket被关掉了' , socketOpen) console .error('ws的socket被关掉了' ) this .socketClose = true socketOpen = false ws.onclose && ws.onclose() } } this .wxSocketTask = wx.connectSocket({ url }) wx.onSocketError(function callback (err ) { console .error('onSocketError' , err) }) wx.onSocketClose(async () => { console .error('socket 关闭>>>' ) this .socketClose = true this .reConnect() }) wx.onSocketOpen(function (res ) { console .log('WebSocket连接已打开!' ) socketOpen = true for (var i = 0 ; i < socketMsgQueue.length; i++) { sendSocketMessage(socketMsgQueue[i]) } socketMsgQueue = [] ws.onopen && ws.onopen() }) wx.onSocketMessage(function (res ) { ws.onmessage && ws.onmessage(res) }) return ws }
使用时:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 const self = this const ws = socket(`wss://${ hostname } /jobmd-online-interview?token=` + self.token)console .log(`开始链接:wss://${ hostname } /jobmd-online-interview?token=` + self.token)self.stompClient = Stomp.over(ws) self.stompClient.connect( '' , '' , function (frame ) { console .error('重连成功' , frame) self.setConnected(true ) console .log('********************* Connected: **********************' + frame) self.initSubscribe() self.socketClose = false }, async (msg) => { console .error('stomp关闭回调' , msg) if (this .socketClose) { this .reConnect() } } ) self.stompClient.heartbeat.outgoing = 2000 self.stompClient.heartbeat.incoming = 2000 }
零宽字符 在 IOS 机型上,socket 会连不上,这又是为啥?
我对比了 IOS 和 安卓机的差异,主要是在 stomp 源码里打断点一个个比较,这叫。。。控制变量法!?
后来在下面的方法里找到了差异。 这里有两个转义字符,LF 是回车,NULL 是一个空字符。这个空字符可不是 ''
,它是具有 length 的,不信试试。'\x00'.length
问题就粗线在 NULL 这个字符上,在安卓上,该字符没被处理,frames 被成功分割成两个数组成员,第二个则是 \x00
,但 IOS 上,该字符被截取掉了,导致了代码后续处理流程除了问题,大概表现为,connect 错误。 所以这里我做了一个 hack。自动给它加上一个空字符。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 Byte = { LF: '\x0A' , NULL: '\x00' }; Frame.unmarshall = function (datas ) { var frame, frames, last_frame, r; frames = datas.split(RegExp ("" + Byte.NULL + Byte.LF + "*" )); r = { frames: [], partial: '' }; frames.length === 1 ? frames.push('' ) : '' ; r.frames = (function ( ) { var _i, _len, _ref, _results; _ref = frames.slice(0 , -1 ); _results = []; for (_i = 0 , _len = _ref.length; _i < _len; _i++) { frame = _ref[_i]; _results.push(unmarshallSingle(frame)); } return _results; })(); last_frame = frames.slice(-1 )[0 ]; if (last_frame === Byte.LF || (last_frame.search(RegExp ("" + Byte.NULL + Byte.LF + "*$" ))) !== -1 ) { r.frames.push(unmarshallSingle(last_frame)); } else { r.partial = last_frame; } return r; };
断线重连问题 当你做完以上后,会发现,socket 链接一段时间就会断掉,这是为什么捏? 原来还是小程序环境导致的,stomp 心跳发送使用的是 setInterval
1 2 3 4 5 6 Stomp.setInterval = function (interval, f ) { return window .setInterval(f, interval); }; Stomp.clearInterval = function (id ) { return window .clearInterval(id); };
小程序哪儿来的 window 对象呢,所以我们还要重写这两个方法 so…
1 2 3 4 5 6 7 8 9 import { Stomp } from './stomp.js' Stomp.setInterval = function (interval, f ) { return setInterval(f, interval); } Stomp.clearInterval = function (id ) { return clearInterval(id); }
断线重连是怎么做的呢? 其实 stomp 集成了心跳检测机制,按道理来说是没问题的,stomp 检测到断连后,前端重新连接就行了 但我们在和后端测试的过程中发现,断线-重连之后,过一段时间又掉线了,如此反复……
这又是为啥捏?
前端:断线:2s -> stomp 检测到掉线了 -> 尝试重连 -> 4s -> 重连成功 后端:断线:20s -> 前端掉线了 -> 踢掉它!!!(但此时前端已经重连上了) 就是这样的前后端检测的时序问题,导致了这个现象,所以我们将时序保持一直就行了 主要是后端缩短掉线检测时间 @杨威 前端增加重连时间
回顾 目前的解决方案不完美,之前在复盘会上我也说了,我们技术选型选的太草率,第三方sdk 很容易跑起来,但pc端/移动端的兼容问题是一个绕不过去的槛儿,之前第三方的选型也是拍脑袋决定的,没有横向对比其他产品。以至于后面在对小程序做开发时,踩了很多坑。主要问题出现在消息同步上
开发下来我心目中的最优选型,应该是 桌面端 electron sdk
,移动端 小程序 sdk
,通信统一用腾讯 IM。而在开发前,要做好一定的技术调研,从兼容性/整体流程上做详细设计。好在小程序目前的方案比较稳定。
年初在做视频面试时,写了不少垃圾代码,不管是参数设计/前端状态管理都很糟糕,我负很大的责任。胡刚最近在着手优化这一部分代码,辛苦辛苦~~
未来 未来我是怎么畅想的?首先桌面端舍弃掉 web 而选择 electron,通信尽量切到 腾讯IM 或者后端同学舍弃单点 socket 服务,转向多点,花精力去搞好 websocket 稳定性。对于我们,要抽空去提升自己的广度,去接触 webGL 去搞 webRTC 去开发游戏 去搞服务器 去玩 electron 以应付未来有可能接到的需求。
对于前端如何写出优雅可维护的代码,也是我们团队值得思考的东西,状态如何更清晰的流转,代码如何最大程度的复用,系统前期该如何设计……我们要做的事情还很多,加油~
青山不改 绿水长流~希望我们都有美好的生活