前几个月的一件事让我措手不及:客户要求在一个维护了很久的UniApp项目里加入语音呼叫功能。之前接触的SIP集成,基本都在原生App或网页上,但UniApp这个跨平台框架,想跑通SIP并不单是把JS包拿来调一下的事。Web端的VoIP方案在浏览器里有现成的JS库,可一旦落到Native App,音频采集、播放、权限管理都得走原生层,UniApp默认的WebView渲染环境根本没法直接接触这些底层能力。查了几晚资料,最终靠RenderJS把JS逻辑和原生音频模块之间的通信打通,配合四方云SIP服务,总算是交付了一个可以稳定通话的版本。这篇东西就从实际落地方案的角度,把完整思路和关键代码拆给你看,适合那些在UniApp里做呼叫功能、又不想把整个项目拆成原生双端重写的团队。
1. 在UniApp里做SIP通话:为什么不是去npm装个库这么简单
普通的前端开发者拿到SIP需求,第一反应大概率是去找一个能在浏览器里跑的JS SIP客户端,比如JsSIP。可UniApp发布到App端之后,页面本身是在一个WebView里渲染的,浏览器环境能用的大部分SIP库在这里确实能跑起来,但通话需要麦克风、扬声器、来电铃声这些原生硬件能力,WebView并没有直接给你访问权限。有人尝试用H5+的plus.audio或者促进webview与原生交互的扩展插件,绕来绕去还是会碰壁,原因不在JS,而在底层音频链路的接管问题。
1.1 原生语音引擎和WebView是两个隔离的世界
在iOS和Android两个平台上,语音通话对音频会话的管理方式完全不同。Android上要抢AudioRecord和AudioTrack,iOS上要用AVAudioSession,如果只是把通话数据交给WebView里的JS去处理,声音一出来就变成外放而没法切到听筒,或者是来电时铃声和通话音叠加在一起,这种体验顺滑不了。于是自然的思路是,原生侧做一个语音引擎,把SIP信令和音频都接管过来,而UniApp侧负责显示界面、响应按钮和拿到拨号号码。问题来了,这两边怎么通信?
直接采用UniApp的标准事件绑定(比如uni.$emit)只能做应用级别的全局通知,拉起App原生层的VoIP服务并不适用,因为这样一个事件根本传不到原生模块内部,必须有一个专门的桥接层。
1.2 RenderJS在这里承担的角色
UniApp一直有个比较冷门但很扎实的用法:RenderJS。在App平台下,页面里的js模块其实有两套环境:一套是逻辑层的JS,一套是视图层的JS,RenderJS运行在视图层的WebView上下文里,它比普通逻辑层JS拥有更多与原生组件交信的能力。通过在页面里嵌一个renderjs模块,就可以把它当成一个“通信中转站”,逻辑层JS把拨号意图传给renderjs,renderjs再调用App的原生扩展方法,把真正的话音数据交给原生音频引擎。
这也是为什么这个方案的标题里特意点出RenderJS——它虽然没有直接去操作麦克风,但它是整个语音链路里,逻辑和底层能够握手的关键。
2. 四方云SIP服务的接入配置实战
四方云作为云通话服务商,提供的是标准的SIP协议接入,简单来说你只需要申请一个SIP账号,拿到服务器地址、端口、账号和密码,然后交给原生语音引擎去注册和拨号。配置工作主要分成两个层面:四方云后台上配置一个可供App进线的SIP分机,然后在UniApp项目里把相关参数固化到配置文件里。
2.1 四方云控制台准备什么参数
先到四方云的控制台上开通一个SIP中继或SIP分机。这个东西不同平台叫法不太一样,有的叫“语音网关”,有的叫“话务分机”,但本质都是分配一组用于SIP注册的凭证。需要重点记下的参数是:
- SIP服务器地址(例如sip.xxx.com,实际值按你的套餐决定)
- 端口(UDP,一般5060或者平台自定义)
- 分机号码/账号名
- 认证密码
- 拨号外显号码,如果需要在通话时让对方看到指定的主叫号码
这些参数后面都要写进App的配置里。注意不同云平台对SIP传输协议的要求不一样,有的默认强制TLS,有的允许UDP,四方云这边我们用的是UDP,延迟和兼容性都更稳。
2.2 UniApp项目里的持久化配置
我不建议把SIP账号密码硬编码在页面js里,一是维护麻烦,二是改动需要重新发版。我习惯将配置单独抽成一个配置文件,放在公共目录下,方便统一管理。实际项目里可以把它放在common文件夹下,新建一个voiceConfig.js:
export default { sipServer: 'sip.xxx.com', sipPort: 5060, account: '10086', // 替换成你的分机账号 authId: '10086', password: 'your_password', displayName: '客服热线', codec: ['PCMU', 'PCMA', 'opus'], // 按服务端支持的编码排序 }然后在页面里通过import引入这个配置,传给renderjs模块。有一点提醒:如果你的项目在多端运行,配置文件的读取要注意区分环境,至少在App端和H5端的配置结构要保持一致,不然到后面调试会混乱。
2.3 原生模块需要的动态权限
除了配置文件,最重要的是在App启动时去声明麦克风和录音权限。UniApp的manifest.json里需要这样配置:
"app-plus": { "distribute": { "android": { "permissions": [ "<uses-permission android:name=\"android.permission.RECORD_AUDIO\"/>", "<uses-permission android:name=\"android.permission.MODIFY_AUDIO_SETTINGS\"/>" ] }, "ios": { "privacyDescription": { "NSMicrophoneUsageDescription": "需要麦克风权限用于语音通话" } } } }这个如果不配,原生模块就算写好了,跑到真机上一调用录音功能,直接闪退。很多问题最后排查半天,其实就是权限这里漏了。
3. RenderJS通信层:让JS的呼叫意图真正落地到原生语音引擎
既然要基于RenderJS做桥接层,就得先明白renderjs在UniApp里的运行方式。传统页面里,逻辑层js和视图层js是可以互相调用的,但在Native App上,为了性能,UniApp把逻辑层和渲染层分开了。renderjs运行在视图层,它能访问页面DOM,又能通过plus桥接原生能力,这样就把“拨号”这个事件从逻辑层传过来,再交给原生语音引擎处理。
3.1 页面里埋一个renderjs模块
下面是一段来自实际项目的页面结构,目的是展示renderjs模块如何使用:
<template> <view class="call-page"> <!-- 这里会放拨号盘、通话状态区域等 --> <view>{{ callState }}</view> </view> </template> <script> // 逻辑层脚本 import voiceConfig from '@/common/voiceConfig.js' export default { data() { return { callState: 'idle', phoneNumber: '' } }, methods: { startCall(number) { // 把拨号意图传给renderjs模块 if (typeof this.$scope !== 'undefined') { // 实际上是通过某种事件机制触发renderjs里的方法 this.$scope.renderVoiceCall && this.$scope.renderVoiceCall(number) } } } } </script> <script module="voiceRender" lang="renderjs"> // renderjs模块,运行在视图层 export default { methods: { onCallStart(number) { // 这里调用原生提供的语音引擎插件 const engine = require('PLUGIN_VOICE_ENGINE'); // 插件名示意 engine.call(number) } } } </script>上面的写法里,module="voiceRender"就是renderjs定义的模块名。逻辑层并不能直接定义它的方法调用,实际应用中,我会通过让renderjs监听一个全局对象或使用事件总线来接收消息,但原理在这里是相通的:renderjs把从逻辑层拿到的号码,通过原生插件的API发起呼叫。
3.2 用plus.bridge而不是plus API直接调
很多UniApp的入门教程里都讲plus,但plus暴露出来的能力更多偏向安卓或iOS的WebView控件操作,像打一个SIP电话这种事还是要走原生插件。你可以自己写一个UniApp原生插件,也可以在renderjs里封装一个桥接函数,把调用转发给原生插件。关键点是,要让renderjs能够正确识别原生插件的实例。
实践里比较可靠的模式是,在renderjs的created生命周期里先完成插件初始化:
export default { data() { return { engineInstance: null } }, created() { // 在视图层加载完成后初始化原生插件 this.engineInstance = require('PLUGIN_VOICE_ENGINE') this.engineInstance.init({ sipServer: 'sip.xxx.com', account: '10086', password: '******' }) }, methods: { onCallStart(number) { if (this.engineInstance) { this.engineInstance.startCall(number) } }, onHangup() { this.engineInstance.hangup() } } }3.3 数据回传:通话状态如何反馈到界面
通话不能只要一个拨出动作,你还得知道是已接通、无人接听、还是占线。原生引擎的回调事件,也要经过renderjs再传给逻辑层,由逻辑层更新UI状态。这里可以采用window上的事件监听方式:
// 原生引擎回跳 this.engineInstance.on('stateChange', (state) => { // 直接丢给逻辑层处理 window.webviewMessage.postMessage(JSON.stringify({ type: 'callState', data: state })) })在逻辑层里通过监听window的message事件去解析,再更新data里的callState。这样一个循环就通了:操作发生在页面,原生引擎执行,状态回到页面显示。
4. 通话状态机与UI层的配合实现
语音呼叫功能的坑往往不在网络层,而在于界面状态的管理。从用户点下拨号按钮到挂断,界面至少要经历空闲、拨号中、振铃中、通话中、通话结束这几个状态。你可以在state字段里维护一个state枚举,配合定时器来控制界面展示。
4.1 用一套状态枚举去驱动UI
不不建议在页面里东写一个v-if、西写一个v-show,太不可控。我更喜欢集中定义一个状态映射,页面模板只跟映射表打交道:
const CALL_STATES = { IDLE: 'idle', DIALING: 'dialing', RINGING: 'ringing', ACTIVE: 'active', ENDED: 'ended' }模板里对应的按钮显示逻辑:
<view v-if="callState === CALL_STATES.IDLE" @click="startCall(inputNumber)">拨号</view> <view v-else-if="callState === CALL_STATES.ACTIVE || callState === CALL_STATES.DIALING" @click="hangup()">挂断</view>这样有几个好处:一是调用renderjs的地方只需传一个目标状态,原生引擎不知道界面状态变化;二是如果平台差异导致某些界面状态需要单独适配,改动只集中在模板局部,不涉及通信层。
4.2 通话计时和来电振铃的处理
通话接通之后,界面上一定需要一个计时器。但不建议在renderjs里不小心把计时器加到视图层,这会影响渲染性能。我反而建议把计时器放在逻辑层,接通的瞬间让逻辑层开启一个setInterval,每秒更新通话时长。挂断之后记得清除。
来电场景就要更谨慎一些。四方云的SIP通道在收到呼入时会先推送到原生引擎,原生引擎发起一个本地通知或者事件,此时renderjs要感知到这个事件,再把“来电”状态推到界面上。实际项目里我用的是一个全局广播机制,原生插件把来电号码通过uni.$emit('incomingCall', {number})发到逻辑层,逻辑层再诱导renderjs去启动振铃。
4.3 音频路由切换的界面按钮
通话过程中经常需要做到“免提”和“听筒”的切换。这个操作由原生引擎管理更可靠,界面只做一个切换按钮,点击之后调用renderjs里的一个切换方法,由原生引擎去调用系统的音频路由设置。来回切换会导致偶发性的采音失真,所以切换后要有一定延迟再去检测回调。
经过这样的折腾,UI状态和原生引擎的状态总算是配合了起来,但这只是开始。真机调试的时候,问题比我想象中还多。
5. 真机调试中的五个高频坑
把项目编译到真机,测试拨号、接听、挂断、切免提全链路,才发现很多坑只有在设备上才会出现。这里挑五个我踩得最深的,给后面要接手这个方案的人提个醒。
5.1 后台运行时麦克风权限被自动收回
这是Android系统很让人头疼的一点,App跑到后台再切回前台,麦克风权限可能被系统回收或冻结,导致通话静音但不掉线。解决方式是在进入后台之前,通过renderjs调用原生插件释放音频资源;回到前台时重新初始化,这总比你让用户手动重启App强。
5.2 来电时铃声和SIP通话音冲突
四方云服务端在呼叫到达时,后台可能会同时下发一个Push通知和SIP INVITE,如果你原生引擎处理顺序不对,用户的手机先收到系统来电铃声,再播放SIP通话声音,就会出现杂音重叠。我的处理方式是把系统来电铃声的播放权交给原生引擎,在收到INVITE的一瞬间主动启动自己的铃声文件,同时屏蔽系统通知声音。
5.3 WebView里的自动播放限制
有些Android机型对自动播放音频限制很严格,特别是在没有用户手势的情况下播放来电铃声。renderjs裡的JS代码因为运行在WebView里,一样会受到这个限制。所以只靠audio元素去播放铃音不可靠,我最后还是先把铃声放到原生插件里,由原生播放。
5.4 通话过程中锁屏导致renderjs实例被销毁
锁屏或者切到后台再回前台,WebView有很小的概率被销毁重建,renderjs也跟着没了,但原生引擎还在通着话。恢复时如果renderjs没有自动初始化回来,界面就卡住不动。解决方法是:通话期间不销毁renderjs实例,在页面生命周期里监听resume事件,触发renderjs重新绑定引擎状态。
5.5 WebSocket和SIP长连接在弱网下的黏连问题
SIP的注册信令和呼叫信令都走长连接,Wi-Fi和4G切换时会断开。四方云平台自身有重连机制,但App端也需要在断网切换时去做重注册。我在renderjs里加了心跳监测,每30秒确认一次注册状态,如果发现断线,立即重连并重新注册。这会提高一些电量消耗,但在呼叫体验面前还是值得。
为了方便排查,我把常见的异常情况和处理动作整理成了一张表:
| 问题现象 | 可能原因 | 处理方式 |
|---|---|---|
| 拨号无声 | 麦克风权限未开启 | 检查manifest.json权限配置 |
| 通话断续 | 弱网导致SIP包丢失 | 开启服务端码率自适应,客户端降低采样率 |
| 来电无振铃 | WebView音频自动播放限制 | 改用原生接口播放铃声 |
| 切后台静音 | Android后台回收音频焦点 | 后台时释放focus,前台重新获取 |
| 挂断后仍在计费 | 通话状态回调丢失 | 服务端对账回调为准,客户端强制挂断 |
6. 从单方通话到多方会议的扩展思路
项目上线之后,需求方十有八九会问一句:“能不能加个多方通话?”配置上其实是支持多方通话的。核心改动点主要在原生引擎的Session管理上,renderjs层需要维护一个通话对象集合,每路通话有自己的状态机。
6.1 会话模型从单路改成多路
刚开始写的时候,会假设只有一个呼叫会话,数据结构简化成一个状态字段。做多方之后,要把callState改成一个列表,每个元素携带sessionId、peerNumber、state、callDuration。页面上的展示区域也要跟着拆分,比如用v-for循环渲染多个通话卡片。
// 改造后的会话数据 { sessions: [ { id: 'session1', peer: '1001', state: 'active', duration: 135 }, { id: 'session2', peer: '1002', state: 'dialing', duration: 0 } ] }renderjs层与原生引擎的通信也要扩展,每次操作都要带上sessionId,才不会让各路通话互相串话。
6.2 通话合并和单独摘挂
多方会议里最基础的功能是,把当前通话保持,再呼出第二路,最后合并成三方通话。原生引擎一般都会提供hold,unhold,merge,transfer之类的SIP方法。renderjs里的调用逻辑就是把这些方法封装成统一的action接口,让页面上层只管发起动作,不关心底层信令细节。
6.3 内存和性能控制
多路通话的状态同步频率会指数级增加,如果每100毫秒就同步一次各路状态,WebView的负担很容易撑爆。我在实际优化中是把同步频率降到500毫秒,并且只在状态变化时推送,界面刷新自然也没问题。如果一个会话长期处于闲置状态,优先考虑从内存中卸载,而不是一直挂着。
我把这套方案跑通了之后,最大的感触是:UniApp虽然不像原生那么自由,但借助RenderJS这个桥接角色和原生插件的配合,依然可以做出接近原生体验的SIP通话功能,关键是要把原生边界想清楚,别把所有事情都丢给JS层去干。最后再分享一个小技巧:编写renderjs模块时,多放几个事件回调到原生插件侧,很多看似棘手的边界情况,其实都是靠这些回调兜住的。