HarmonyOS趣味相机实战第32篇:XComponent Surface就绪、预览启动门禁与销毁竞态
HarmonyOS趣味相机实战第32篇:XComponent Surface就绪、预览启动门禁与销毁竞态
摘要
CameraKit 的 PreviewOutput 需要可用 Surface,但 ArkUI 页面创建不等于 Surface 已就绪。相机权限、设备发现和 XComponent onLoad 又是三条独立时序:权限先到但 Surface 为空,不能启动;Surface 已到但用户拒绝权限,也不能启动;页面销毁时正在执行异步 start,还可能把已失效的 Surface 重新写进会话。
本文基于 D:/APP/1quweixiangji 的 Index.ets 与 CameraPreviewService.ets,围绕 XComponentType.SURFACE、getXComponentSurfaceId()、预览启动门禁、onLoad/onDestroy、状态机和 generation 取消令牌展开。目标是让预览只在权限、设备和 Surface 三者同时成立时启动,并在任何一个条件失效后安全停止。
源码定位
| 文件 | 作用 |
|---|---|
pages/Index.ets |
XComponent、Surface ID与页面门禁 |
service/CameraPreviewService.ets |
CameraInput/Session/PreviewOutput |
service/CameraPermissionService.ets |
动态权限结果 |
service/CameraDeviceService.ets |
前后摄发现与选择 |
entryability/EntryAbility.ets |
页面加载和前后台入口 |
环境与关键状态
| 状态 | 类型 | 含义 |
|---|---|---|
previewSurfaceId |
string | XComponent输出Surface标识 |
cameraPermissionStatus |
union | granted/denied/error等 |
cameraAvailable |
boolean | 至少存在可用摄像头 |
previewStatus |
union | idle/starting/running/error |
activeCameraPosition |
front/back | 当前设备位置 |

