一个扫码页,预览正常,对焦正常,点击拍照按钮的那一刻,你期望画面“定住”一下,然后弹出结果。但真机上,capture() 调了,回调也回来了,照片确实存下来了,屏幕上那张预览图却还在实时刷新,像是快门从来没按过。用户看着画面以为没拍上,又按了一次,结果相册里多出一张一模一样的照片。

你翻代码,PhotoOutput.capture() 的执行顺序没问题,错误码也没有。问题不在 capture 本身,而在你对 Camera Kit 会话模型的理解:预览流和拍照流是同一个 Session 下两条独立的输出管道,capture() 触发的是 PhotoOutput 那条管子,它不负责让 PreviewOutput 停下来。要让画面“定住”,需要你自己在拍照前后处理预览流的启停,而不是假设相机框架会替你暂停它。

但这只是表层现象。Camera Kit 真正的脾气在于它的状态机和资源归属:CameraInput、CaptureSession、PreviewOutput、PhotoOutput 各自有独立的生命周期,彼此之间的依赖顺序是硬的,谁先谁后写错了,表现不是报错,而是黑屏、画面拉伸、或者第二次调用直接静默失效。

CameraInput 的 open 不 await,后面每一步都在流沙上盖楼

先看一个最常见的顺序错误。很多人把 createCameraInput 当成了“打开相机”,拿到句柄之后直接 session.addInput,结果抛异常或者预览黑屏。createCameraInput(camera) 只创建一个输入对象的壳,此时相机硬件根本没有被占用。真正打开设备、申请底层资源的是 await cameraInput.open(),而且必须 await。

如果漏掉 await,open 还在进行中,beginConfig 和 addInput 就已经开始了。addInput 的时序检测发现输入还没就绪,直接拒绝,但错误信息往往不会告诉你“因为你没等 open”。更隐蔽的是,有时候它不报错,只是后续 commitConfig 或 start 的时候静默失败,预览黑屏,日志里干干净净。

正确的顺序钉死成一条链:createCameraInput → await open() → beginConfig → addInput → addOutput → commitConfig → start。beginConfig 和 commitConfig 之间是配置窗口,所有 add 和 remove 操作必须落在这个区间内。在 beginConfig 之前调 commitConfig,或者 session 还没 config 就 start,都会被时序检测拦下来,报 Session not config。

typescript

async function setupSession(cameraManager: camera.CameraManager): Promise<void> {
  const cameras = cameraManager.getSupportedCameras();
  if (cameras.length === 0) return;
  const cameraInput = cameraManager.createCameraInput(cameras[0]);
  await cameraInput.open();               // 必须 await,打开硬件

  const session = cameraManager.createCaptureSession();
  session.beginConfig();                  // 打开配置窗口
  session.addInput(cameraInput);          // 此时输入已就绪

  const previewOutput = cameraManager.createPreviewOutput(profile, surfaceId);
  session.addOutput(previewOutput);
  session.commitConfig();                 // 提交配置,之后不能再 add/remove
  await session.start();
}

beginConfig 之后如果 addInput 抛异常,不要跳过它继续 addOutput。输入加不进去,输出加进去也没有意义,commitConfig 会把一个不完整的会话提交上去,start 时黑屏但未必报错。每一步 add 之后检查一下是否有异常,比事后对着黑屏猜要省时间。

回前台重建不是“再 start 一次”,旧代次的回调会在新画面上捣乱

切后台再回来,预览黑屏,几乎每个相机应用都会遇到一次。系统在应用进入后台时会出于隐私和资源管理回收相机会话,回到前台时 session 已经失效了,不能继续用。

第一反应是在 onForeground 里重新 start 旧的 session。能跑通一次,但第二次、第三次就开始出怪事:预览偶尔黑屏,偶尔闪一下旧画面,error 回调里冒出一些莫名其妙的状态。根因不是 start 调错了,而是旧的异步回调还在路上。你在后台释放了第 N 代的资源,前台创建了第 N+1 代,第 N 代的 error 或状态回调此时到达,把你的协调器状态改成了一个已经被销毁的会话的视角。

解法不是用布尔值 isOpening 挡住重复进入,而是给每一轮资源分配一个代次。CameraInput、CaptureSession、PreviewOutput、PhotoOutput 以及所有监听器都归属当前代次,回调进入协调器第一件事是比对代次,不匹配直接丢弃。布尔值只能阻止重复创建,它回答不了“这个回调属于哪一次打开”。

typescript

class CameraCoordinator {
  private generation: number = 0;

  private isCurrent(generation: number): boolean {
    return generation === this.generation;
  }

