HarmonyOS趣味相机实战第32篇:XComponent Surface就绪、预览启动门禁与销毁竞态

📅 2026/7/22 16:14:03
HarmonyOS趣味相机实战第32篇:XComponent Surface就绪、预览启动门禁与销毁竞态
HarmonyOS趣味相机实战第32篇XComponent Surface就绪、预览启动门禁与销毁竞态摘要CameraKit 的 PreviewOutput 需要可用 Surface但 ArkUI 页面创建不等于 Surface 已就绪。相机权限、设备发现和 XComponentonLoad又是三条独立时序权限先到但 Surface 为空不能启动Surface 已到但用户拒绝权限也不能启动页面销毁时正在执行异步 start还可能把已失效的 Surface 重新写进会话。本文基于D:/APP/1quweixiangji的Index.ets与CameraPreviewService.ets围绕XComponentType.SURFACE、getXComponentSurfaceId()、预览启动门禁、onLoad/onDestroy、状态机和 generation 取消令牌展开。目标是让预览只在权限、设备和 Surface 三者同时成立时启动并在任何一个条件失效后安全停止。源码定位文件作用pages/Index.etsXComponent、Surface ID与页面门禁service/CameraPreviewService.etsCameraInput/Session/PreviewOutputservice/CameraPermissionService.ets动态权限结果service/CameraDeviceService.ets前后摄发现与选择entryability/EntryAbility.ets页面加载和前后台入口环境与关键状态状态类型含义previewSurfaceIdstringXComponent输出Surface标识cameraPermissionStatusuniongranted/denied/error等cameraAvailableboolean至少存在可用摄像头previewStatusunionidle/starting/running/erroractiveCameraPositionfront/back当前设备位置一、XComponent承担真实预览承载面BuilderCameraPreviewSurface(){XComponent({id:cameraPreviewSurface,type:XComponentType.SURFACE,controller:this.previewController}).width(100%).height(100%).onLoad((){this.onPreviewSurfaceReady();}).onDestroy((){this.onPreviewSurfaceDestroy();})}XComponentType.SURFACE提供可交给相机输出的 Surface。页面声明阶段还拿不到可用 ID必须等待 onLoad。二、onLoad之后再读取Surface IDprivateonPreviewSurfaceReady():void{this.previewSurfaceIdthis.previewController.getXComponentSurfaceId();this.startCameraPreview();}Surface ID 是运行时资源标识不应写死或跨组件复用。读取后仍需验证非空constsurfaceIdthis.previewController.getXComponentSurfaceId();if(surfaceId.length0){this.captureStatusText预览画布尚未就绪;return;}this.previewSurfaceIdsurfaceId;三、预览启动需要三条件门禁项目在启动前检查privateasyncstartCameraPreview():Promisevoid{if(!this.isCameraGranted()||!this.cameraAvailable||this.previewSurfaceId.length0){return;}// call service}可以表达为canStart permissionGranted cameraAvailable surfaceIdNotEmpty三个条件缺一不可。把检查集中在一个方法里权限回调、Surface onLoad和设备切换都可以安全调用startCameraPreview()未满足时只返回。四、权限与Surface顺序不可假设可能时序A页面出现 - Surface onLoad - 无权限启动返回 - 用户授权 - 再次调用start - 成功可能时序B已有权限 - 页面出现 - 设备发现 - Surface尚未onLoad启动返回 - onLoad获得ID - 再次调用start - 成功因此每个条件从 false 变 true 时都可以尝试启动但最终是否执行由统一门禁决定。不要只在某一个回调里启动否则另一种顺序会永远不触发。五、设备发现也属于门禁conststate:CameraDeviceStateCameraDeviceService.discover(context,this.activeCameraPosition);this.cameraAvailablestate.available;this.hasFrontCamerastate.hasFront;this.hasBackCamerastate.hasBack;this.activeCameraPositionstate.activePosition;有权限不代表设备一定可用。模拟器、硬件故障或前后摄缺失都可能使 discover 失败。只有availabletrue才创建 CameraInput。首选位置不可用时设备服务回退到实际存在的位置页面要同步更新按钮状态。六、服务状态机阻止重复启动预览状态定义exporttypeCameraPreviewStatusidle|starting|running|error;页面多个条件回调可能几乎同时调用 start。服务入口应判断if(statusstarting){returncurrentState(相机正在启动);}if(statusrunningactiveSurfaceIdsurfaceIdactivePositionposition){returncurrentState(相机预览已运行);}相同 Surface 和相同设备的重复启动应幂等返回而不是重复创建 Session。七、Surface ID变化必须重建输出页面旋转、组件重建或导航返回可能生成新的 Surface ID。即使预览状态是 running只要 ID 变化旧 PreviewOutput 就不能继续使用。constsameTargetrunningSurfaceIdrequestedSurfaceIdrunningPositionrequestedPosition;if(!sameTarget){awaitCameraPreviewService.release();awaitCameraPreviewService.start(context,requestedSurfaceId,requestedPosition);}不能只判断statusrunning就跳过启动。八、onDestroy先使Surface失效privateasynconPreviewSurfaceDestroy():Promisevoid{this.previewSurfaceId;awaitCameraPreviewService.release();this.previewStatusidle;}先清空页面 ID能让其他并发路径立即无法通过门禁再等待服务释放 CameraSession、Output和Input。若反过来先 await release等待期间旧 ID 仍可能被另一个回调读取并发起新启动。九、异步start与destroy会发生竞态典型时序onLoad获得surface-A - start(A)正在await创建CameraInput - 页面离开onDestroy清空ID并release - start(A)继续完成错误地进入running仅清空 ID 不能取消已经开始的异步函数。需要 generationprivatepreviewGeneration:number0;privateasyncstartCameraPreview():Promisevoid{constgenerationthis.previewGeneration;constsurfaceIdthis.previewSurfaceId;if(!this.canStartPreview(surfaceId))return;conststateawaitCameraPreviewService.start(context,surfaceId,this.activeCameraPosition);if(generation!this.previewGeneration||surfaceId!this.previewSurfaceId){awaitCameraPreviewService.release();return;}this.previewStatusstate.status;}privateasynconPreviewSurfaceDestroy():Promisevoid{this.previewGeneration1;this.previewSurfaceId;awaitCameraPreviewService.release();}销毁递增 generation使旧 start 结果失效。十、不要在旧请求中释放新会话上面的旧请求发现过期后直接调用全局release()也有风险新 Surface 可能已经启动旧请求会把新会话释放。服务应为每轮会话分配 token并只释放自己的资源。interfacePreviewHandle{token:number;surfaceId:string;}release(handle)先比较 token只有当前活动会话匹配才释放。资源尽量使用局部变量构建全部成功后再原子替换为 active session。十一、启动过程使用事务式资源组装创建CameraInput - 创建PreviewOutput(surfaceId) - 创建PhotoOutput - 创建CaptureSession - beginConfig - addInput/addOutput - commitConfig - start - 标记active任一步失败都要逆序释放已经创建的局部资源。不要创建到一半就写入全局静态字段否则另一轮 release 无法判断哪些对象已就绪。letinput:camera.CameraInput|nullnull;letpreview:camera.PreviewOutput|nullnull;letsession:camera.PhotoSession|nullnull;try{// create and start}catch(error){awaitsafeRelease(session);awaitsafeRelease(preview);awaitsafeRelease(input);throwerror;}十二、Surface尺寸与相机Profile要匹配XComponent 使用页面宽高PreviewProfile来自相机支持列表。两者比例不一致时Surface内容会裁剪或留黑边。页面需要明确展示策略Cover填满但裁剪边缘。Contain完整但可能留边。固定取景比例布局按Profile宽高比约束。坐标识别层也必须使用同一裁剪模型否则人物框看起来偏移。十三、onDestroy不能只停止预览完整释放通常包含注销metadata/photo回调 - 停止session - 移除输入输出按API需要 - release session - release metadataOutput - release photoOutput - release previewOutput - close/release cameraInput - 清空静态引用与状态只调用 session.stop() 会保留相机占用返回页面时可能无法重新创建输入。十四、页面前后台与组件销毁不同XComponent onDestroy 表示承载面消失Ability 进入后台时Surface可能仍存在但相机应停止以遵守资源和隐私边界。页面生命周期需要额外处理asyncaboutToDisappear():Promisevoid{this.previewGeneration1;awaitCameraPreviewService.release();}再次出现时重新发现设备、确认权限并等待有效 Surface 后启动。不要把 onDestroy 当成唯一释放入口。十五、拍照按钮读取running状态privateisRealPreviewRunning():boolean{returnthis.previewStatusrunning;}拍照入口先检查避免 Surface 已销毁但按钮事件仍到达if(!this.isRealPreviewRunning()){this.captureStatusText请先启用相机预览;return;}服务层仍需再次验证 PhotoOutput 和 Session因为页面状态可能滞后。UI门禁改善体验服务门禁保证安全。十六、切换前后摄等价于目标变化用户切换摄像头时 Surface 不变但 position 变化。流程应锁定切换按钮 - generation递增 - release旧会话 - 更新activePosition - 用同一有效Surface启动新会话 - 成功后解锁若目标位置不可用CameraDeviceService保持当前位置不要释放一个可用会话后进入空白。十七、错误恢复入口要可重复启动失败后状态为 error用户可点击“启用相机”重试。重试前确认旧半成品已释放再按当前权限、设备和 Surface 重建。错误文案分层未授权引导申请权限。无设备提示检查硬件。Surface未就绪等待组件加载。Session创建失败允许重试。页面已离开静默取消不显示错误。十八、日志只记录状态与短标识hilog.info(DOMAIN,TAG,preview transition%{public}s generation%{public}d surface%{public}s,transition,generation,shortSurfaceId(surfaceId));诊断日志只记录预览状态、generation和经过缩短的资源标识不记录图像帧、用户水印或完整运行时对象。十九、自动化测试状态机把门禁抽成纯函数functioncanStartPreview(state:PreviewPrerequisites):boolean{returnstate.permissionGrantedstate.cameraAvailablestate.surfaceId.length0;}测试8种真假组合只有三者全真才返回 true。异步测试使用 fake service 控制 start 延迟调用 start(A) 并暂停。触发 destroy使 generation 变化。让 start(A) 返回 running。断言页面不接受旧结果。新 start(B) 不被旧任务释放。二十、真机验收矩阵场景期望首次授权前Surface先到不启动授权后自动启动已授权但Surface后到onLoad后启动快速进入退出页面无残留会话、无崩溃后台再前台旧会话释放新会话恢复前后摄连续切换始终最多一个活动会话旋转/组件重建使用新Surface ID启动中销毁旧结果不写回runningSurface为空不创建PreviewOutput相机硬件不可用显示可理解错误反复进入100次相机资源和内存不持续增长二十一、常见问题排查现象高概率原因排查点首次进入黑屏只在权限回调启动onLoad也尝试启动返回页面仍黑屏复用了旧Surface ID比较目标ID快速退出后相机灯仍亮start/destroy竞态generation与token切换摄像头失败running状态阻止重建position纳入目标偶发双会话多入口同时startstarting门闩拍照提示Output未就绪UI状态早于服务完成running双层检查二十二、发布前验收清单XComponent使用SURFACE类型并在onLoad读取ID。权限、设备、Surface三条件统一门禁。每个条件变为可用时都可安全尝试启动。相同目标重复start具备幂等性。Surface ID或摄像头位置变化会重建会话。onDestroy先使页面ID和generation失效。旧异步任务不能写回或释放新会话。启动半成品在失败时逆序释放。前后台生命周期也释放CameraKit资源。UI与服务都检查running/Output状态。真机覆盖快速进入退出和组件重建。二十三、项目真实启动代码复盘页面当前实现把三项门禁集中在启动方法中privateasyncstartCameraPreview():Promisevoid{if(!this.isCameraGranted()||!this.cameraAvailable||this.previewSurfaceId.length0){return;}this.previewStatusstarting;this.previewStatusText正在启动相机预览;constcontext:common.UIAbilityContextthis.getUIContext().getHostContext()ascommon.UIAbilityContext;constresult:CameraPreviewStateawaitCameraPreviewService.startPreview(context,this.previewSurfaceId,this.activeCameraPosition);this.previewStatusresult.status;this.previewStatusTextresult.message;this.captureStatusTextresult.statusrunning?真实相机预览中:result.message;}Surface回调只负责更新资源条件privateonPreviewSurfaceReady():void{this.previewSurfaceIdthis.previewController.getXComponentSurfaceId();if(this.isCameraGranted()this.cameraAvailable){this.startCameraPreview();}}privateasynconPreviewSurfaceDestroy():Promisevoid{this.previewSurfaceId;this.previewStatusidle;this.previewStatusText真实预览未启动;awaitCameraPreviewService.stopPreview();}这组代码已经形成基本闭环ID为空时启动门禁失败销毁先清空ID再停止服务。generation与会话token是在此基础上处理极端异步竞态的增强项。二十四、权限、设备和Surface的状态转换表权限设备Surface当前动作目标状态未授权未知空等待用户操作idle未授权未知已就绪显示权限覆盖层idle已授权不可用已就绪展示设备错误error/idle已授权可用空等待onLoadidle已授权可用已就绪启动PreviewSessionstarting已授权可用同一ID运行中幂等返回running已授权可用新ID重建输出starting任意任意onDestroy失效并停止idle状态表可直接用于代码评审任何新增入口都只能触发表中允许的转换不能绕过门禁直接创建 PreviewOutput。二十五、Hypium门禁与竞态测试先把门禁抽成无副作用函数interfacePreviewPrerequisites{granted:boolean;available:boolean;surfaceId:string;}functioncanStart(state:PreviewPrerequisites):boolean{returnstate.grantedstate.availablestate.surfaceId.length0;}Hypium测试覆盖关键组合describe(PreviewGate,(){it(starts only when all prerequisites are ready,0,(){expect(canStart({granted:true,available:true,surfaceId:surface-A})).assertTrue();expect(canStart({granted:true,available:true,surfaceId:})).assertFalse();expect(canStart({granted:false,available:true,surfaceId:surface-A})).assertFalse();});});异步测试把CameraPreviewService.startPreview替换为可控 Promise发起start(surface-A, generation1) - 触发onDestroygeneration变为2且ID清空 - 让旧Promise返回running - 断言页面仍为idle - onLoad获得surface-B并启动generation3 - 断言旧任务不会停止surface-B会话这比单纯等待真机偶现黑屏更容易验证竞态修复。二十六、版本兼容与构建检查工程目标为 HarmonyOS 6.0.2(22)。迁移 SDK 或设备形态时需要重新核对边界检查项XComponentSURFACE类型、controller与回调签名CameraKitPreviewProfile、Session类型与释放顺序页面生命周期onLoad/onDestroy与前后台回调时序设备形态手机横竖屏、折叠状态和窗口变化严格模式可空Surface ID与Promise返回类型构建通过后还要在真机记录以下检查结果冷启动首次授权授权后出现真实预览 已有授权冷启动Surface就绪后自动预览 快速进入退出20次没有相机占用残留 前后摄切换20次始终只有一个活动会话 后台停留再返回预览可恢复且拍照可用 组件重建新Surface ID接管旧回调不写回构建验证解决类型和API问题真机循环验证解决系统资源与异步时序问题两者缺一不可。总结XComponent 预览稳定的关键是承认权限、相机设备和 Surface 来自三条独立时序。页面把三者收敛到统一门禁onLoad、授权回调和设备刷新都只负责“尝试启动”服务状态机保证相同目标幂等目标变化则重建。进一步用 generation 和会话 token 隔离异步 start/destroy采用局部资源事务组装与逆序释放就能避免黑屏、双会话、旧 Surface 复用和页面退出后相机仍占用等问题。Surface ID不是普通字符串而是一段有明确创建与失效时刻的系统资源引用。