鸿蒙视频通话与屏幕分享实战:从采集到渲染的完整链路解析
做鸿蒙视频通话和屏幕分享这个功能我最初的直觉是“直接调系统播放器 摄像头 API 就行”等到真正把通话双方的画面拉通、把屏幕分享切进去、把回声和杂音调干净之后才发现这里面横着一条完整的音视频工程链路。这篇文章不谈空泛的概念只讲我自己在 HarmonyOS 上用 ArkTS 落地视频聊天和屏幕分享时的方案选择、链路设计、关键代码和踩坑记录给准备入坑或正在入坑的朋友一条能直接参考的路线。这篇文章适合三类人一是产品里需要做一对一或多人通话的鸿蒙应用开发二是想打通“主叫 - 被叫 - 媒体通道”完整闭环的人三是已经被系统屏幕采集、摄像头切换或音频路由坑过一轮想找个方向再试的同学。我会尽量把每个“为什么这么做”都说清楚因为很多问题不是代码写错了而是对链路理解偏了。1. 视频通话的整体设计先搞清楚一帧画面是怎么传到对面的视频聊天不是“把摄像头预览发给对方”这么简单。摄像头采集到的数据是一帧帧原始图像体积非常大直接走网络根本不现实。鸿蒙端口的完整链路是采集 → 前处理 → 编码 → 打包 → 网络传输 → 解包 → 解码 → 渲染再加上音频同步和信令控制才构成了一个能用的通话系统。用一个快递的比方来理解摄像头是发货仓库编码器是打包车间把巨大的视频帧压缩成小包裹RTC 通道是快递干线信令服务则相当于下单系统——它不搬运包裹本身只传递“谁要发货、发到什么地址、包裹什么时候出发”这类控制信息。接收方拿到的包裹要先拆包解码再摆上货架渲染。音频和视频是两条包裹线必须对好时间戳否则画面和声音就会错位。1.1 架构选型全自研、半自研还是直接上 RTC SDK这是所有入坑者第一个要拍的板。鸿蒙系统本身提供了底层的音视频采集和播放能力但它没有直接给你一套“拨号即通话”的标准组件就像你有了水龙头和管子还得自己设计整套供水系统。方案工作内容工程量适合场景全部自研采集、编码、传输、丢包重传、回声消除全自己写极大至少是 5 人团队半年起步有自研音视频引擎积累想完全掌控链路半自研用鸿蒙原生能力做采集和渲染自己维护媒体传输与信令较大需要处理弱网和兼容性对质量要求极高且愿意长期投入接入 RTC SDK只写采集输出、渲染回调、房间控制和 UI小集中在业务侧绝大多数应用场景尤其是产品验证期我做的时候选择了“鸿蒙原生采集 RTC SDK 通道”的半自研方案。理由很实际RTC 的核心能力不在“能不能采到画面”而在“网络抖动 200ms 时画面还流畅不流畅、丢包 20% 时声音还连续不连续”。这些不是短时间能写好的直接用成熟的传输层方案把精力省下来放在业务上。如果你做的是局域网 Demo 或纯粹技术验证那用 WebSocket 手动传帧也够但要真上线一定要评估清楚自研传输的长期成本。1.2 媒体服务器选型P2P 直连还是走 SFU媒体传输模式决定了你服务器的压力和客户端的带宽要求。P2P 直连的优势是省服务器带宽两个人通话时延迟也低但问题是 NAT 穿透不一定成功多人通话时每个客户端都要给其他人各传一路数据上行带宽压力很大。SFU选择性转发单元则是所有参与者的音视频都汇到服务器服务器再按需转发给其他人这是当前主流多人通话的标准做法。在鸿蒙端的实现里这个选择基本由 RTC SDK 决定。我做的是小规模私密通话默认走 P2P失败时自动切换 SFU 转发。信令服务需要同时维护这两种模式的状态字段P2P 模式下信令主要用于交换 SDP 和 ICE Candidate。2. 采集端实现摄像头、麦克风、权限与生命周期管理采集是整个链路的第一环也是崩溃和黑屏问题的高发地段。鸿蒙的权限模型和 Android 一脉相承但又有些细节差异比如部分权限需要加入到module.json5里声明运行时还要弹窗授权配置漏一处API 调用就会静默失败。2.1 权限声明与动态申请视频通话至少需要相机权限和麦克风权限。屏幕分享还需要额外的录屏权限。在module.json5中这样声明{ requestPermissions: [ { name: ohos.permission.CAMERA, reason: 用于视频通话画面采集, usedScene: { abilities: [EntryAbility] } }, { name: ohos.permission.MICROPHONE, reason: 用于视频通话语音采集, usedScene: { abilities: [EntryAbility] } }, { name: ohos.permission.CAPTURE_SCREEN, reason: 用于屏幕分享画面采集, usedScene: { abilities: [EntryAbility] } } ] }动态申请处我建议做一个统一入口不要等用户点“通话”按钮那一刻才弹窗比较稳妥的做法是进入通话页面前先走一遍权限预检和预申请提前暴露拒绝风险。import abilityAccessCtrl from ohos.abilityAccessCtrl; async function requestPermissions(context: common.UIAbilityContext): Promiseboolean { const atManager abilityAccessCtrl.createAtManager(); const permissions: ArrayPermissions [ ohos.permission.CAMERA, ohos.permission.MICROPHONE, ohos.permission.CAPTURE_SCREEN ]; const result await atManager.requestPermissionsFromUser(context, permissions); const authResults result.authResults; return authResults.every(item item 0); }注意CAPTURE_SCREEN屏幕采集权限的弹窗文案必须和你的场景保持一致系统会非常明确地告诉用户有人正在录屏这个提示无法绕过实际使用时要提前做好用户心理预期引导。2.2 打开摄像头并建立预览鸿蒙的摄像头接口在ohos.multimedia.camera下。核心对象有三个CameraManager 负责管理设备列表CameraInput 代表一路摄像头输入PreviewOutput 负责把画面输出到指定 Surface。下面的代码展示了最简流程import camera from ohos.multimedia.camera; import { BusinessError } from ohos.base; async function initCamera(surfaceId: string, context: common.UIAbilityContext) { const cameraManager camera.getCameraManager(context); const cameras cameraManager.getSupportedCameras(); if (cameras.length 0) { throw new Error(未检测到摄像头设备); } const cameraInput cameraManager.createCameraInput(cameras[0]); await cameraInput.open(); const previewOutput cameraManager.createPreviewOutput( { width: 1280, height: 720 }, surfaceId ); const session cameraManager.createSession(camera.SceneMode.NORMAL_VIDEO); session.beginConfig(); session.addInput(cameraInput); session.addOutput(previewOutput); await session.commitConfig(); await session.start(); }这里的surfaceId来自 XComponent 组件等下讲渲染时会详细说。比较关键的是SceneMode.NORMAL_VIDEO如果你只做拍照 Preview 却选了这个模式续航和发热会有额外开销如果做视频通话选成了 PHOTO又会发现帧率上不去。2.3 设备切换、前后摄切换与异常处理前后摄切换是视频通话的高频操作。不要直接关闭当前 CameraInput 再重新创建正确做法是先获取当前输入和会话把输入从会话中移除后再新增。我用一个简单的函数封装了切换逻辑async function switchCamera( session: camera.CameraSession, cameraManager: camera.CameraManager, oldInput: camera.CameraInput, newCameraIndex: number ): Promisecamera.CameraInput { const cameras cameraManager.getSupportedCameras(); const newInput cameraManager.createCameraInput(cameras[newCameraIndex]); await newInput.open(); session.beginConfig(); session.removeInput(oldInput); session.addInput(newInput); await session.commitConfig(); await session.start(); await oldInput.close(); return newInput; }摄像头还可能出现被其他应用占用、系统温控强制关闭等异常。建议统一监听 CameraManager 的on(cameraStatusChange)一旦状态变为 unavailable立刻给对方推一条“摄像头异常”的扩展消息而不是让对方一直看黑屏。3. 对端画面渲染XComponent 是核心容器渲染端是鸿蒙和 Android/iOS 差异最明显的地方。鸿蒙没有一个像 iOSUIView那样可以直接嵌进任意布局的通用视频视图在 ArkUI 里默认场景是使用 XComponent 绑定一块原生 Surface/Texture然后把surfaceId传给摄像头 PreviewOutput 或 RTC SDK 的远端渲染器。3.1 创建远端画面 XComponent我通常维护一个渲染管理器动态创建和销毁 XComponent ID避免多个通话用户在同一个页面堆叠大量原生视图造成卡顿。一个远端用户对应一个 XComponent。Builder RemoteVideoView(userId: string) { XComponent({ id: remote_ userId, type: XComponentType.SURFACE, libraryname: rtc_render_plugin }) .onLoad((context: XComponentContext) { this.rtcEngine.setRemoteRender(userId, context.surfaceId); }) .width(100%) .height(100%) }type字段有两个选择SURFACE 和 TEXTURE。SURFACE 的渲染性能更高安卓系开发者也比较熟但它无法被 ArkUI 直接做圆角裁剪、旋转矩阵等视觉效果TEXTURE 可以当作普通纹理做更多 UI 操作但多一路纹理拷贝延迟和 CPU 占用会稍高。视频通话的主画面我推荐 SURFACE小窗预览画中画可以用 TEXTURE。3.2 一主多辅的布局策略通话界面经常要处理“一个人主讲、其他人小窗”的动态布局。ArkUI 里可以用Grid或Flex动态摆放 XComponent但反复切换布局会导致原生 Surface 重建一不留神就会闪黑屏。我的处理方式是把 XComponent 的宽高用百分比约束好主画面和一个备选画面常驻两个组件切换时只改变它们的zIndex、宽高和可见性而不是删除重建。实测下来切换速度明显更快闪黑问题也大幅减少。3.3 远端画面渲染的常见坑绿屏/黑屏通常是对端没有成功推流或本端拿到 surfaceId 的时机早于 XComponent 加载完成。一定要在onLoad回调里再把 surfaceId 传给 RTC 引擎。画面拉伸远端视频分辨率变化时需要按视频实际宽高比动态调整 XComponent 的尺寸策略否则画面会被压扁。RTC SDK 通常会有onVideoSizeChanged回调在这个回调里更新组件宽高比。首帧慢检查是否启用了硬解还有是否在on(firstFrameRendered)前渲染了占位图。设置占位图没问题但不要在收到首帧后还保留它有的版本下占位图和视频层会产生 Z 序冲突。4. 屏幕分享把手机屏幕变成一路可切换的视频源屏幕分享和视频通话的最大区别在于画面源从摄像头变成了系统屏幕。实现上并不需要“切换采集设备的物理类型”而是相当于给对端多推送了一路新的视频流由接收端决定显示哪一路。也就是说屏幕分享更像“视频源的切换 同屏双推”而不是必须停掉摄像头。4.1 鸿蒙屏幕采集接口的使用流程我用的是系统屏幕采集能力ohos.multimedia.avScreenCapture。前置条件有三个应用处于前台、已获得CAPTURE_SCREEN权限、用户在系统弹窗中确认录屏。弹窗由系统自动发出应用无法定制。import avScreenCapture from ohos.multimedia.avScreenCapture; async function startScreenCapture(context: common.UIAbilityContext): PromiseavScreenCapture.AVScreenCapture { const capture await avScreenCapture.createAVScreenCapture(context); const config: avScreenCapture.ScreenCaptureConfig { audioCaptureInfo: { audioSampleRate: 48000, audioChannels: 2, audioSource: avScreenCapture.AudioSource.SOURCE_DEFAULT }, videoCaptureInfo: { videoFrameWidth: 720, videoFrameHeight: 1280, videoSource: avScreenCapture.VideoSource.SOURCE_SURFACE_RGBA } }; await capture.init(config); await capture.start(); return capture; }细心的人会注意到配置里同时开了 audio 采录这里要特别小心。如果你只想分享屏幕不说话系统录屏的音轨可能会把本机出声也采进去对端会听到“自己说话的回声”。我的做法是分享屏幕时默认关闭 Audio Capture仅推视频流如果业务上需要同时分享屏幕和系统声音就要确保对端做了端侧回声消除。4.2 屏幕采集帧率的取舍屏幕和摄像头不同长时间高频刷新对硬件压力大而且屏幕内容很多都是静态的没有必要满帧跑。通话场景下 15fps 是一个折中点——动态演示基本够用码率又能压到可以接受的范围内。要注意的是videoFrameWidth和videoFrameHeight不一定要等于屏幕物理分辨率很多设备录 1080p 屏幕再编码压力很大用 720p 会明显更稳。如果屏幕内容主要是文字、PPT也可以通过调节 GOP 和码率控制来降低带宽。固定码率 1Mbps 左右对这种内容观感不差但如果是游戏画面或视频播放建议开到 2Mbps 以上。4.3 从摄像头切换到屏幕分享的完整流程切换动作不能是“拔掉摄像头再推屏幕”否则对端会有几秒黑屏。稳妥做法是让新视频流先完成首帧推流再通知对端切换显示画面最后关闭旧视频流。我封装的状态机如下用户点击“开始分享”。保存当前摄像头流的编码器句柄和发送状态告诉对方“即将共享屏幕”。启动屏幕采集把采集到的帧送入同一个 RTC 引擎但推到一个新的流 ID。等对端回调“收到新视频流”且已经渲染出首帧后再通知对端将主画面切换为新流同时关闭摄像头推流。这个顺序的好处是全程无缝唯一要留意的是网格布局里的缩略图也要跟着切换否则用户在选看小窗时会看到错乱画面。4.4 屏幕分享的隐私保护录屏权限拿到之后系统虽然会显示状态栏提醒但应用层还是要做一道自己的隐私保护分享期间建议隐藏所有包含账号密码、验证码类内容的弹窗。比较粗暴但在小型团队很实用的方式是在弹这些组件时给屏幕采集 Surface 覆盖一层纯色遮罩或者先暂停推流再弹出敏感页。5. 信令服务与通话状态控制看不到但决定成败的环节很多人以为 RTC SDK 装好就能通话了实际上缺少信令服务双方根本不会“认识”彼此。信令服务负责房间管理、成员状态同步、通话邀请/接听/挂断等。它的可靠性直接影响通话质量。5.1 最小信令协议设计我把信令消息统一为 JSON 格式每个消息都带一个msgType和callId。callId是一次通话的唯一标识所有后续消息都绑定这个 ID这样可以避免串线。{ msgType: call/invite, callId: a1b2c3d4-1234-5678-9abc, from: userId_1001, to: userId_1002, payload: { roomId: room_001, mediaType: video, initiator: userId_1001 } }通话邀请发出后被叫方需要返回call/accept或call/reject。主叫方收到 accept才带着房间号和媒体参数去调用 RTC SDK 的加入房间接口。顺序不能反先入会再通知对方的话会造成对方入会时找不到人。5.2 断线重连与状态补偿移动网络环境不可能永远稳定。鸿蒙应用在后台被系统挂起或网络切换时通话会中断。RTC 层有通信断线回调信令层也需要把“离线/回来”的状态同步给房间里的其他人。我维护了一张房间状态表每个成员定期上报心跳。如果 10 秒内没有收到某人的心跳其他端就显示“对方网络不佳”。收到恢复报文后再做一次媒体流重订阅。不要等用户已经听不清声音了才处理提前降级展示能显著降低负面体验。5.3 信令通道与媒体通道分离信令使用 WebSocket 长连接就够了不要用媒体通道传信令。RTC 通道为了低延迟有优先级控制和丢包重传策略把信令混进媒体通道会出现排队阻塞比如双向通话已经把带宽占满时信令反而发不出去用户会看到“挂断无响应”的诡异问题。6. 音频处理视频看着挺好但一提声音就暴露问题音频调试比视频更玄学。视频问题通常能看到黑屏或者花屏音频问题往往表现为“有一点回声”“声音发闷”“对方声音断续”这些描述很难快速定位。6.1 回声消除、噪声抑制和自动增益RTC SDK 一般默认开启 AEC回声消除、ANS噪声抑制、AGC自动增益但鸿蒙某些设备上默认配置未必最优。我的经验是通话建立前将音频参数设置为“语音通话模式”而不是“音乐模式”。语音模式会启用更强的回声消除和噪声门限牺牲一些音质换清晰度通话场景下音质并非越高越好。6.2 音频设备路由扬声器、听筒、耳机和蓝牙鸿蒙手机插入耳机或连接蓝牙设备后系统音频路由可能不会自动切换到通话通道。通话界面最好暴露一个“扬声器切换”按钮监听ohos.multimedia.audio的设备变化事件在耳机插入/拔出时更新 UI 状态。耳机拔出是高频事故点经常出现“对方听不到我说话”的投诉。正确做法是监听耳机的拔插事件拔出的瞬间主动把音频路由切回扬声器或听筒而不是等系统自动切换。6.3 通话通知音的干扰来电铃声、系统通知、微信提示音都可能被采集进麦克风传出去。方案之一是通话期间把系统音量合理的压低不过这会误伤用户播放其他媒体时的场景另一个办法是启用 SDK 的音频焦点Audio Focus能力申请焦点后系统会自动压低其他应用的媒体音量。7. 性能调优与踩坑清单最后这部分是实打实的排错记录。我把过去遇到的高频问题整理成一张表按发生的概率和排查成本排序现象大概率原因解决建议对端黑屏但本地预览正常摄像头流未推送到 RTC 通道检查 RTC 引擎入会后是否 setLocalVideoRender startLocalVideoPreview本地预览黑屏surfaceId 与摄像头创建时机不匹配等 XComponentonLoad后再创建 PreviewOutput屏幕分享时画面卡顿采集分辨率过高、帧率过高降为 720p15fps或降低码率通话一开始有杂音和回声未使用语音模式、AEC 未开启切换语音场景确认 AEC/ANS/AGC 参数切后台后画面冻结应用挂起摄像头被系统释放必须在后台运行音频通话时保持前台服务比如播放无声的循环音或在真后台模式下使用系统提供的通话后台能力多人通话某一路黑屏该用户上行带宽不足或编码失败监控上行丢包率和码率动态降分辨率或暂停非主讲视频使用 TextUI 频繁操作导致 UI 卡顿原生 Surface 数量过多限制同时渲染的 XComponent 数量隐藏画面改为只更新占位图除了表格里的问题还有两个特别容易被忽略的隐性坑一是不要在调用session.stop()后立刻再次session.start()。鸿蒙摄像机会话的状态切换有内部时序连续快速切换会触发服务端异常这时需要重新创建新的会话而不是复用旧对象。所以我把“会话对象”封装成了一个可替换资源每次重建都先从cameraManager拿到新实例。二是 XComponent 的释放时机。退出通话页面时如果先销毁组件再关闭 RTC 引擎有一定概率出现引擎访问到已释放的 Surface 而崩溃。更安全的是先让 RTC 引擎停止渲染、释放 surfaceId再退页面最后等页面完全销毁后再关闭摄像头输入。7.1 内存泄漏与发热控制音视频应用最容易出内存问题。createCameraInput和createPreviewOutput每次都会创建原生对象一定要在通话结束时主动执行cameraInput.close()、previewOutput.release()。RTC SDK 的远端渲染对象也要在设计上跟随用户离开房间的时机统一清理否则通话持续半小时以上内存会以肉眼可见的速度上涨。发热主要集中在编码器。如果通话期间温度过高导致掉帧比较好的策略是降低编码分辨率而不是降低帧率。分辨率降低对观感的影响相对平滑帧率一下降会导致动态画面明显卡顿。7.2 弱网策略移动端通话必须做网络自适应。固定码率推流在WiFi下没问题切到移动网络就可能频繁卡顿。业界通用的做法是开启拥塞控制带宽不足时自动把编码器码率降到 300kbps 左右同时降帧率到 10fps。极端弱网时还可以考虑只推音频不推视频这是很多产品在弱网模式下的默认做法。鸿蒙上实现也不复杂RTC SDK 有对应的网络质量回调当上报downlinkNetworkQuality连续数秒低于阈值时在 UI 上弹出“当前网络不佳是否切换为语音通话”的提示。这个功能虽然只是多写几行代码却能让用户体验上限高很多。写在最后的经验视频通话这个功能技术层面的难点从来不是“把 Camera Preview 显示在屏幕上”而是把采集、编码、传输、渲染、信令、音频处理这一整条链路稳定地维持住。鸿蒙作为一个相对较新的生态文档和第三方资料没有老平台那么密集但它提供的多媒体 API 整体设计还是清晰的尤其是 XComponent 的 Surface 管理和屏幕采集权限的整合比很多平台做得更规范。我给后来者的建议是先别急着写界面把“摄像头 → 编码 → 传输 → 解码 → 渲染”的最小闭环在纯代码层面跑通再接入音频解决回声问题最后加屏幕分享和 UI。这样每加一层你都知道问题出在哪一层。屏幕分享和摄像头切换这种需求本质上是把一条已经稳定的通道扩展到多路视频流的管理理解了模型代码只是水到渠成的事。