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

摘要

CameraKit 的 PreviewOutput 需要可用 Surface,但 ArkUI 页面创建不等于 Surface 已就绪。相机权限、设备发现和 XComponent onLoad 又是三条独立时序:权限先到但 Surface 为空,不能启动;Surface 已到但用户拒绝权限,也不能启动;页面销毁时正在执行异步 start,还可能把已失效的 Surface 重新写进会话。

本文基于 D:/APP/1quweixiangjiIndex.etsCameraPreviewService.ets,围绕 XComponentType.SURFACEgetXComponentSurfaceId()、预览启动门禁、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 当前设备位置

HarmonyOS趣味相机Surface预览链路

一、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 延迟:

  1. 调用 start(A) 并暂停。
  2. 触发 destroy,使 generation 变化。
  3. 让 start(A) 返回 running。
  4. 断言页面不接受旧结果。
  5. 新 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不是普通字符串,而是一段有明确创建与失效时刻的系统资源引用。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