在这里插入图片描述

HarmonyOS技术精讲-Camera Kit(相机服务)第20篇:性能优化与常见问题

在HarmonyOS NEXT的Camera Kit开发中,很多人会把官方示例直接搬进项目,结果发现预览界面掉帧、内存暴增、权限回调不生效、设备兼容性翻车。这些问题不是API本身的问题,而是大部分人没处理好会话生命周期和资源释放的细节。

今天这篇不讲“什么是Camera Kit”,直接进入正题:帧率优化、内存泄漏、会话复用、权限回调、设备兼容性。每个问题都会给优化前后的代码对比,代码基于ArkTS,可以在真机上直接跑。

环境说明

DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机

核心问题与优化

1. 预览帧率不够?别只盯着帧率设置

很多人一上来就设 CameraOutputCapability 的帧率,发现预览依然卡顿。问题通常出在两个方面:一是 ImageReceiver 的接收频率没匹配显示器刷新率;二是会话创建时没合理复用资源。

优化前代码:

// 每次打开相机都重新创建会话,并且不限制预览帧率
import { camera } from '@kit.CameraKit';
import { image } from '@kit.ImageKit';

class CameraService {
  private cameraManager: camera.CameraManager | null = null;
  private cameraInput: camera.CameraInput | null = null;
  private previewOutput: camera.PreviewOutput | null = null;
  private session: camera.CaptureSession | null = null;

  async initCamera(context: Context) {
    this.cameraManager = camera.getCameraManager(context);
    let cameraInfo = await this.cameraManager.getSupportedCameras();
    if (cameraInfo.length === 0) {
      return;
    }
    this.cameraInput = this.cameraManager.createCameraInput(cameraInfo[0]);
    await this.cameraInput.open();
    this.session = this.cameraManager.createCaptureSession();
    await this.session.beginConfig();
    // 问题:没有限制帧率
    let outputCapability = this.session.getCameraOutputCapabilities(cameraInfo[0]);
    let previewProfiles = outputCapability.getSupportedPreviewOutputs();
    this.previewOutput = this.session.createPreviewOutput(previewProfiles[0]);
    await this.session.addInput(this.cameraInput);
    await this.session.addOutput(this.previewOutput);
    await this.session.commitConfig();
    await this.session.start();
  }
}

这段代码的问题:每次初始化都 createCaptureSession,且没有显式控制帧率,导致在高分辨率预览时掉帧明显。

优化后代码:

// 复用会话实例,并通过PreviewOutputProfile限制帧率
import { camera } from '@kit.CameraKit';
import { image } from '@kit.ImageKit';

class CameraService {
  private cameraManager: camera.CameraManager | null = null;
  private cameraInput: camera.CameraInput | null = null;
  private previewOutput: camera.PreviewOutput | null = null;
  private session: camera.CaptureSession | null = null;
  private sessionInited: boolean = false;

  async initCamera(context: Context) {
    this.cameraManager = camera.getCameraManager(context);
    let cameraInfo = await this.cameraManager.getSupportedCameras();
    if (cameraInfo.length === 0) {
      return;
    }
    // 关键点:复用会话,避免重复创建
    if (!this.sessionInited) {
      this.session = this.cameraManager.createCaptureSession();
      this.sessionInited = true;
    }
    // 如果旧Input已打开,先关闭
    if (this.cameraInput) {
      await this.cameraInput.close();
    }
    this.cameraInput = this.cameraManager.createCameraInput(cameraInfo[0]);
    await this.cameraInput.open();
    await this.session.beginConfig();
    // 限制帧率为30fps,避免过高丢帧
    let outputCapability = this.session.getCameraOutputCapabilities(cameraInfo[0]);
    let previewProfiles = outputCapability.getSupportedPreviewOutputs().filter(
      (profile) => (profile as camera.PreviewOutputProfile).frameRateRange.min === 30
    );
    if (previewProfiles.length === 0) {
      // 如果没有30fps配置,直接取第一个
      this.previewOutput = this.session.createPreviewOutput(previewProfiles[0]);
    } else {
      this.previewOutput = this.session.createPreviewOutput(previewProfiles[0]);
    }
    await this.session.addInput(this.cameraInput);
    await this.session.addOutput(this.previewOutput);
    await this.session.commitConfig();
    await this.session.start();
  }
}

