
最近在做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-APIC代码和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.400SDK选择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全都依赖网络权限不声明的话运行时直接报网络错误而且是那种不容易一眼看出来的错误。动态权限申请建议在进入通话页面前完成用abilityAccessCtrlimport { abilityAccessCtrl, bundleManager, Permissions } from kit.AbilityKit; async function requestPermissions(): Promiseboolean { 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内部持有FrameCallbackC侧注册一个回调当鸿蒙相机通过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: v0..., 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到answerSDP协商在鸿蒙上的对齐细节主叫端创建PeerConnection并发送offerconst 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 });被叫端处理远程offerawait 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的GCCGoogle 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本身带了AECAcoustic 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::vectorwebrtc::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平均CPU12%21%通话耗电约9%约14%视频帧率30fps24fpsRTT40ms60ms丢包率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的生命周期是否与媒体线程同步。把这三个问题在项目早期解决掉后面会顺畅很多。