背景痛点:移动端实时通话的三座大山
做社交小游戏最怕什么?玩家刚开黑就“喂喂喂,听得到吗?”——延迟、设备碎片化、弱网就像三座大山,把实时通话体验压得死死的。
- 延迟:300 ms 是红线,超过就明显“对不上嘴”。Cocos 默认音频管线走原生层,再绕回 JS,一来一回 100 ms 就没了。
- 设备碎片化:Android 机型 2W+,音频采样率 44.1 k/48 k 混用,部分低端机连
OpenSL ES都跑不满。 - 弱网:地铁里 4→3→E 的瞬间,丢包率 15% 起步,直接炸麦。
想靠第三方 SDK 一键解决?钱包先疼一下,而且 SDK 黑盒,调参全靠“玄学”。于是我们把目光投向了开源、可控、还能省预算的 WebRTC——WebRTC。
技术选型:为什么自己撸 WebRTC
| 方案 | 优点 | 缺点 | 结论 |
|---|---|---|---|
| 声网/即构 | 1 小时集成,全球节点 | 包体 +2.5 MB,按分钟计费,码率不可调 | 成本敏感型项目劝退 |
| 原生 WebRTC | 0 授权费,码率/帧率/算法全开放,C++ 源码可裁剪 | 要自己搭信令、补回声、写桥接 | 可控 + 省钱 = 真 bunker |
最终拍板:用 Google WebRTC M110 分支,裁剪后 so 体积 1.1 MB,再包一层 TypeScript 给 Cocos 调用,既省钱又安心。
核心实现一:信令服务器 30 行代码
信令只负责“牵线”,越简单越稳。Node.js + Socket.io 天生支持房间概念,代码直接丢上云函数即可。
// signal-server.ts import { createServer } from 'http'; import { Server } from 'socket.io'; const http = createServer(); const io = new Server(http, { cors: { origin: '*' } }); io.on('connection', socket => { socket.on('join', room => { socket.join(room); socket.to(room).emit('new-peer', socket.id); // 通知房间里其他人 }); socket.on('offer', data => socket.to(data.room).emit('offer', data)); socket.on('answer', data => socket.to(data.room).emit('answer', data)); socket.on('ice', data => socket.to(data.room).emit('ice', data)); socket.on('disconnect', () => { socket.rooms.forEach(r => socket.to(r).emit('bye', socket.id)); }); }); http.listen(3000, () => console.log('signaling ready'));上线前记得把cors收窄到游戏域名,并给socket.io开ping/pong心跳,防止 NAT 超时。
核心实现二:Cocos ↔ WebRTC 的 TypeScript 桥接层
Cocos Creator 3.8 以后能直接拖.ts脚本,省掉一层 JSB。下面把最关键的“创建 Peer”和“收集 ICE”封装成 Promise,方便业务顺序调用。
// webrtc-bridge.ts export class WebRTCPeer { private pc: RTCPeerConnection; private localStream: MediaStream; constructor(private roomId: string, private socket: Socket) { const cfg: RTCConfiguration = { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], bundlePolicy: 'max-bundle', iceCandidatePoolSize: 10 }; this.pc = new RTCPeerConnection(cfg); // 收到远端流就抛给 Cocos Sprite this.pc.ontrack = e => { const remoteVideo = find('RemoteVideo'); // 你的 cc.Sprite 节点 remoteVideo.getComponent(VideoPlayer).clip = e.streams[0]; }; } async startCamera(): Promise<void> { try { this.localStream = await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480, frameRate: 15 }, audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 48000 } }); this.localStream.getTracks().forEach(t => this.pc.addTrack(t, this.localStream)); } catch (err) { console.error('摄像头被禁用或占用', err); throw err; } } async createOffer(): Promise<string> { const offer = await this.pc.createOffer({ offerToReceiveAudio: 1, offerToReceiveVideo: 1 }); await this.pc.setLocalDescription(offer); return offer.sdp!; } // 封装 ICE 收集完成事件 waitIceComplete(): Promise<RTCIceCandidate[]> { return new Promise(resolve => { const cache: RTCIceCandidate[] = []; this.pc.onicecandidate = e => { if (e.candidate) cache.push(e.candidate); else resolve(cache); // null 代表收集结束 }; }); } close(): void { this.localStream?.getTracks().forEach(t => t.stop()); this.pc.close(); } }注意sampleRate: 48000要与 Android 音频通路对齐,否则会出现“花屏式”杂音。
核心实现三:抗抖动——FEC & NACK 参数这样配
WebRTC 默认开 NACK、FEC,但手游场景需要“激进”一点:
const recvOpt: RTCOfferOptions = { offerToReceiveAudio: true, offerToReceiveVideo: true, voiceActivityDetection: false // 关 VAD,减少 CPU 抖动 }; // SDP 钩子:把 opus 丢包保护开到 30% export function patchSDP(sdp: string): string { return sdp.replace(/opus\/48000\/2/g, 'opus/48000/2\\;maxaveragebitrate=32000\\;maxplaybackrate=48000\\;useinbandfec=1'); }同时把RTCRtpSender.setParameters里的transactionId随机化,防止 Android 机型复用失败。
性能优化:回声 & 纹理
Android 端回声消除
国产机 ROM 常把AcousticEchoCanceler默认关,得手动开,还要在AudioRecord构造时把 buffer 设成 2× 周期:
int bs = AudioRecord.getMinBufferSize(48000, CHANNEL_IN_MONO, ENCODING_PCM_16BIT); AudioRecord ar = new AudioRecord(MediaRecorder.AudioSource.VOICE_COMMUNICATION, 48000, CHANNEL_IN_MONO, ENCODING_PCM_16BIT, bs*2); AcousticEchoCanceler.create(ar.getAudioSessionId()).setEnabled(true);C++ 层把audio_attributes的content_type设成AUDIO_CONTENT_TYPE_SPEECH,否则系统仍走媒体通路,回声消除失效。
iOS 端屏幕共享纹理映射
ReplayKit 出来的是CVPixelBuffer,直接塞RTCVideoFrame会卡主线程。用MetalTexture异步转码:
id< CVMetalTextureRef metalTexture; CVMetalTextureCacheCreate(nil, nil, device, nil, &cache); CVMetalTextureCacheCreate(nil, cache, 640, 480, MTLPixelFormatBGRA8Unorm, &metalTexture); RTCVideoFrame *frame = [[RTCVideoFrame alloc] initWithBuffer: [[RTCVideoFrameBuffer alloc] initWithMetalTexture:metalTexture] rotation:RTCVideoRotation_0 timeStamp:CMSampleBufferGetPresentationTimeStamp(sampleBuffer).value];帧率降到 10 fps,CPU 占用从 38% 降到 18%,发热也顺下去。
避坑指南:生产环境 3 大坑
证书过期
现象:ICE 状态一直checking,30 s 后failed。
解决:把stun:stun.xxx.com换成stun:stun.l.google.com:19302先排除证书问题,再续签 Let’s Encrypt,记得fullchain.pem必须带中间证书。ICE 协商失败
现象:同一房间 4G/5G 互拨 80% 超时。
解决:把iceCandidatePoolSize从 10 提到 30,并开prflx打洞;同时把RTCPeerConnection构造放在用户点击事件里,避免浏览器拦截。音频路由错乱
现象:Android 插耳机还外放。
解决:监听AUDIO_BECOMING_NOISY,一插耳机就AudioTrack.setPreferredDevice()切到耳机;Cocos 层再发事件关背景音乐,防止抢占音频焦点。
互动提问:1080P 与低功耗如何兼得?
目前我们把分辨率降到 720P@20fps,把码率压到 800 kbps,iPhone 13 30 min 掉电 9%。如果目标 1080P,还要保证 1 h 游戏后电量 <15%,你会怎么做?欢迎评论区聊聊你的软硬一体思路,比如:
- 用 AV1 硬编是否值得?
- 动态分辨率 + ROI 区域检测?
- 还是干脆降帧到 15 fps,靠超分拉回清晰度?
期待你的方案,一起把“高清”和“续航”这对冤家真正和解。