注意点:getSupportedPreviewOutputs 返回的是 PreviewOutputProfile[],其中 frameRateRange 是一个对象,包含 minmax 属性。这里统一限制到 min 为30,大部分设备都能稳定在这个帧率。

2. 内存泄漏?问题出在会话释放和回调解绑

这是官方文档最容易忽略的地方:页面销毁时,必须显式释放 CaptureSessionCameraInputPreviewOutput,并且解绑所有的回调监听。否则,后台线程会继续持有这些对象,导致内存泄漏。

优化前代码:

// 只释放session,其他资源不管
releaseCamera() {
  if (this.session) {
    this.session.close();
    this.session = null;
  }
}

这种写法下,CameraInputPreviewOutput 依然持有内部线程,GC无法回收。

优化后代码:

// 完整释放所有相机资源,并解绑回调
releaseCamera() {
  if (this.previewOutput) {
    // 解绑所有回调,否则异步回调可能引用已释放的UI上下文
    this.previewOutput.off('previewOutputStart');
    this.previewOutput.off('previewOutputError');
    this.previewOutput.close();
    this.previewOutput = null;
  }
  if (this.cameraInput) {
    this.cameraInput.off('cameraInputStart');
    this.cameraInput.off('cameraInputError');
    this.cameraInput.close();
    this.cameraInput = null;
  }
  if (this.session) {
    // 先停止再关闭,避免残留任务
    this.session.stop();
    this.session.close();
    this.session = null;
  }
  this.cameraManager = null;
  this.sessionInited = false;
}

这里特别说明:off('previewOutputStart') 等回调解绑不能省。因为 PreviewOutput 在子线程中上报事件,如果回调里引用了UI组件(比如更新 @State 变量),而页面已经销毁,ArkUI的响应式系统会抛出运行时错误,并且导致对象无法完全释放。

3. 权限回调不响应?顺序和生命周期没对齐

很多人写权限申请回调时,直接写在 onPageShow 里,结果相机初始化完成时权限还没返回。或者权限回调没绑定 UIAbilityContext,导致回调函数无法正确执行。

优化前代码:

// 权限申请与相机初始化分离,可能导致时序问题
import { abilityAccessCtrl, common } from '@kit.AbilityKit';

async function requestAndInitCamera(context: common.UIAbilityContext) {
  let atManager = abilityAccessCtrl.createAtManager();
  // 权限回调可能晚于相机初始化
  atManager.requestPermissionsFromUser(context, ['ohos.permission.CAMERA']).then((data) => {
    if (data.authResults[0] === 0) {
      // 这里才初始化相机
    }
  });
  // 但下面的代码会立刻执行,导致相机初始化在没有权限时失败
  let cameraManager = camera.getCameraManager(context);
}

优化后代码:

