ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

鸿蒙NEXT下React Native Camera视频录制:原理与桥接实践

鸿蒙NEXT下React Native Camera视频录制:原理与桥接实践 在React Native鸿蒙开发里“Camera录制视频”听起来就是一个普通API调用的事实际动手才知道坑有多深。鸿蒙NEXT彻底去掉AOSP兼容层之后原来Android上那一套react-native-camera、expo-camera的适配思路几乎全部作废连最基本的相机权限弹窗都不再走RN的PermissionsAndroid。我在这条路上踩了一个多星期把预览黑屏、录制中断、文件损坏、权限回调丢失挨个试了一遍最后整理出一套从零到能录MP4的完整方案。这篇文章就写给同样在RN鸿蒙上做相机功能的人尤其是手里有现成业务、想用最小成本把录制视频跑通的团队。1. 为什么鸿蒙上跑RN最容易卡在Camera这一关先说一个很多人刚开始没想明白的问题鸿蒙NEXT发布后市面上所有基于Google Play Service和Android Camera2 API的三方库在鸿蒙设备上全部失效。RN生态里的相机库走的基本都是Android原生Camera iOS AVFoundation双端桥接鸿蒙既不是Android也不是iOS等于第三个平台而且这个平台的三方库数量还少得可怜。我自己当时的情况是老项目里用react-native-camera做扫码、拍照、录视频迁移到鸿蒙后发现两个致命问题。第一react-native-camera没有鸿蒙分支npm上能找到的鸿蒙兼容版本基本是个人维护的fork功能不全很多还停留在预览阶段录制回调经常丢。第二鸿蒙原生相机API的设计思路和Camera2完全不一样用的是kit.CameraKit那套Manager/Session/Output模型直接在RN层做polyfill根本不现实。所以真正可行的路子只有两条把相机相关功能全部下沉到鸿蒙原生侧用ArkTS写一个完整的能力封装再通过RN的TurboModule暴露给JS层调用。使用鸿蒙官方支持的RN运行容器把原生模块以标准TurboModule的方式注册进去JS侧保持常规Promise调用风格。这两条路不是互斥的实际落地时通常是组合使用。我最终选择的是原生侧负责相机生命周期、录制、码率控制JS侧负责页面UI、开始/停止指令、录完返回文件路径。这个分层的好处是相机这类对时序敏感的能力离业务层越远越稳定JS线程哪怕卡一下也不至于直接把录制搞崩。还有一点必须提前认识清楚鸿蒙上的RN相机模块权限处理不能只在JS层做。鸿蒙的动态权限请求接口requestPermissionsFromUser必须在UIAbility上下文里调用而且返回结果不能像Android那样直接同步拿到必须通过on(response)监听回调。RN的JS层拿不到UIContext所以必须在原生侧封装一个权限申请方法把结果Promise化回传给JS。2. 环境准备与权限处理先把工程搭到能吃相机2.1 工程结构怎么摆RN鸿蒙项目不像普通RN那样只有一个android/ios目录它的鸿蒙工程在harmony/目录下这是个独立的DevEco Studio工程通过oh-package.json5依赖RN的Harmony运行时库。我实际用的基础配置长这样// harmony/oh-package.json5 { modelVersion: 5.0.0, description: RN HarmonyOS project, dependencies: { react-native-oh-tpl/react-native-harmony: file:../node_modules/react-native-oh-tpl/react-native-harmony, rnoh/react-native-openharmony: file:../node_modules/rnoh/react-native-openharmony, ohos/camera: file:../node_modules/ohos/camera } }如果你是从npm直接引RN库记得把node_modules里相关包以file:方式链到鸿蒙工程的oh_modules里。这一步非常容易漏漏了之后DevEco编译能过但运行时找模块会直接报找不到RNOHCorePackage。2.2 相机和麦克风权限鸿蒙的权限分为system_grant和user_grant两类。相机权限ohos.permission.CAMERA和麦克风权限ohos.permission.MICROPHONE都属于user_grant不光要在module.json5里声明还必须发起动态授权请求。// entry/src/main/module.json5 { module: { requestPermissions: [ { name: ohos.permission.CAMERA }, { name: ohos.permission.MICROPHONE } ] } }动态申请在ArkTS侧这样写import { abilityAccessCtrl, Permissions } from kit.AbilityKit; import { common } from kit.AbilityKit; async function requestCameraPermission(context: common.UIAbilityContext): Promiseboolean { const atManager abilityAccessCtrl.createAtManager(); const permissions: ArrayPermissions [ ohos.permission.CAMERA, ohos.permission.MICROPHONE ]; try { let result await atManager.requestPermissionsFromUser(context, permissions); return result.authResults.every(r r 0); } catch (err) { return false; } }我在实际项目里的经验是鸿蒙的权限弹窗一次只能正常显示一组如果你把CAMERA和MICROPHONE一次性传进去在部分机型的弹窗上会出现麦克风授权被折叠、用户只点了相机权限的情况。这也是后期录制“有画面没声音”的最常见原因。稳妥做法是分开申请或者申请完后逐个检查AuthResult。2.3 RN侧拿到授权状态我在原生侧封装了一个CameraRecorderNative模块里边带一个hasPermission()和一个requestPermission()JS侧在页面进入录制页前先调一次// CameraScreen.tsx import CameraRecorderNative from ./CameraRecorderNative; const beforeEnter async () { const granted await CameraRecorderNative.hasPermission(); if (!granted) { const result await CameraRecorderNative.requestPermission(); if (!result) { // 弹窗被拒直接引导用户去设置页 return; } } };这个封装不是多余设计。因为鸿蒙的requestPermissionsFromUser只能在原生UIAbility里调用RN JS跑在容器内直接通过NAPI接不到UIAbilityContext所以这个桥是必须的。3. 原生录制链路拆解CameraManager、VideoOutput与MediaRecorder怎么配合鸿蒙相机API的核心模型一句话概括先拿CameraManager再创建CameraInput和VideoOutput然后把这些组件加进同一个Session最后让VideoOutput把数据吐给MediaRecorder做编码封装。这里最容易搞混的是VideoOutput和MediaRecorder的关系。VideoOutput不是文件写入器它只是一个“视频帧输出口”。输出口的目标可以是屏幕预览也可以是MediaRecorder提供的输入Surface。录制视频时你真正干活的编码器是MediaRecorder不是VideoOutput。整个初始化的时序大概是import { camera } from kit.CameraKit; import { media } from kit.MediaKit; import { fileIo as fs } from kit.CoreFileKit; const cameraManager camera.getCameraManager(context); const cameras cameraManager.getSupportedCameras(); const cameraInput cameraManager.createCameraInput(cameras[0]); const mediaRecorder new media.MediaRecorder(context); const inputSurfaceId await mediaRecorder.getInputSurface(); const videoProfile: camera.VideoProfile { format: camera.CameraFormat.CAMERA_FORMAT_YUV_420_SP, width: 1920, height: 1080, frameRateRange: { min: 24, max: 30 } }; const videoOutput cameraManager.createVideoOutput(videoProfile, inputSurfaceId);注意到这里createVideoOutput的surfaceId来自MediaRecorder而不是屏幕预览。如果同一时刻你还要在界面上显示预览画面还需要单独创建一个PreviewOutput把预览Surface传进去。接下来是把它们组装进Sessionconst session cameraManager.createSession(camera.SceneMode.NORMAL_VIDEO); session.beginConfig(); session.addInput(cameraInput); session.addOutput(videoOutput); await session.commitConfig(); await session.start();MediaRecorder那边需要先配置编码参数再启动顺序不能反。我下面给出一个能直接跑的示例const file await fs.open(fdPath, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE); const avConfig: media.AVRecorderConfig { audioSourceType: media.AudioSourceType.AUDIO_SOURCE_TYPE_MIC, videoSourceType: media.VideoSourceType.VIDEO_SOURCE_TYPE_SURFACE, fileFormat: media.ContainerFormatType.CFT_MPEG_4, video: { width: 1920, height: 1080, frameRate: 30, bitrate: 8_000_000, codec: media.VideoCodecType.VIDEO_ENCODER_TYPE_AVC }, audio: { sampleRate: 44_100, channels: 2, bitrate: 192_000, codec: media.AudioCodecType.AUDIO_ENCODER_TYPE_AAC }, url: fd://${file.fd} }; await mediaRecorder.prepare(avConfig); await mediaRecorder.start(); await videoOutput.start();这里有个细节inputSurfaceId必须在mediaRecorder.prepare()之前拿到因为getInputSurface()返回的是Surface ID字符串这个ID只有在prepare前获取才是MediaRecorder自带的输入口如果prepare之后再拿拿到的东西不是录制用的surface。4. RN与鸿蒙的桥接设计TurboModule与事件回传RN鸿蒙的NativeModule现在统一走TurboModule。我理解它的原理就是JS侧调用一个接口声明鸿蒙原生侧用同一份接口定义实现NAPI负责把ArkTS对象翻译成JS对象。4.1 JS侧接口定义在src/specs/CameraRecorderNative.ts里写Specimport { TurboModule, TurboModuleRegistry } from react-native; export interface VideoRecordResult { code: number; path?: string; durationMs?: number; sizeByte?: number; message?: string; } export interface StartRecordOptions { preset?: 720p | 1080p | 4k; fps?: number; bitrateMbps?: number; frontCamera?: boolean; outputDir?: string; } export interface Spec extends TurboModule { startRecording(options: StartRecordOptions): PromiseVideoRecordResult; stopRecording(): PromiseVideoRecordResult; switchCamera(): Promiseboolean; hasPermission(): Promiseboolean; requestPermission(): Promiseboolean; onRecordStatusChanged(callback: (event: Recordstring, any) void): void; } export default TurboModuleRegistry.getEnforcingSpec(CameraRecorderNative);4.2 ArkTS侧实现在鸿蒙工程的entry/src/main/ets目录下建一个实现文件import { TurboModule } from rnoh/react-native-openharmony; export class CameraRecorderNative extends TurboModule { startRecording(options: Recordstring, string): PromiseRecordstring, string { // 在这里调用原生CameraManager和MediaRecorder return new Promise((resolve) { startNativeRecording(options) .then((result) resolve(result)) .catch((err) resolve({ code: -1, message: err.message })); }); } stopRecording(): PromiseRecordstring, string { // 停止采集、停止编码、关闭文件描述符 } switchCamera(): Promiseboolean { // 切换前后摄像头 } }注意在RN鸿蒙里实现TurboModule需要在Index.ets里注册包export interface ModulesProxy { CameraRecorderNative: CameraRecorderNative; }注册方式跟Android的Package有点像但是基于ObjectToMap容器去管理的。如果你对这块不熟最省事的做法是参考react-native-harmony仓库里的SampleTurboModule照葫芦画瓢。4.3 事件回传怎么搞录制过程中有很多异步事件录制启动成功、中途出错、停止完成、文件写入结束这些不能全靠Promise等。最方便的是用DeviceEventEmitter在原生侧发事件。原生侧import { deviceEventEmitter } from kit.CoreEventKit; // 封装一个事件发射方法 this.context.getReactNativeTurboModuleRegistry()?.emitEvent( CameraRecorderNative.onRecordStatusChanged, { status: recording, timestamp: Date.now() } );JS侧监听import { NativeEventEmitter, NativeModules } from react-native; const emitter new NativeEventEmitter(NativeModules.CameraRecorderNative); emitter.addListener(CameraRecorderNative.onRecordStatusChanged, (event) { if (event.status recording) setRecording(true); if (event.status stopped) setRecording(false); });我自己用下来所有原生抛出的错误码、递归回调、相机生命周期变化统一走事件通道比在Promise里包一层更稳妥因为Promise一旦在JS侧被丢弃原生侧只能干等很容易出现内存不释放的问题。5. 录制功能的核心代码落地从相机预览到MP4落盘5.1 我把完整录制封装成了一个Manager组合原生能力时不要直接把CameraManager和MediaRecorder的调用暴露给UI层。UI层只需要三个动作开始录制、停止录制、切换摄像头。我把这三件事封装成原生侧的VideoRecordManager单例export class VideoRecordManager { private cameraManager: camera.CameraManager; private cameraInput: camera.CameraInput; private videoOutput: camera.VideoOutput; private previewOutput?: camera.PreviewOutput; private session?: camera.VideoSession; private mediaRecorder?: media.MediaRecorder; private file?: fs.File; private isRecording false; }启动录制时内部依次执行检查权限 - 获取相机列表 - 创建CameraInput - 创建PreviewOutput和VideoOutput - 启动Session - prepare MediaRecorder - start MediaRecorder。停止录制时顺序反过来先停VideoOutput再停MediaRecorder然后释放Session最后关闭文件句柄。这条链路上我踩过最大的坑是停止顺序。如果先把Session停了再停MediaRecorderVideoOutput会立刻进入错误态MediaRecorder拿不到完整的数据帧生成的MP4会出现“时长对不上”或者最后几秒花屏。正确顺序是async stopRecording(): PromiseVideoRecordResult { await this.videoOutput?.stop(); await this.mediaRecorder?.stop(); await this.session?.stop(); this.session?.release(); this.cameraInput?.release(); await this.mediaRecorder?.release(); await this.file?.close(); // 返回文件路径和文件大小 }5.2 JS侧调用示例RN业务代码里录制页大概长这样const startRecording async () { try { const result await CameraRecorderNative.startRecording({ preset: 1080p, fps: 30, frontCamera: false, outputDir: getContextDir() /records }); if (result.code 0) { setRecording(true); } else { Toast.show(result.message); } } catch (e) { // 兜底处理避免原生Promise reject导致白屏 } }; const stopRecording async () { const result await CameraRecorderNative.stopRecording(); if (result.code 0 result.path) { // 跳转到预览页或者直接上传 uploadVideo(result.path); } };5.3 预览画面这块怎么处理录制视频通常需要实时预览。鸿蒙里预览是一个独立的SurfaceRN页面如果想看到相机画面一般用原生自定义组件把XComponent包出来。我这里说一个轻量方案录制页保留原生ArkTS侧的一个XComponent作为预览层RN的CameraScreen只是一个容器用Fragment或者全屏View嵌套原生预览层。实际RN代码里原生预览组件通过XComponent标签暴露给JSJS只控制录制按钮。这么做虽然UI不算完全统一但胜在稳定不会出现RN重渲染导致预览Surface被销毁的噩梦。如果你一定要把预览完全做成RN自定义组件就要自行处理Surface生命周期和RN的ViewGroup嵌套复杂度会高很多。6. 实测踩坑记录白屏、权限弹窗、录制中断与资源释放顺序6.1 启动白屏和预览黑屏鸿蒙RN项目最常见的问题就是启动白屏。我从RN日志看大多数白屏不是因为相机模块而是鸿蒙容器的RNOHCorePackage没有正确注册JS引擎启动后没有宿主页面。排查方向先把不相关的原生模块全部注释确认RN基础能力能跑起来再叠加相机。预览黑屏则是另一类问题。黑屏一般发生在PreviewOutput创建时传的surfaceId对应的Surface还没有准备好。鸿蒙对时序要求非常严XComponent的surfaceId必须先于CameraSession创建拿到否则预览一直黑屏。解决方法是把XComponent的onLoad回调里拿到的surfaceId保存下来确保非空再初始化相机。6.2 权限被拒后没有任何反应鸿蒙的设备如果用户点了拒绝在应用重启前不会再次弹窗。我一开始在真机上点了拒绝后续每次进入录制页都静默失败日志里也看不到callback。排查花了很久最后用abilityAccessCtrl查询授权状态才定位到问题let result await atManager.checkAccessToken( abilityAccessCtrl.createAtManager().getApplicationContext().applicationInfo.uid, ohos.permission.CAMERA );所以我的建议是进入录制页前先检查状态如果是拒绝态不要轻易再次申请而是引导用户到设置页手动授权。鸿蒙设置页的跳转方式又和Android不同没有通用的ACTION_APPLICATION_DETAILS_SETTINGS我用的是kit.AbilityKit的startAbility显式跳转到应用的“应用详情”页面。6.3 录制中断、文件打不开我在步骤里提到过停止顺序问题实际上录制中断的根因往往是两件事同时发生相机Session和MediaRecorder在竞争资源时某一方超时。再补充一个容易忽略的文件描述符必须提前打开并且用完后要立即close。如果不closeMediaRecorder可能一直在往已释放的句柄上写出来的文件头部时间戳正常、但播放器拉到结尾就报错。我在一次测试里连续录了5段视频前面4段都正常第5段文件大小为零查下去发现file.fd在第四段停止时被外部回收了。我的经验是所有文件写入必须走fd://协议确保句柄生命周期和MediaRecorder存续期严格绑定停止录制时先停止写入再关句柄。6.4 资源释放顺序的最终结论这一步是反复踩出来的可以给一个固定结论先停VideoOutput再停MediaRecorder然后停Session最后release所有相机输入输出组件和MediaRecorder。整个过程中不要跳过任何一步尤其不要直接调session.release()而不停VideoOutput否则设备相机硬件会一直处于被占用状态后续再进页面会直接报“设备被占用”。我还遇到过退出页面时只release了CameraManager而没有release PreviewOutput的情况导致下次进入时相机无法启动必须冷重启进程。这种问题在真机上几乎不可排查所以退出页面时一定要做完整释放。7. 稳定性与性能调优码率、帧率、焦距与常见误用7.1 码率和帧率怎么定以目前鸿蒙主流机型来看1080P配8Mbps码率、30fps是比较稳的组合。码率太高比如1080P拉到20Mbps不仅体积爆炸还有可能在弱光场景下掉帧码率太低比如4Mbps动态画面会有明显马赛克。我给一个参考表供快速选择录制规格分辨率推荐码率帧率适用场景720p1280x7204-5 Mbps30直播上传、短视频1080p1920x10808-10 Mbps30通用录制1080p 高帧率1920x108012 Mbps60运动场景、慢放4K3840x216020-30 Mbps30高质量素材帧率上我不建议在真机上无脑开60fps因为部分机型在60fps下自动对焦和防抖能力会打折反而画面看起来更抖。如果业务不是专门拍运动画面稳定输出30fps更安全。7.2 对焦和变焦处理很多RN项目把对焦放在JS层频繁调用这个做法在鸿蒙上会导致录制帧率被拉低。相机的AF自动对焦和AE自动曝光应该尽量交给Session自己去跑JS层只在用户主动点击屏幕时才发起一次对焦触发// 原生侧暴露聚焦接口 async function setFocusPoint(x: number, y: number) { const activeResult await cameraManager.isSessionActive(); if (!activeResult) return; await session.setFocusPoint({ x, y }); await session.setExposurePoint({ x, y }); }变焦是另一个性能杀手。鸿蒙相机变焦是数字变焦CropRegion的计算在相机管线里做频繁改动会增大ISP负载。实际开发时我会限制JS侧每秒最多更新一次焦距并做线性插值而不是直接跳变。7.3 JS线程卡顿会不会影响录制影响非常直接。RN的JS线程在渲染列表、执行同步网络请求时如果卡顿超过200msMediaRecorder那一侧的编码缓冲会积压最终丢帧。我在项目里做了一件事录制期间页面只保留录制按钮、定时器和状态文本任何列表渲染、图片预加载、网络长轮询全部暂停。这是最经济有效的稳定方案。7.4 我最后的经验整个模块落地后我实际上已经把CameraRecorderNative当成了一个独立组件换个壳就能用在拍照、扫码、人脸入库上。如果你也要在RN鸿蒙里做视频录制我的建议是先别急着找现成库老老实实封一层原生ArkTS能力再往RN桥接层暴露最小接口。虽然前期工作量大但后期维护会轻松很多。尤其是相机这种牵一发动全身的模块任何试图绕过原生核心能力的方案最后都会在稳定性上还债。最后再分享一个细节鸿蒙上录制时最好同时开一个前台Service或者在UI里放一个常驻状态提示否则录制时间较长时机身发热系统后台可能直接回收页面上下文导致MediaRecorder被强制杀死。这个坑不在API文档里但真实用户场景中非常容易遇到。
返回列表