做鸿蒙视频通话和屏幕分享这个功能,我最初的直觉是“直接调系统播放器 + 摄像头 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): Promise<boolean> { const atManager = abilityAccessCtrl.createAtManager(); const permissions: Array<Permissions> = [ '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 ): Promise<camera.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): Promise<avScreenCapture.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 |
| 屏幕分享时画面卡顿 | 采集分辨率过高、帧率过高 | 降为 720p@15fps,或降低码率 |
| 通话一开始有杂音和回声 | 未使用语音模式、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。这样每加一层,你都知道问题出在哪一层。屏幕分享和摄像头切换这种需求,本质上是把一条已经稳定的通道扩展到多路视频流的管理,理解了模型,代码只是水到渠成的事。