一、XComponent承担真实预览承载面
@Builder
CameraPreviewSurface() {
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 ID
private onPreviewSurfaceReady(): void {
this.previewSurfaceId =
this.previewController.getXComponentSurfaceId();
this.startCameraPreview();
}
Surface ID 是运行时资源标识,不应写死或跨组件复用。读取后仍需验证非空:
const surfaceId =
this.previewController.getXComponentSurfaceId();
if (surfaceId.length === 0) {
this.captureStatusText = '预览画布尚未就绪';
return;
}
this.previewSurfaceId = surfaceId;
三、预览启动需要三条件门禁
项目在启动前检查:
private async startCameraPreview(): Promise<void> {
if (!this.isCameraGranted() ||
!this.cameraAvailable ||
this.previewSurfaceId.length === 0) {
return;
}
// call service
}
可以表达为:
canStart = permissionGranted
&& cameraAvailable
&& surfaceIdNotEmpty
三个条件缺一不可。把检查集中在一个方法里,权限回调、Surface onLoad和设备切换都可以安全调用 startCameraPreview(),未满足时只返回。
四、权限与Surface顺序不可假设
可能时序A:
页面出现 -> Surface onLoad -> 无权限,启动返回
-> 用户授权 -> 再次调用start -> 成功
可能时序B:
已有权限 -> 页面出现 -> 设备发现
-> Surface尚未onLoad,启动返回
-> onLoad获得ID -> 再次调用start -> 成功
因此每个条件从 false 变 true 时都可以尝试启动,但最终是否执行由统一门禁决定。不要只在某一个回调里启动,否则另一种顺序会永远不触发。
五、设备发现也属于门禁
const state: CameraDeviceState =
CameraDeviceService.discover(context, this.activeCameraPosition);
this.cameraAvailable = state.available;
this.hasFrontCamera = state.hasFront;
this.hasBackCamera = state.hasBack;
this.activeCameraPosition = state.activePosition;
有权限不代表设备一定可用。模拟器、硬件故障或前后摄缺失都可能使 discover 失败。只有 available=true 才创建 CameraInput。
首选位置不可用时,设备服务回退到实际存在的位置,页面要同步更新按钮状态。
六、服务状态机阻止重复启动
预览状态定义:
export type CameraPreviewStatus =
'idle' | 'starting' | 'running' | 'error';
页面多个条件回调可能几乎同时调用 start。服务入口应判断:
if (status === 'starting') {
return currentState('相机正在启动');
}
if (status === 'running' &&
activeSurfaceId === surfaceId &&
activePosition === position) {
return currentState('相机预览已运行');
}
相同 Surface 和相同设备的重复启动应幂等返回,而不是重复创建 Session。
七、Surface ID变化必须重建输出
页面旋转、组件重建或导航返回可能生成新的 Surface ID。即使预览状态是 running,只要 ID 变化,旧 PreviewOutput 就不能继续使用。
const sameTarget =
runningSurfaceId === requestedSurfaceId &&
runningPosition === requestedPosition;
if (!sameTarget) {
await CameraPreviewService.release();
await CameraPreviewService.start(
context, requestedSurfaceId, requestedPosition);
}
不能只判断 status==='running' 就跳过启动。
八、onDestroy先使Surface失效
private async onPreviewSurfaceDestroy(): Promise<void> {
this.previewSurfaceId = '';
await CameraPreviewService.release();
this.previewStatus = 'idle';
}
先清空页面 ID,能让其他并发路径立即无法通过门禁;再等待服务释放 CameraSession、Output和Input。若反过来先 await release,等待期间旧 ID 仍可能被另一个回调读取并发起新启动。
九、异步start与destroy会发生竞态
典型时序:
onLoad获得surface-A
-> start(A)正在await创建CameraInput
-> 页面离开,onDestroy清空ID并release
-> start(A)继续完成,错误地进入running
仅清空 ID 不能取消已经开始的异步函数。需要 generation:
private previewGeneration: number = 0;
private async startCameraPreview(): Promise<void> {
const generation = ++this.previewGeneration;
const surfaceId = this.previewSurfaceId;
if (!this.canStartPreview(surfaceId)) return;
const state = await CameraPreviewService.start(
context, surfaceId, this.activeCameraPosition);
if (generation !== this.previewGeneration ||
surfaceId !== this.previewSurfaceId) {
await CameraPreviewService.release();
return;
}
this.previewStatus = state.status;
}
private async onPreviewSurfaceDestroy(): Promise<void> {
this.previewGeneration += 1;
this.previewSurfaceId = '';
await CameraPreviewService.release();
}
销毁递增 generation,使旧 start 结果失效。
十、不要在旧请求中释放新会话
上面的旧请求发现过期后直接调用全局 release() 也有风险:新 Surface 可能已经启动,旧请求会把新会话释放。服务应为每轮会话分配 token,并只释放自己的资源。
interface PreviewHandle {
token: number;
surfaceId: string;
}
release(handle) 先比较 token,只有当前活动会话匹配才释放。资源尽量使用局部变量构建,全部成功后再原子替换为 active session。
十一、启动过程使用事务式资源组装
创建CameraInput
-> 创建PreviewOutput(surfaceId)
-> 创建PhotoOutput
-> 创建CaptureSession
-> beginConfig
-> addInput/addOutput
-> commitConfig
-> start
-> 标记active
任一步失败,都要逆序释放已经创建的局部资源。不要创建到一半就写入全局静态字段,否则另一轮 release 无法判断哪些对象已就绪。
let input: camera.CameraInput | null = null;
let preview: camera.PreviewOutput | null = null;
let session: camera.PhotoSession | null = null;
try {
// create and start
} catch (error) {
await safeRelease(session);
await safeRelease(preview);
await safeRelease(input);
throw error;
}
十二、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可能仍存在但相机应停止,以遵守资源和隐私边界。
页面生命周期需要额外处理:
async aboutToDisappear(): Promise<void> {
this.previewGeneration += 1;
await CameraPreviewService.release();
}
再次出现时重新发现设备、确认权限,并等待有效 Surface 后启动。不要把 onDestroy 当成唯一释放入口。
十五、拍照按钮读取running状态
private isRealPreviewRunning(): boolean {
return this.previewStatus === 'running';
}
拍照入口先检查,避免 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和经过缩短的资源标识,不记录图像帧、用户水印或完整运行时对象。
十九、自动化测试状态机
把门禁抽成纯函数:
function canStartPreview(state: PreviewPrerequisites): boolean {
return state.permissionGranted &&
state.cameraAvailable &&
state.surfaceId.length > 0;
}
测试8种真假组合,只有三者全真才返回 true。
异步测试使用 fake service 控制 start 延迟:
- 调用 start(A) 并暂停。
- 触发 destroy,使 generation 变化。
- 让 start(A) 返回 running。
- 断言页面不接受旧结果。
- 新 start(B) 不被旧任务释放。
二十、真机验收矩阵
| 场景 | 期望 |
|---|---|
| 首次授权前Surface先到 | 不启动,授权后自动启动 |
| 已授权但Surface后到 | onLoad后启动 |
| 快速进入退出页面 | 无残留会话、无崩溃 |
| 后台再前台 | 旧会话释放,新会话恢复 |
| 前后摄连续切换 | 始终最多一个活动会话 |
| 旋转/组件重建 | 使用新Surface ID |
| 启动中销毁 | 旧结果不写回running |
| Surface为空 | 不创建PreviewOutput |
| 相机硬件不可用 | 显示可理解错误 |
| 反复进入100次 | 相机资源和内存不持续增长 |
二十一、常见问题排查
| 现象 | 高概率原因 | 排查点 |
|---|---|---|
| 首次进入黑屏 | 只在权限回调启动 | onLoad也尝试启动 |
| 返回页面仍黑屏 | 复用了旧Surface ID | 比较目标ID |
| 快速退出后相机灯仍亮 | start/destroy竞态 | generation与token |
| 切换摄像头失败 | running状态阻止重建 | position纳入目标 |
| 偶发双会话 | 多入口同时start | starting门闩 |
| 拍照提示Output未就绪 | UI状态早于服务完成 | running双层检查 |
二十二、发布前验收清单
- XComponent使用SURFACE类型并在onLoad读取ID。
- 权限、设备、Surface三条件统一门禁。
- 每个条件变为可用时都可安全尝试启动。
- 相同目标重复start具备幂等性。
- Surface ID或摄像头位置变化会重建会话。
- onDestroy先使页面ID和generation失效。
- 旧异步任务不能写回或释放新会话。
- 启动半成品在失败时逆序释放。
- 前后台生命周期也释放CameraKit资源。
- UI与服务都检查running/Output状态。
- 真机覆盖快速进入退出和组件重建。
二十三、项目真实启动代码复盘
页面当前实现把三项门禁集中在启动方法中:
private async startCameraPreview(): Promise<void> {
if (!this.isCameraGranted() ||
!this.cameraAvailable ||
this.previewSurfaceId.length === 0) {
return;
}
this.previewStatus = 'starting';
this.previewStatusText = '正在启动相机预览';
const context: common.UIAbilityContext =
this.getUIContext().getHostContext()
as common.UIAbilityContext;
const result: CameraPreviewState =
await CameraPreviewService.startPreview(
context,
this.previewSurfaceId,
this.activeCameraPosition
);
this.previewStatus = result.status;
this.previewStatusText = result.message;
this.captureStatusText = result.status === 'running' ?
'真实相机预览中' : result.message;
}
Surface回调只负责更新资源条件:
private onPreviewSurfaceReady(): void {
this.previewSurfaceId =
this.previewController.getXComponentSurfaceId();
if (this.isCameraGranted() && this.cameraAvailable) {
this.startCameraPreview();
}
}
private async onPreviewSurfaceDestroy(): Promise<void> {
this.previewSurfaceId = '';
this.previewStatus = 'idle';
this.previewStatusText = '真实预览未启动';
await CameraPreviewService.stopPreview();
}
这组代码已经形成基本闭环:ID为空时启动门禁失败,销毁先清空ID再停止服务。generation与会话token是在此基础上处理极端异步竞态的增强项。
二十四、权限、设备和Surface的状态转换表
| 权限 | 设备 | Surface | 当前动作 | 目标状态 |
|---|---|---|---|---|
| 未授权 | 未知 | 空 | 等待用户操作 | idle |
| 未授权 | 未知 | 已就绪 | 显示权限覆盖层 | idle |
| 已授权 | 不可用 | 已就绪 | 展示设备错误 | error/idle |
| 已授权 | 可用 | 空 | 等待onLoad | idle |
| 已授权 | 可用 | 已就绪 | 启动PreviewSession | starting |
| 已授权 | 可用 | 同一ID运行中 | 幂等返回 | running |
| 已授权 | 可用 | 新ID | 重建输出 | starting |
| 任意 | 任意 | onDestroy | 失效并停止 | idle |
状态表可直接用于代码评审:任何新增入口都只能触发表中允许的转换,不能绕过门禁直接创建 PreviewOutput。
二十五、Hypium门禁与竞态测试
先把门禁抽成无副作用函数:
interface PreviewPrerequisites {
granted: boolean;
available: boolean;
surfaceId: string;
}
function canStart(state: PreviewPrerequisites): boolean {
return state.granted &&
state.available &&
state.surfaceId.length > 0;
}
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, generation=1)
-> 触发onDestroy,generation变为2且ID清空
-> 让旧Promise返回running
-> 断言页面仍为idle
-> onLoad获得surface-B并启动generation=3
-> 断言旧任务不会停止surface-B会话
这比单纯等待真机偶现黑屏更容易验证竞态修复。
二十六、版本兼容与构建检查
工程目标为 HarmonyOS 6.0.2(22)。迁移 SDK 或设备形态时,需要重新核对:
| 边界 | 检查项 |
|---|---|
| XComponent | SURFACE类型、controller与回调签名 |
| CameraKit | PreviewProfile、Session类型与释放顺序 |
| 页面生命周期 | onLoad/onDestroy与前后台回调时序 |
| 设备形态 | 手机横竖屏、折叠状态和窗口变化 |
| 严格模式 | 可空Surface ID与Promise返回类型 |
构建通过后还要在真机记录以下检查结果:
冷启动首次授权:授权后出现真实预览
已有授权冷启动:Surface就绪后自动预览
快速进入退出20次:没有相机占用残留
前后摄切换20次:始终只有一个活动会话
后台停留再返回:预览可恢复且拍照可用
组件重建:新Surface ID接管,旧回调不写回
构建验证解决类型和API问题,真机循环验证解决系统资源与异步时序问题,两者缺一不可。
总结
XComponent 预览稳定的关键,是承认权限、相机设备和 Surface 来自三条独立时序。页面把三者收敛到统一门禁,onLoad、授权回调和设备刷新都只负责“尝试启动”;服务状态机保证相同目标幂等,目标变化则重建。
进一步用 generation 和会话 token 隔离异步 start/destroy,采用局部资源事务组装与逆序释放,就能避免黑屏、双会话、旧 Surface 复用和页面退出后相机仍占用等问题。Surface ID不是普通字符串,而是一段有明确创建与失效时刻的系统资源引用。
更多推荐
所有评论(0)