最近在做HarmonyOS NEXT上的一套音视频通话功能,基础能力基于WebRTC来搭建。先说背景:这不是把Android上的WebRTC库拿过来打个包就完事,鸿蒙NEXT移除了AOSP兼容层,原来的JNI桥、Linux设备驱动接口、OpenSL ES音频链路全都不在了,WebRTC要从编译参数开始重新适配。
这篇文章记录我在这套方案里的完整落地过程,包括技术选型、工程搭建、信令与PeerConnection打通、通话质量优化,以及真机调试时踩过的几个鸿蒙平台特有的坑。如果你正在评估"鸿蒙上要不要上WebRTC",或者已经在DevEco Studio里被编译错误折磨,这篇文章应该能帮你少走不少弯路。
1. 为什么鸿蒙上的WebRTC不是"编译一下就完事"
1.1 从AOSP到鸿蒙内核,音视频链路发生了什么变化
WebRTC在Android上能跑,依赖的是Android系统提供的一整套能力:Camera2采集视频、AudioRecord采集音频、OpenSL ES或AAudio播放音频、MediaCodec做硬编硬解,再加上JNI作为Java层与Native层之间的桥梁。
鸿蒙NEXT把这些底座全换了。第一,已经没有Android兼容层,开发者没法直接复用Android的Camera2和MediaCodec接口;第二,Native开发侧从JNI变成了N-API,C++代码和ArkTS的交互要走napi接口;第三,音频采集播放不再是OpenSL ES,而是鸿蒙的OHAudio或者AudioCapturer/AudioRenderer这套系统API。这些变化对WebRTC来说是伤筋动骨的,因为WebRTC内部本来有大量的平台适配代码,全是针对Android/iOS/Windows/macOS写的,压根没有"鸿蒙"这个编译目标。
所以所谓"鸿蒙上跑WebRTC",本质上是把WebRTC的音频采集、视频采集、编解码、渲染这些环节里的Android实现,全部用鸿蒙的系统API重写一遍,再通过N-API暴露给上层ArkTS调用。想省事,可以直接用社区里已经移植好的OpenHarmony WebRTC源码,但版本一般不会太新,拉下来之后自己还要做不少修补。
1.2 三条可行路线:移植、自研封装、三方RTC SDK
我评估过三条路线,各有适用场景。
第一条是源码级移植。直接从OpenHarmony开源社区拉取third_party_webrtc,按鸿蒙的编译链重新构建成so库,再通过N-API封装给ArkTS调用。这条路最灵活,信令、编码策略、弱网对抗全都能自己控制,但工程量大,编译一次要跑很久,而且每个HarmonyOS版本升级都可能带来API适配问题。
第二条是完全基于鸿蒙系统API自研。用AVCapture采集视频、AudioCapturer采集音频、AVCodec编解码、WebSocket做信令,整个链路自己实现。这条路适合对包体积和功能裁剪有极致要求的场景,比如只做一对一语音,就完全不需要把WebRTC那一大坨视频引擎引进来。缺点是工作量大,Jitter Buffer、带宽估计、回声消除这些WebRTC已经做得很成熟的能力,自研很难达到同等水平。
第三条是接入三方RTC SDK。声网、腾讯云这些厂商都提供了鸿蒙NEXT版本的SDK,内部已经封装好了WebRTC能力,开发快,稳定性也高。缺点是费钱、绑定厂商,而且如果你要做的是纯内部系统、数据不出内网,三方SDK的服务器架构不一定满足要求。
我最终选的是第一条路线:基于OpenHarmony WebRTC源码做移植,自己搭信令服务器。原因是项目要求音视频数据完全走内网,不能依赖公网RTC服务,同时又希望保留WebRTC的带宽自适应和弱网对抗能力。这个选型思路后面我会展开讲,每个环节的取舍都有代价。
1.3 决定技术方案前的成本判断
如果你现在还在方案评估阶段,可以拿这张表做个快速对照:
| 对比维度 | 源码级移植 | 系统API自研 | 三方RTC SDK |
|---|---|---|---|
| 开发周期 | 3到6周 | 2到4个月 | 1周内 |
| 通话质量保障 | 高,依赖WebRTC成熟算法 | 中,Jitter Buffer等需自研 | 高,厂商已调优 |
| 包体积 | 中等 | 小 | 中等偏大 |
| 数据私密性 | 完全可控 | 完全可控 | 依赖厂商服务器 |
| 集成成本 | 高 | 很高 | 低 |
| 长期维护 | 版本升级需自维护 | 所有逻辑自维护 | 跟随厂商更新 |
我的建议是:如果你的业务场景允许把音视频路由到第三方RTC网络,直接选三方SDK,开发效率完全不在一个级别。只有当你有明确的数据私域要求、或者要跟自建媒体服务器对接时,再考虑源码级移植。
2. 工程搭建:DevEco项目里的依赖与权限处理
2.1 开发环境与工程结构
我用的是DevEco Studio 5.0.3.400,SDK选择HarmonyOS 5.0.0 API 12及以上。创建工程时,建议直接选择"Native C++"模板,因为WebRTC核心是C++代码,我们需要在工程里编译C++源文件,并通过N-API暴露接口给ArkTS层调用。
工程关键结构大致如下:
entry/src/main/ ├── cpp/ │ ├── CMakeLists.txt │ ├── native_rtc.cpp │ └── webrtc_wrapper.h ├── ets/ │ ├── pages/ │ │ └── CallPage.ets │ └── services/ │ └── RtcEngine.ts ├── resources/ └── module.json5C++侧的CMakeLists需要把WebRTC的so库链接进来,同时在编译参数里加上必要的宏定义,比如WEBRTC_POSIX、WEBRTC_LINUX这些WebRTC本身依赖的平台宏。不要小看这一步,我第一次编译就漏了WEBRTC_USE_H264,导致后面视频编码器怎么都拉不起来。
2.2 WebRTC库的引入方式
如果你从OpenHarmony社区拉的是源码,那整个WebRTC源码树会作为第三方库放进工程的third_party目录,通过CMake的子工程形式参与编译。这种方式的好处是调试方便,坏处是编译时间非常长,我本地机器首次全量编译用了将近40分钟。
如果你只是想在已有工程里接入WebRTC能力,更推荐直接把编译好的so文件和头文件拿出来做成har包或静态库。我在项目里就是先单独编译一个WebRTC适配中间件,输出librtc_core.so,然后主工程通过CMake链接它。这样主工程的编译速度能控制在几秒内。
C++侧的封装层要处理N-API的注册逻辑,把createPeerConnection、addTrack、createOffer、setRemoteDescription、onIceCandidate这些关键方法暴露出来。ArkTS侧不需要关心WebRTC内部实现,只需要拿到一个RtcEngine对象。
2.3 权限声明:不止CAMERA和MICROPHONE
音视频通话看起来只跟摄像头和麦克风有关,实际在鸿蒙上远不止这两个权限。我在module.json5里声明了这么一组:
{ "name": "ohos.permission.CAMERA", "reason": "$string:reason_camera", "usedScene": { "when": "inuse" } }, { "name": "ohos.permission.MICROPHONE", "reason": "$string:reason_microphone", "usedScene": { "when": "inuse" } }, { "name": "ohos.permission.INTERNET" }, { "name": "ohos.permission.MODIFY_AUDIO_SETTINGS" }INTERNET权限容易漏。信令走WebSocket、媒体流走SRTP/UDP,全都依赖网络权限,不声明的话运行时直接报网络错误,而且是那种不容易一眼看出来的错误。
动态权限申请建议在进入通话页面前完成,用abilityAccessCtrl:
import { abilityAccessCtrl, bundleManager, Permissions } from '@kit.AbilityKit'; async function requestPermissions(): Promise<boolean> { const atManager = abilityAccessCtrl.createAtManager(); const permissions: Permissions[] = [ 'ohos.permission.CAMERA', 'ohos.permission.MICROPHONE' ]; const result = await atManager.requestPermissionsFromUser( getContext(this), permissions ); return result.authResults.every((r) => r === 0); }这里有个体验细节:不要在用户点完"接听"之后再弹权限框,那样会有一两秒黑屏没画面,体验很差。我是在呼叫发起时就把权限申请好,通话页打开时只做检查。
2.4 XComponent:视频渲染面的关键载体
WebRTC解码出来的视频帧要显示到鸿蒙UI上,最直接的方式是用XComponent组件。XComponent本质上是一个可以承载Native层渲染内容的容器,它提供surfaceId给C++侧。
ArkTS侧声明:
XComponent({ id: 'video_render', type: 'surface', libraryname: 'rtc_core' }) .width('100%') .height('100%') .onLoad(() => { RtcEngine.setLocalSurface(this.xComponentController.getXComponentSurfaceId()); })libraryname不是必须的,如果你用ArkTS的XComponentController获取surfaceId再传给C++也可以。C++侧拿到surfaceId之后,通过OH_NativeWindow创建窗口,把WebRTC解码后的VideoFrame渲染上去。
这里最容易踩的坑是:XComponent的surface生命周期和WebRTC的视频轨道生命周期不一致。页面退出时,XComponent会先销毁surface,如果这时候远端视频流还在解码,C++层再往已销毁的surface上写帧,就会crash。我后面会讲具体的处理方法。
3. 打通采集、信令与PeerConnection的主链路
3.1 音视频采集的鸿蒙适配
WebRTC默认的Android视频采集器在鸿蒙上根本不能用。鸿蒙的相机能力通过CameraManager获取,采集到的数据是OH_OutputSurface或者Buffer形式,需要把这些数据转换成WebRTC能消费的VideoFrame。
我封装的思路:在Native层实现一个VideoCapturer,内部持有FrameCallback,C++侧注册一个回调,当鸿蒙相机通过N-API把NV12或NV21数据传上来时,自动填入VideoFrameBuffer并走WebRTC的管道。
音频采集的适配更隐蔽。鸿蒙的AudioCapturer支持PCM数据回调,但默认的采样率、声道数、位深跟WebRTC期望的不一定一致。我统一设置成PCM_16_BIT、48000采样率、双声道,虽然单声道就够用,但鸿蒙某些设备在双声道采集时延迟更低,这个需要实测确认。
采集启动顺序也有讲究。Android上可以先开会话再起摄像头,鸿蒙里如果摄像头已经启动,音频采集启动会比预期慢很多,表现为前几秒只有画面没有声音。我的解决方法是:先启动音频采集,再启动视频采集,反过来才快。
3.2 信令服务器:最小可用设计
WebRTC本身不负责信令,需要自己搭。我选的是WebSocket + Node.js,在双方之间传递三类消息:加入房间、SDP协商、ICE候选。
信令格式做得尽量简单:
{ "type": "join", "roomId": "10001", "userId": "u_001" } { "type": "offer", "sdp": "v=0...", "userId": "u_001", "target": "u_002" } { "type": "ice", "candidate": "candidate:1 1 udp 2113937151 ...", "userId": "u_001", "target": "u_002" }服务端只做转发,不做SDP处理。一对一通话可以直接按roomId选择对端转发;如果将来做多人会议,再在服务端做SFU选路。
ArkTS侧维护一个WebSocket长连接:
this.ws = webSocket.createWebSocket(); this.ws.on('message', (err, data) => { const msg = JSON.parse(data as string); switch (msg.type) { case 'offer': this.handleRemoteOffer(msg); break; case 'answer': this.handleRemoteAnswer(msg); break; case 'ice': this.handleRemoteIce(msg); break; } });信令服务器的选型比较宽泛,Netty、Go、甚至EMQX都可以。关键是信令延迟必须低。如果信令往返超过100ms,用户在拨号界面上会明显感觉"卡了一下"才进入通话,体验很差。我实测Node.js单机在50ms内完全够用。
3.3 从offer到answer:SDP协商在鸿蒙上的对齐细节
主叫端创建PeerConnection并发送offer:
const pc = await RtcEngine.createPeerConnection({ iceServers: [ { urls: 'stun:192.168.1.100:3478' }, { urls: 'turn:192.168.1.100:3478', username: 'user', credential: 'pass' } ] }); pc.addTrack(localVideoTrack); pc.addTrack(localAudioTrack); const offer = await pc.createOffer({ offerToReceiveVideo: true, offerToReceiveAudio: true }); await pc.setLocalDescription(offer); sendMessage({ type: 'offer', sdp: offer.sdp, target: remoteUserId });被叫端处理远程offer:
await pc.setRemoteDescription({ type: 'offer', sdp: msg.sdp }); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); sendMessage({ type: 'answer', sdp: answer.sdp, target: msg.userId });这段逻辑在Android上轻轻松松,但在鸿蒙上有个细节:WebRTC需要对SDP做一些平台相关的修正。比如鸿蒙的视频编码器在某些设备上只支持H.264 High Profile,如果你把Android版的SDP处理逻辑原样搬过来,协商出来的视频编码格式可能在对方的解码器上不支持,表现为黑屏。
我在封装层里加了SDP过滤逻辑,把不支持的编码格式在offer发出前过滤掉。后来发现这也是OpenHarmony社区WebRTC版本跟最新WebRTC版本差异最大的地方之一。
3.4 ICE候选的收集与上报
ICE候选是WebRTC能否打通的最后一步。Trickle ICE意味着候选是陆续到达的,我封装成事件回调:
pc.onIceCandidate = (candidate) => { sendMessage({ type: 'ice', candidate: JSON.stringify(candidate), target: remoteUserId }); }; // 收到远端ICE后 await pc.addIceCandidate(JSON.parse(msg.candidate));这里有个鸿蒙网络权限带来的坑:鸿蒙的INTERNET权限是动态授予的,有些设备上申请权限后需要重新创建WebRTC的IceTransport才能拿到底层网卡列表。我在测试时遇到过一次,开飞行模式再关闭后,ICE候选只返回host类型,没有srflx,最终导致跨网段打不通。重启应用后恢复正常。这个问题的根因是鸿蒙网络状态变化时,原生的ICE枚举没有及时刷新。后来我在应用前后台切换时强制重建ICE,解决了。
4. 通话质量优化:链路容量估计、回声与弱网
4.1 链路容量估计与码率调整
WebRTC内部的带宽估计(Congestion Controller)是一套很复杂的机制,总的原则是:探测链路实际容量,根据丢包和延迟动态调整发送码率。在鸿蒙上,我遇到的最大问题是默认的带宽估计策略不适用于国产手机的网络环境。
部分鸿蒙设备在某些Wi-Fi弱信号场景下,链路丢包率很高但延迟并不明显,WebRTC的GCC(Google Congestion Control)会把丢包误判为拥塞,导致码率被拉升而不是降低。这里的核心原因是丢包不是拥塞导致的,而是无线信号不稳定导致的随机丢包。
解决办法是开启FEC并调整丢包阈值:
webrtc::RtcpRttStats* rttStats = ...; webrtc::Call::Config config(...); config.acknowledged_bitrate_estimator_settings.initial_conservative_bitrate = 100 * 1000;这部分参数我没有直接改GCC的核心算法,而是通过RTPSender的FEC配置来提高抗丢包能力,同时动态调整目标码率。实际上,我建议开发者在做鸿蒙适配时不要轻易动GCC源码,先把编码器的maxBitrate和minBitrate限制好,就能解决80%的问题。
4.2 回声消除在鸿蒙音频设备上的处理
回声是音视频通话开发里最容易被忽视、又最容易翻车的点。手机免提模式下,扬声器播放的远端声音会被麦克风录进去,再传回给远端,就形成了回声。
WebRTC本身带了AEC(Acoustic Echo Cancellation)模块,在Android上默认是启用的。但在鸿蒙上,如果音频采集用的是AudioCapturer,很多适配层为了省事没有把采集到的数据送进WebRTC的音频处理管道,而是直接往编码器塞,AEC等于被绕过了。结果就是:
- 手机听筒模式回声不明显
- 一开免提,对方立刻听到自己的回声
- 声音尖锐且带着金属感
排查方法是用WebRTC的audio_processing模块做处理,AudioFrame数据必须经过ProcessStream和ProcessReverseStream两条链路。我在适配层加了如下确认:
webrtc::AudioProcessing* apm = webrtc::AudioProcessingBuilder().Create(); webrtc::ProcessingConfig pconfig = { {webrtc::StreamConfig(48000, 2), // 本地采集格式 webrtc::StreamConfig(48000, 2), // 远端播放格式 webrtc::StreamConfig(48000, 2), webrtc::StreamConfig(48000, 2)} }; apm->Initialize(pconfig);然后采集线程拿到的每一帧PCM,先调用ProcessStream处理后再送编码器;扬声器播放的远端PCM,在放出去的同时也要调用ProcessReverseStream喂给APM。两条链路缺一条,AEC都是无效的。
4.3 弱网丢包对抗参数调优
弱网下的通话质量,直接决定这个功能能不能用。我做了三轮调优,最终把30%丢包场景下的通话可用性从"完全不可用"拉到了"基本能听懂"。
第一轮,开启NACK(丢包重传)。NACK对随机丢包有效,但对网络拥塞导致的大面积丢包基本无解,因为重传包也会丢。第二轮,开启FEC(前向纠错)。在丢包率超过10%时,FEC的冗余包能撑起一部分语音连续性和视频关键帧。第三轮,则是在鸿蒙上特有的问题:WebRTC的抖动缓冲(JitterBuffer)在系统音频回调时序不稳定时会异常增大缓冲延迟,表现为对方说话一顿一顿。
我的解决办法是限制抖动缓冲的最大延迟:
config.jitter_buffer_max_packets = 200; config.jitter_buffer_fast_accelerate = true;从实际测试看,在20%丢包、200ms RTT的环境下,语音MOS分能从1.8提升到3.2左右。对于鸿蒙设备,我建议测试时重点关注音频延迟曲线,而不是只看丢包率,因为不同设备的硬件Buffer大小差异很大。
5. 真机调试中的踩坑记录
5.1 前后台切换:音频一瞬间断掉的根源
这是我在鸿蒙上遇到的第一个比较隐蔽的问题。通话过程中按Home键切后台,再切回来,经常出现声音"啪"一下断了,然后就一直没声音,但视频还正常。
定位链路花了一个多小时。日志里看到音频采集线程还在跑,PCM数据也在回调,但没有数据送到编码器里。查到最后发现是鸿蒙的AudioCapturer在应用切后台后进入了suspend状态,从代码层面看回调仍然存在,但拿到的PCM数据全是静音零值。
解决思路是监听应用生命周期,切后台时主动暂停发送静音帧,切前台时重建AudioCapturer并重新采集:
import { UIAbility } from '@kit.AbilityKit'; onBackground() { RtcEngine.pauseAudioCapture(); } onForeground() { RtcEngine.resumeAudioCapture(); }一定不要完全销毁再重建整个PeerConnection,那会触发重新协商,远端的画面会卡顿好几秒。只重建采集链路,编码器和网络传输链路保持不变。
5.2 音频焦点冲突与来电场景
通话中突然来了系统电话,或者用户打开了音乐App,音频输出会变得非常混乱。鸿蒙有一套音频焦点机制,类似Android的AudioFocus,但如果应用不主动申请和管理焦点,系统会静音你的AudioRenderer,导致听不到对方声音。
详情见代码:
import { audio } from '@kit.AudioKit'; const audioRendererInterruptListener = (interruptEvent: audio.InterruptEvent) => { if (interruptEvent.eventType === audio.InterruptType.INTERRUPT_TYPE_BEGIN) { // 系统来电或高优先级应用抢占音频焦点 RtcEngine.pauseRemoteAudio(); } else if (interruptEvent.eventType === audio.InterruptType.INTERRUPT_TYPE_END) { // 焦点恢复 RtcEngine.resumeRemoteAudio(); } };这部分的处理策略看业务需要。如果是被系统电话打断,我会在恢复焦点后继续通话;如果是被音乐App抢占,我不做自动恢复,因为这时候用户很可能已经主动切走了音频焦点。
5.3 视频编码器能力差异:不同设备 H.264 档位不一致
鸿蒙设备的视频硬编解码能力方差很大。旗舰机普遍支持H.264 High Profile和部分H.265,但中低端设备可能只支持Baseline。如果按照统一策略下发编码参数,部分设备会直接编码失败,或者编码出来的码流对其他设备不可解。
我的做法是在启动时做能力探测:
std::vector<webrtc::VideoCodecInfo> codecs = encoderFactory->GetSupportedFormats(); for (auto& codec : codecs) { // 记录设备支持的编码器类型与档位 if (codec.name == "H264") { supportedH264Profile = codec.profile; } }协商SDP时,根据能力动态过滤。比如某台设备只有H264 Baseline,我就直接把自己这边的profile-level-id也改成Baseline,避免协商出High Profile导致解码失败。
5.4 日志排查:从RTCStats里读出问题
遇到通话质量问题,不要全靠猜。WebRTC的getStats接口能拿到每一路音视频的发送/接收字节数、丢包率、往返时延、抖动等指标。我在RtcEngine里做了个快照方法:
interface RTCStatsSnapshot { rttMs: number; audioLossRate: number; videoLossRate: number; jitterMs: number; bytesSent: number; bytesReceived: number; frameWidth: number; frameHeight: number; fps: number; }实测中,rttMs突然升高但audioLossRate很低,大概率是网络路径出现拥塞排队;jitterMs持续波动超过100ms,基本就是弱网缓冲调优没到位。鸿蒙端我用定时器每3秒采一次快照,写到日志文件里,排查问题时按时间线拉出来看,比看WebRTC的debug日志直观得多。
6. 性能调优与交付前的收尾
6.1 分辨率动态切换与CPU占用
鸿蒙的中低端设备跑720P编码已经有点吃力,更别提1080P。我在WebRTC的VideoEncoderConfig中设置了动态码控:
pc.setEncodingParameters({ video: { maxBitrate: 1_200_000, // 上限 1.2Mbps minBitrate: 150_000, // 下限 150Kbps maxFramerate: 24 } });同时监听带宽估计事件,当估计带宽低于500Kbps时,主动把采集分辨率降到640x360;高于1.5Mbps时再升回1280x720。实测这个策略能让CPU占用稳定在20%以下,而固定720P在低端机上经常飙到40%以上,导致机身发热明显。
分辨率切换需要注意时机,必须在关键帧边界切换,否则远端画面会花屏。WebRTC内部有OnDroppedFrame回调,我利用它判断当前帧率稳定后再做切换。
6.2 内存和耗电量的实测数据
交付前我做了一轮基础数据测试,设备是Mate 60和一台中端荣耀机型,连续通话30分钟:
| 指标 | Mate 60 | 中端荣耀 |
|---|---|---|
| 内存占用 | 约220MB | 约260MB |
| 平均CPU | 12% | 21% |
| 通话耗电 | 约9% | 约14% |
| 视频帧率 | 30fps | 24fps |
| RTT | 40ms | 60ms |
| 丢包率 | 0.2% | 1.5% |
内存占用比Android同功能应用高一些,主要原因是WebRTC的编码器、抖动缓冲、音视频处理模块吃内存比较多。如果内存敏感,可以通过关闭不必要的视频处理特性来压缩,比如关掉视频降噪和视频美颜扩展,能省下30到50MB。
耗电方面,屏幕常亮加上视频解码是大头。建议通话过程中动态熄灭屏幕或降低屏幕亮度,能比优化编码参数省更多电。
6.3 这个方案后续还能怎么扩展
这套基于WebRTC的鸿蒙音视频通话架构搭好之后,后续扩展天然有路:
- 屏幕共享:鸿蒙的
AVScreenCapture可以采集屏幕画面,通过WebRTC的自定义视频源推流,实现远程演示功能。注意屏幕采集需要用户在设置里授权,这个权限无法通过代码自动申请。 - 会议模式:当前是一对一,扩展多人会议需要把媒体服务器换成SFU,比如接入mediasoup或LiveKit。信令协议需要从"转发"升级为"多路广播",我自己正在评估这条路。
- 跨端互通:WebRTC标准本身保证跨端能力,鸿蒙端和iOS/Android/Web都能通。最大的坑是H.264的profile匹配,鸿蒙端建议固定用H.264 High Profile和iOS对齐,否则部分iOS设备会黑屏。
最后分享一个体会:一开始我试图把WebRTC在鸿蒙上做得跟Android完全一样,后来发现很多API细节对不齐,反而是在面对问题时把架构里本可以被隐藏的耦合点暴露了出来,这其实是件好事。如果你的项目正处于选型阶段,或者已经遇到了采集、渲染、音频焦点的问题,建议优先关注这几个方向:确认你的WebRTC源码版本是否跟上鸿蒙API版本、音频采集链路是否完整经过APM处理、XComponent的生命周期是否与媒体线程同步。把这三个问题在项目早期解决掉,后面会顺畅很多。