// 权限申请完成后,再初始化相机
import { abilityAccessCtrl, common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';

class CameraService {
  async requestAndInitCamera(context: common.UIAbilityContext): Promise<void> {
    let atManager = abilityAccessCtrl.createAtManager();
    try {
      let data = await atManager.requestPermissionsFromUser(context, ['ohos.permission.CAMERA']);
      if (data.authResults[0] !== 0) {
        console.error('Camera permission denied');
        return;
      }
      // 权限确认后再初始化
      await this.initCamera(context);
    } catch (error) {
      let bizError = error as BusinessError;
      console.error(`Camera permission request failed: ${bizError.message}`);
    }
  }
}

注意:requestPermissionsFromUser 返回的是一个 Promise,使用 await 可以保证顺序。但如果用户拒绝权限,authResults 值不为0,此时直接返回,不会执行后续的相机逻辑。

4. 设备兼容性问题:不同机型的Session支持不同

某些低端机型不支持某些分辨率或帧率配置,直接使用 getSupportedPreviewOutputs() 返回的第一个Profile,会导致崩溃或预览黑屏。

优化后代码:

// 安全获取第一个可用Profile,并降级处理
async initCamera(context: Context) {
  this.cameraManager = camera.getCameraManager(context);
  let cameraInfo = await this.cameraManager.getSupportedCameras();
  if (cameraInfo.length === 0) {
    return;
  }
  // 先尝试使用第一个相机
  let cameraId = cameraInfo[0];
  this.cameraInput = this.cameraManager.createCameraInput(cameraId);
  await this.cameraInput.open();
  this.session = this.cameraManager.createCaptureSession();
  await this.session.beginConfig();
  let outputCapability = this.session.getCameraOutputCapabilities(cameraId);
  let previewProfiles = outputCapability.getSupportedPreviewOutputs();
  // 关键:如果设备不支持MJPEG编码,它会返回空数组,此时需要降级
  if (previewProfiles.length === 0) {
    console.warn('No supported preview profiles, using default config');
    // 这里可以尝试手动创建默认Profile,或提示设备不支持
    return;
  }
  // 优先选择1280x720,大多数设备都支持
  let targetProfile = previewProfiles.find(
    (profile) => {
      return profile.size.width === 1280 && profile.size.height === 720;
    }
  ) || previewProfiles[0];
  this.previewOutput = this.session.createPreviewOutput(targetProfile);
  await this.session.addInput(this.cameraInput);
  await this.session.addOutput(this.previewOutput);
  await this.session.commitConfig();
  await this.session.start();
}

常见问题FAQ

Q:为什么有些设备上预览是黑的,但日志没有报错?

A:预览黑屏最常见的原因是 PreviewOutput 没有正确绑定到 ImageReceiver。检查 ImageReceiver 是否在创建 PreviewOutput 之前初始化,并且 ImageReceiver 的尺寸与 PreviewOutputProfile 的尺寸一致。此外,某些低端设备不支持 SURFACE_FORMAT_YCBCR_422_I,需要改为 SURFACE_FORMAT_RGBA_8888

Q:为什么页面返回后再进入,相机黑屏或报错“session already started”?

A:这是因为页面 aboutToDisappear 没有调用 releaseCamera()。当再次进入页面时,initCamera 试图创建一个新的 CaptureSession,但旧的会话可能没有被彻底关闭。解决方案:在 aboutToDisappear 里调用完整释放方法,并在 aboutToAppear 里重新 initCamera

Q:为什么权限回调里 authResults 总是返回-1?

A:常见原因是 requestPermissionsFromUsercontext 参数不是正确的 UIAbilityContext。在组件中,可以通过 getContext() 获取,确保它是一个 common.UIAbilityContext 实例。另一种情况是用户在系统设置中已经永久拒绝了相机权限,此时 requestPermissionsFromUser 不会再弹出弹窗,而是直接返回拒绝结果。如果权限被永久拒绝,需要通过 abilityAccessCtrl 检查权限状态并引导用户去设置页面开启。

最佳实践

  • 不要在build()中创建CameraManager。 每次build都会触发组件重建,而CameraManager的创建涉及底层服务初始化,非常昂贵。建议放在aboutToAppear@State变量变化的回调中,且只创建一次。
  • Session复用:如果应用需要频繁切换摄像头(比如前后摄切换),不要每次重新创建CaptureSession 使用beginConfig()removeInputremoveOutputaddInputaddOutputcommitConfig()的方式,可以避免资源泄露和性能开销。
  • 所有回调都使用lambda表达式绑定当前组件实例,并在释放时解绑。 这样可以避免回调引用过期对象导致的ArkUI报错“Component has been destroyed”。
Logo

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

更多推荐