  async reopen(): Promise<void> {
    const myGen = ++this.generation;      // 先递增,让旧代次失效
    const input = await this.createInput();
    if (!this.isCurrent(myGen)) {         // open 期间又发生了一次重建
      await this.releaseInput(input);
      return;
    }
    const session = await this.createSession(input);
    if (!this.isCurrent(myGen)) {
      await this.releaseSession(session);
      return;
    }
    // 监听器回调里带上 myGen,不匹配的直接 return
  }
}

重建还需要等两个条件同时满足:页面可见,且预览 Surface 已经就绪。回到前台不代表 XComponent 的 surface 已经创建完成,如果 surfaceId 还是旧的或者空的,createPreviewOutput 会成功但画面出不来。

多摄同开的能力集是硬边界,不是“有两颗镜头就能同时开”

前后双摄同开从 API 18 开始支持,但它的限制比很多人预期的多得多。当前仅支持前置加后置的预览和录像,双摄同时拍照尚不支持。而且并发模式下只能使用七项基础功能:闪光灯、曝光、变焦、曝光补偿、对焦、防抖、色彩空间。超出这个范围的任何设置、查询或使能,都不保证行为。

最关键的一步不是创建两个 Session,而是先查并发能力集。getCameraConcurrentInfos 返回当前设备允许的并发组合和对应的输出能力,返回空数组就表示这台设备根本不支持你想要的组合。配对的 mode 和 profile 必须落在返回的能力集之内,否则创建阶段就会失败或者运行时不输出。

后置广角加超广角这种组合,不能从前置加后置的支持里类推。即使能力集非空,两颗镜头也是各自独立的 Session,对焦和曝光分别在各自的 Session 上设置,没有跨摄像头的硬件级同步接口。设成相同的模式不等于同步生效,不保证同一帧。能做的是软件层对齐:两边设同一个对焦模式和曝光模式,用户点击时把归一化坐标同时设给两个 Session,或者主摄对焦成功后读取对焦点再设给副摄。曝光补偿要落在两边支持范围的交集里。

快门声绑在哪个时刻,用户听到的“拍到了”就在哪个时刻

快门声看起来是最小的事,但它暴露的是同一条时序思维。CameraPicker 不提供拍照声音控制的参数,系统相机的静音设置也不会被自动继承。如果使用自定义相机,声音播放在哪个回调之后触发,决定了用户感知到的“成功”到底对应什么。

在 capture() 调用之前播放,声音代表“我收到了点击”,不代表照片拍到了;在 capture() 返回成功之后播放,代表“系统接受了请求”,但照片可能还没落盘;在 photoAvailable 确认之后播放,才代表“照片真的可用了”。对于扫码或文档扫描这类场景,第三种更诚实:相机不可用或存储失败时,不应该发出一声“已经拍到了”的错觉。

播放本身也有资源边界。每次拍照新建一个 AudioRenderer,异常路径如果没有 stop 和 release,连续拍摄之后会出现声音叠加或对象泄漏。用 try/finally 把释放闭环,或者在服务层复用一个已经初始化的播放器,避免把音频生命周期散落在页面代码里。

资源归属:谁创建的,谁负责在正确的代次里释放

把上面几条串起来,Camera Kit 的稳定接入可以收敛成一句:每一代资源只属于创建它的那个协调器,回调只认代次,不认页面。

CameraInput 是排他资源,不要放进应用全局单例。全局引用会掩盖所有权,让“谁该释放”变得模糊。页面的 onPageShow 只报告可见性和 surface 状态,不直接创建相机对象;onBackground 里按“停止会话 → 释放 output → 释放 input”的顺序清理,而不是只把引用置空。回到前台时走完整的新建链路,用新代次隔离旧回调。

addOutput 之前先用 canAddOutput 确认这个 output 能不能加进去,比加进去失败再回头猜要直接。createPreviewOutput 的 profile 从 getSupportedOutputCapability 返回的能力集里取,不要手写分辨率;previewOutput 和 photoOutput 的宽高比不一致,预览会被拉伸。这些不是“优化项”,是会话能不能正常工作的前提。

综上所述,Camera Kit 的黑屏和“按了没反应”,很少是某一个 API 调错了,而是一组时序和归属的隐性契约没有被满足:open 必须 await,beginConfig 和 commitConfig 之间才是合法的配置窗口;回前台要重建而不是重启,代次是隔离旧回调的唯一可靠手段;多摄同开先查能力集,能开不等于所有功能都能用,两颗镜头的对焦曝光没有硬件同步;快门声绑在哪个回调之后,决定了用户听到的“成功”是不是真的。下一次预览黑屏或者拍照没反馈的时候,先别翻 capture 的参数,打开协调器看一眼:当前这一代资源,是在哪个顺序里被创建、被提交、被释放的。状态机不认“我觉得应该好了”,它只认你写没写对那条链。

Logo

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

更多推荐