第5篇:录制视频与保存)
HarmonyOS技术精讲-Camera Kit相机服务第5篇录制视频与保存开篇视频录制功能的实际开发矛盾HarmonyOS NEXT 开发中Camer Kit相机服务的视频录制功能官方文档给出了一套标准流程创建VideoOutput、配置AVRecorder、开始/停止录制、保存文件。流程看起来非常清晰但实际项目落地时很多开发者会碰到一个共同的问题——官方示例能跑通但一旦涉及到“开始录制后再次点击停止”、“录制过程中切换页面”、“录完视频找不到文件”这些真实交互场景代码就开始变得不稳定。问题出在哪里答案往往不在Camera Kit本身而在于VideoOutput与AVRecorder的生命周期绑定方式。这两个对象各自有自己的状态转换逻辑如果只关注录制流程而忽略状态同步稍微复杂一点的交互就会导致崩溃或文件丢失。这篇文章会直接给出一个稳定的实现方案并重点分析那些容易被忽略的细节。它解决了什么问题视频录制功能的核心是将实时摄像头采集的画面编码并写入文件。在HarmonyOS生态里这一过程由两个模块协作完成模块职责关键点Camera Kit (VideoOutput)提供相机预览流需要指定输出格式surfaceIdAVRecorder编码并写入文件需要传入videoSourceType和输出URI适用场景需要将摄像头画面保存为本地视频文件的应用比如社交App的拍摄功能、安防监控App的录像回传。不适用场景如果只是想在界面展示预览画面而不保存那直接用Camera Kit的PreviewOutput即可不需要引入AVRecorder增加复杂度。环境说明DevEco Studio 版本DevEco Studio 6.1.0 及以上 HarmonyOS SDK 版本HarmonyOS 6.1.0(23) 及以上 目标设备手机带摄像头核心实现从初始化到文件保存第一步初始化Camera并获取VideoOutput这部分在《HarmonyOS技术精讲-Camera Kit第2篇》里已经详细讲过这里只给出核心代码。关键点是VideoOutput需要与AVRecorder共享同一个surfaceId否则视频录制无法正常工作。// CameraManager.etsimport{camera}fromkit.CameraKit;import{common}fromkit.AbilityKit;exportclassCameraManager{privatecameraManager:camera.CameraManager|nullnull;privatecameraInput:camera.CameraInput|nullnull;privatepreviewOutput:camera.PreviewOutput|nullnull;privatevideoOutput:camera.VideoOutput|nullnull;privatesurfaceId:string;asyncinitCamera(context:common.Context,surfaceId:string):Promisevoid{this.surfaceIdsurfaceId;// 获取CameraManagerthis.cameraManagercamera.getCameraManager(context);// 获取相机列表默认后置constcameras:Arraycamera.CameraDevicethis.cameraManager.getSupportedCameras();if(cameras.length0){thrownewError(No camera available);}constcameraDevicecameras[0];// 后置// 创建输入流this.cameraInputthis.cameraManager.createCameraInput(cameraDevice);awaitthis.cameraInput.open();// 获取输出配置constsession:camera.CaptureSessionthis.cameraManager.createCaptureSession();awaitsession.beginConfig();// 添加输入session.addInput(this.cameraInput);// 创建PreviewOutput预览constpreviewProfile:camera.Profile{format:camera.CameraFormat.CAMERA_FORMAT_YUV_420_SP,size:{width:1920,height:1080}};this.previewOutputthis.cameraManager.createPreviewOutput(previewProfile,surfaceId);session.addOutput(this.previewOutput);// 创建VideoOutput用于录制但此时不连接AVRecorderconstvideoProfile:camera.VideoProfile{format:camera.CameraFormat.CAMERA_FORMAT_YUV_420_SP,size:{width:1920,height:1080},frameRate:30};this.videoOutputthis.cameraManager.createVideoOutput(videoProfile,surfaceId);session.addOutput(this.videoOutput);// 提交配置awaitsession.commitConfig();awaitsession.start();}getVideoOutput():camera.VideoOutput|null{returnthis.videoOutput;}release():void{this.cameraInput?.close();this.previewOutput?.release();this.videoOutput?.release();this.cameraManager?.destroy();}}注意事项createVideoOutput传入的surfaceId必须和PreviewOutput的surfaceId一致。理论上VideoOutput是独立的输出流但HarmonyOS当前版本要求共享同一个surface否则录制时会收到errorCode 7400101之类的错误表示surface不匹配。第二步集成AVRecorder并绑定AVRecorder是真正执行编码写入的模块。录制开始时AVRecorder会从VideoOutput的surface读取数据帧录制结束时AVRecorder停止写入并释放资源。// VideoRecorder.etsimport{media}fromkit.MediaKit;import{photoAccessHelper}fromkit.MediaLibraryKit;import{fileIo}fromkit.CoreFileKit;import{common}fromkit.AbilityKit;exportclassVideoRecorder{privateavRecorder:media.AVRecorder|nullnull;privatevideoOutput:camera.VideoOutput|nullnull;privatecontext:common.Context;privateoutputUri:string;constructor(context:common.Context){this.contextcontext;}// 绑定VideoOutput在开始录制前调用bindVideoOutput(output:camera.VideoOutput):void{this.videoOutputoutput;}// 开始录制必须传入VideoOutput对应的surfaceIdasyncstartRecording(surfaceId:string):Promisevoid{if(this.avRecorder){console.warn(AVRecorder already exists, stop first);return;}// 创建AVRecorder实例this.avRecorderawaitmedia.createAVRecorder();// 设置录制参数constrecorderConfig:media.AVRecorderConfig{videoSourceType:media.VideoSourceType.VIDEO_SOURCE_TYPE_SURFACE_YUV,// 从surface读取YUV数据surfaceId:surfaceId,// 必须与VideoOutput的surfaceId一致outputFormat:media.OutputFormatType.OUTPUT_FORMAT_MPEG_4,// 输出mp4videoEncoder:media.VideoCodecType.VIDEO_CODEC_AVC,// 使用H.264编码videoBitrate:2000000,// 2MbpsvideoFrameWidth:1920,videoFrameHeight:1080,videoFrameRate:30,audioSourceType:media.AudioSourceType.AUDIO_SOURCE_TYPE_MIC,// 录制音频可选audioEncoder:media.AudioCodecType.AUDIO_CODEC_AAC,audioBitrate:96000,audioSampleRate:44100,audioChannels:2,url:// 设置文件路径见下一步};// 准备文件路径保存到媒体库this.outputUriawaitthis.createVideoFile();recorderConfig.urlthis.outputUri;// 准备AVRecorderawaitthis.avRecorder.prepare(recorderConfig);// 开始录制awaitthis.avRecorder.start();// 通知VideoOutput开始输出this.videoOutput?.start();console.log(Recording started);}// 停止录制并保存asyncstopRecording():Promisestring{if(!this.avRecorder){thrownewError(No active recording);}// 先停止VideoOutput再停止AVRecorder顺序重要this.videoOutput?.stop();awaitthis.avRecorder.stop();// 释放AVRecorder资源awaitthis.avRecorder.release();this.avRecordernull;console.log(Recording saved to:${this.outputUri});returnthis.outputUri;}// 创建视频文件保存在媒体库privateasynccreateVideoFile():Promisestring{consthelperphotoAccessHelper.getPhotoAccessHelper(this.context);consturiawaithelper.createAsset(photoAccessHelper.PhotoType.VIDEO,mp4);returnuri;}}关键逻辑说明startRecording接收的surfaceId必须与CameraManager中VideoOutput使用的surfaceId完全一致。如果不想暴露surfaceId可以在CameraManager内部维护一个全局变量但更推荐显式传递避免混淆。停止录制时先stop VideoOutput再stop AVRecorder。如果反过来AVRecorder可能因为数据流中断而报错state is invalid状态机异常。AVRecorder销毁后必须重新创建不能重复使用同一个实例。第三步UI界面与交互控制// Index.etsimport{CameraManager}from./CameraManager;import{VideoRecorder}from./VideoRecorder;import{XComponentController}fromkit.ArkUI;EntryComponentstruct VideoRecordPage{StateisRecording:booleanfalse;StaterecordTime:number0;privatecameraManager:CameraManagernewCameraManager();privatevideoRecorder:VideoRecordernewVideoRecorder(getContext());privatexComponentController:XComponentControllernewXComponentController();privatetimerInterval:number|nullnull;privatesurfaceId:string;build(){Column(){// 预览区域XComponent({id:camera_preview,type:surface,controller:this.xComponentController}).onLoad((){// XComponent加载完后获取surfaceIdthis.surfaceIdthis.xComponentController.getXComponentSurfaceId();this.initCamera();}).width(100%).height(70%)// 录制控制Row(){Button(this.isRecording?停止:录制).onClick((){if(this.isRecording){this.stopRecord();}else{this.startRecord();}}).backgroundColor(this.isRecording?Color.Red:Color.Blue).fontSize(18).width(100).height(50)}.justifyContent(FlexAlign.Center).width(100%).margin({top:20})// 录制时长显示Text(录制时长:${this.recordTime}秒).fontSize(16).margin({top:10})}.width(100%).height(100%)}// 初始化相机asyncinitCamera():Promisevoid{try{awaitthis.cameraManager.initCamera(getContext(),this.surfaceId);// 绑定VideoOutput到VideoRecorderconstvideoOutputthis.cameraManager.getVideoOutput();if(videoOutput){this.videoRecorder.bindVideoOutput(videoOutput);}}catch(error){console.error(Camera init failed:,error);}}// 开始录制asyncstartRecord():Promisevoid{try{awaitthis.videoRecorder.startRecording(this.surfaceId);this.isRecordingtrue;this.recordTime0;// 开启计时器模拟时长this.timerIntervalsetInterval((){this.recordTime;},1000);}catch(error){console.error(Start record failed:,error);}}// 停止录制asyncstopRecord():Promisevoid{try{consturiawaitthis.videoRecorder.stopRecording();this.isRecordingfalse;if(this.timerInterval){clearInterval(this.timerInterval);this.timerIntervalnull;}console.info(Video saved to:,uri);// 弹出提示省略弹窗代码}catch(error){console.error(Stop record failed:,error);}}// 页面销毁时释放资源aboutToDisappear():void{this.cameraManager.release();if(this.timerInterval){clearInterval(this.timerInterval);}}}代码结构说明XComponent是预览容器加载完成后获取surfaceId。录制按钮长按/点击逻辑点击开始录制 → 调用videoRecorder.startRecording → 更新状态显示时长再次点击停止 → 调用stopRecording → 写入文件。建议在aboutToDisappear中释放Camera和AVRecorder资源防止页面残留导致的内存泄漏。常见问题分析踩坑记录问题1录制过程中页面返回再次进入时崩溃现象点击录制后立即返回上一页再回到录制页面点击“开始录制”会抛出errorCode: 7400102, state is invalid。原因CameraManager和AVRecorder的生命周期没有和页面绑定清晰。第一次退出时CameraManager被销毁但AVRecorder由于录制未结束依然占用资源第二次初始化Camera时与残留的AVRecorder状态冲突。解决方案在aboutToDisappear中强制终止所有操作aboutToDisappear():void{// 如果正在录制先停止if(this.isRecording){// 这里加个标志防止异步回调再次触发stopthis.isRecordingfalse;// 直接释放资源不等待stop完成this.videoRecorder.releaseForce();// 自定义方法直接release}this.cameraManager.release();}需要在VideoRecorder中添加releaseForceasyncreleaseForce():Promisevoid{if(this.avRecorder){try{awaitthis.avRecorder.stop();}catch(e){// 吞掉异常避免影响释放流程}awaitthis.avRecorder.release();this.avRecordernull;}}问题2录制的视频时长和实际不符现象按下停止按钮生成的视频文件时长比预期少了1-2秒。原因AVRecorder的stop()方法需要花时间完成文件写入而UI在点击停止后立即显示了停止状态。用户误以为停止时录制就已经结束实际上AVRecorder还在做最后的flush操作。这一延迟在H.264编码中尤其常见。解决方案在UI层面不要立即更新状态而是等待stopRecording返回后再更新asyncstopRecord():Promisevoid{try{// 先禁用按钮防止反复点击// this.isRecording保持true状态让按钮显示为“停止中”consturiawaitthis.videoRecorder.stopRecording();// 正式更新状态this.isRecordingfalse;// ...提示保存成功}catch(error){this.isRecordingfalse;// 出错也恢复状态}}最佳实践不要在build()中创建CameraManager或VideoRecorder实例。ArkUI组件的build方法会被频繁调用每次都创建新实例会导致旧实例的surface泄露增加GC压力。应该在组件的构造函数或aboutToAppear中初始化。始终使用单个surfaceId。VideoOutput和PreviewOutput在HarmonyOS NEXT上必须共享同一个surfaceId这是系统限制。如果后续版本支持分离surface官方已经在规划再把视频流和预览流分开以获得更好的性能。录制参数要贴合设备能力。不要硬编码videoFrameRate: 60或videoBitrate: 50000000。应该先通过CameraManager的getSupportedVideoProfiles查询设备支持的分辨率和帧率再选择最接近目标值的配置。强行使用不支持的配置会导致录制失败。完整API调用流用户点击录制 → AVRecorder.prepare(config) // 配置编码参数 → AVRecorder.start() // 开始编码 → VideoOutput.start() // 开始输出数据到surface → 帧数据流入AVRecorder → 用户点击停止 → VideoOutput.stop() // 停止输出 → AVRecorder.stop() // 停止编码并写入文件尾 → AVRecorder.release() // 释放资源 → 视频文件就绪FAQQ为什么真机上录制的视频画面是黑的A大部分原因是surfaceId传递出错。检查XComponent的onLoad回调中获取的surfaceId是否有效以及startRecording传入的surfaceId是否与VideoOutput创建时一致。如果surfaceId无效AVRecorder会收到黑帧。Q录制过程中如何获取实时视频数据帧A当前HarmonyOS NEXT的Camera Kit没有直接暴露VideoOutput的数据回调API。如果需要实时处理帧比如添加滤镜建议使用ImageReceiver或者另开线程从AVRecorder的surface读取但这会增加延迟。官方更推荐在录制完成后进行后处理。Q录制时长有没有限制A系统层面没有硬性限制但受设备存储和编码器稳定性的影响。长时间录制超过30分钟在部分中低端设备上可能会出现H264编码器异常崩溃的问题。建议在录制时长超过30分钟时主动分割文件。Q为什么点击录制后等了很久才开始显示时长AAVRecorder的start()方法需要创建编码器和初始化缓存这一过程在首次录制时可能需要几百毫秒到1秒不等。可以接受的话加一个loading动画即可。如果对启动速度要求很高可以在页面初始化时就提前prepare AVRecorder但不start录制时直接调用start。