HarmonyOS趣味相机实战第5篇:CameraKit资源释放、PixelMap生命周期与预览黑屏排查

摘要

相机应用最容易出现的不是“代码编译不过”,而是运行一段时间后预览黑屏、切后台回来相机被占用、连续拍照后卡顿、保存后内存涨、前后摄像头切换失败。这些问题通常不是一个 UI 按钮能解决的,而是 CameraKit 会话、PreviewOutput、PhotoOutput、MetadataOutput、CameraInput、PixelMap、ImageSource、CoreVision 检测器和页面状态没有形成清晰的生命周期。

本文继续基于 D:/APP/1quweixiangji HarmonyOS ArkTS 水印相机项目,复盘第 5 条工程主线:资源释放和稳定性治理。重点看四个位置:

  1. Index.ets 页面消失时如何释放预览 PixelMap 和相机会话。
  2. captureNow() 拍照前为什么要先释放上一张预览图。
  3. CameraPreviewService.analyzeCapturedPhoto() 如何在“交给页面展示”和“内部失败释放”之间控制 PixelMap 所有权。
  4. CameraPreviewService.stopPreview() 如何按顺序停止会话、解绑回调、释放输出、关闭输入和释放 CoreVision。

这篇文章不是泛泛讲“记得释放资源”,而是把项目里真实的资源链路拆出来,给出可以复用的排查表和验收清单。

工程背景与源码定位

文件 作用
entry/src/main/ets/pages/Index.ets 页面生命周期、拍照防重复、预览 PixelMap 释放、保存/关闭预览
entry/src/main/ets/service/CameraPreviewService.ets CameraKit 会话、拍照输出、metadata 输出、实时采样和资源释放
entry/src/main/ets/service/CoreVisionHumanService.ets 骨骼检测器和主体分割资源释放
entry/src/main/ets/service/PhotoAlbumService.ets 保存照片元数据,不保存 PixelMap 大对象
entry/src/main/ets/service/PhotoDocumentService.ets 转文档时保存元数据,不长期持有图像对象
entry/src/main/module.json5 相机权限声明
entry/src/test/LocalUnit.test.ets 当前已有照片快照测试,可继续扩展资源状态纯函数测试

环境与版本信息

项目 当前值 说明
工程路径 D:/APP/1quweixiangji 本文引用该项目源码
工程类型 HarmonyOS Stage 模型 EntryAbility 加载主页面
target SDK 6.0.2(22) 以当前工程配置为准
bundleName com.fun.quweixiangji 来自应用配置
versionName 1.0.1 当前复盘版本
相机能力 @kit.CameraKit 预览、拍照、metadata
图像对象 @ohos.multimedia.image PixelMap、ImageSource
视觉分析 @kit.CoreVisionKit 可选人体识别和主体分割
本地存储 @kit.ArkData Preferences 只保存元数据,不保存大图二进制

HarmonyOS CameraKit 资源生命周期复盘

本地构建命令:

cd D:\APP\1quweixiangji
$env:JAVA_HOME='D:\Program Files\Huawei\DevEco Studio\jbr'
$env:Path="$env:JAVA_HOME\bin;$env:Path"
& 'D:\Program Files\Huawei\DevEco Studio\tools\hvigor\bin\hvigorw.bat' --mode module -p module=entry@default -p product=default assembleHap --no-daemon

一、先画清资源所有权

这个项目里和相机稳定性相关的对象可以分成三层:

层级 对象 所有者 释放位置
CameraKit 会话 PhotoSession CameraPreviewService stopPreview()
预览输出 PreviewOutput CameraPreviewService stopPreview()
拍照输出 PhotoOutput CameraPreviewService stopPreview()
metadata 输出 MetadataOutput CameraPreviewService stopPreview()
相机输入 CameraInput CameraPreviewService stopPreview()
拍照回调 photoAvailableCallback CameraPreviewService photoOutput.off()
metadata 回调 metadataAvailableCallback CameraPreviewService metadataOutput.off()
拍照结果图 PixelMap 先由服务创建,成功后交给页面 页面 releasePreviewPixelMap()
解码源 ImageSource CameraPreviewService analyzeCapturedPhoto() finally
原始照片 camera.Photo CameraPreviewService analyzeCapturedPhoto() finally
实时采样图 PixelMap CameraPreviewService sampleLiveVision() finally
视觉检测器 CoreVision detector/segmentation CoreVisionHumanService CoreVisionHumanService.release()

这张表是排查黑屏和卡顿的基础。只要出现“谁创建、谁释放”不清楚,就容易出现重复释放、没释放或页面拿到已释放对象。

二、页面消失时释放预览和相机会话

Index.ets 在页面出现时读取相册和文档:

async aboutToAppear(): Promise<void> {
  await this.refreshAlbum();
  await this.refreshDocuments();
}

页面消失时释放资源:

async aboutToDisappear(): Promise<void> {
  await this.releasePreviewPixelMap();
  await CameraPreviewService.stopPreview();
}

这里顺序合理:先释放页面持有的预览图,再停止相机服务。因为 previewPhotoPixelMap 是页面状态对象,CameraPreviewService.stopPreview() 不应该越过页面去释放它。

如果这一步缺失,常见后果是:

场景 后果
从页面返回再进入 相机被旧会话占用,预览黑屏
切后台再回来 旧输出没有释放,新会话启动失败
多次拍照不关闭预览 页面持有多个大图对象,内存压力升高

三、拍照入口先防重复,再释放旧图

拍照入口先防重复:

private async capturePhoto(): Promise<void> {
  if (this.isCapturing || this.countdownText.length > 0) {
    return;
  }
  if (this.timerSeconds > 0) {
    await this.runCountdown();
  }
  await this.captureNow();
}

真正拍照时:

private async captureNow(): Promise<void> {
  if (this.isCapturing) {
    return;
  }
  this.isCapturing = true;
  await this.releasePreviewPixelMap();
  if (this.shutterSoundEnabled) {
    this.triggerShutterFeedback();
  }
  this.captureStatusText = '正在拍照';
}

await this.releasePreviewPixelMap() 放在拍照前很关键。否则用户连续拍照时,上一张预览图还被页面持有,新的拍照结果又创建一张 PixelMap,内存会叠加。

四、未启动真实预览时不要创建照片记录

captureNow() 会检查真实预览:

if (!this.isRealPreviewRunning()) {
  this.captureStatusText = '请先启用相机预览';
  this.isCapturing = false;
  return;
}

然后才调用拍照服务:

const captureState: CameraCaptureState =
  await CameraPreviewService.capturePhoto(this.captureQualityPreference(), false);
this.captureStatusText = captureState.message;
if (!captureState.realPhotoReceived || captureState.photoPixelMap === undefined) {
  this.isCapturing = false;
  return;
}

这段逻辑避免了“没有真实照片,却生成相册记录”的假成功。只有 realPhotoReceived 为 true 且拿到 photoPixelMap,页面才会创建 CapturedPhoto

五、页面接管 PixelMap 后必须负责释放

拍照成功后,服务把 PixelMap 交给页面:

this.previewPhotoPixelMap = captureState.photoPixelMap;
this.previewShotIndex += 1;
this.previewPhoto = PhotoAlbumService.createPhoto(
  this.previewShotIndex,
  [],
  '无滤镜',
  '无相框',
  '标准模式',
  'real',
  `${captureState.message} · ${this.resolutionLabel()}`,
  0,
  '标准',
  0,
  this.resolutionLabel(),
  this.watermarkSnapshot()
);

页面释放函数:

private async releasePreviewPixelMap(): Promise<void> {
  const pixelMap: image.PixelMap | null = this.previewPhotoPixelMap;
  this.previewPhotoPixelMap = null;
  if (pixelMap !== null) {
    try {
      await pixelMap.release();
    } catch (_) {
    }
  }
}

这里先把 this.previewPhotoPixelMap 置空,再释放旧对象,是一个很好的习惯。它能避免释放过程中 UI 再次引用旧对象,也能防止重复释放。

预览弹窗关闭时:

private async closePreview(): Promise<void> {
  this.previewPhoto = null;
  await this.releasePreviewPixelMap();
}

保存相册时:

private async savePreviewPhoto(): Promise<void> {
  if (!this.previewPhoto) {
    return;
  }
  this.album = await PhotoAlbumService.persistPhoto(this.previewPhoto);
  this.previewPhoto = null;
  await this.releasePreviewPixelMap();
  this.activeTab = 1;
  this.captureStatusText = '照片已保存到本地相册';
}

关闭、保存、转文档、页面退出都应该释放预览图,这就是页面层的职责。

六、服务层用 keepPixelMap 表达所有权转移

CameraPreviewService.analyzeCapturedPhoto() 里有一个关键变量:

let pixelMap: image.PixelMap | null = null;
let source: image.ImageSource | null = null;
let keepPixelMap: boolean = false;

解码照片:

const mainImage: image.Image = photo.main;
const component: image.Component = await mainImage.getComponent(image.ComponentType.JPEG);
source = image.createImageSource(component.byteBuffer);
pixelMap = await source.createPixelMap();

如果不做人体分析,就把 PixelMap 交给页面:

if (!analyzeTargets) {
  keepPixelMap = true;
  return {
    targets: [],
    pixelMap
  };
}

如果做人体分析,分析完仍然把 PixelMap 交给调用方:

const targets: DetectedTarget[] =
  await CoreVisionHumanService.analyzePixelMap(
    pixelMap,
    CameraPreviewService.previewRotation,
    CameraPreviewService.activeCameraPosition === 'front'
  );
keepPixelMap = true;
return {
  targets: CameraPreviewService.toCameraTargets(targets),
  pixelMap
};

失败或没有交出去时,finally 负责释放:

finally {
  if (pixelMap !== null && !keepPixelMap) {
    await pixelMap.release();
  }
  if (source !== null) {
    await source.release();
  }
  await photo.release();
}

keepPixelMap 的含义很清楚:为 true 表示 PixelMap 所有权已经转移给页面,服务层不能再释放;为 false 表示服务层自己负责释放。这比隐式约定安全得多。

七、ImageSource 和 camera.Photo 仍由服务释放

即使 PixelMap 交给页面,ImageSourcecamera.Photo 仍在服务层释放:

if (source !== null) {
  try {
    await source.release();
  } catch (releaseError) {
    hilog.warn(DOMAIN, TAG, 'release image source failed: %{public}s', JSON.stringify(releaseError));
  }
}
try {
  await photo.release();
} catch (releaseError) {
  hilog.warn(DOMAIN, TAG, 'release captured photo failed: %{public}s', JSON.stringify(releaseError));
}

这是正确边界:页面只需要展示 PixelMap,不应该关心解码源和底层 photo 对象。

八、capturePhoto() 用时间戳确认本次回调

服务层拍照前记录开始时间:

const captureStartedAt: number = Date.now();
await CameraPreviewService.photoOutput.capture(setting);
const realPhotoReceived: boolean =
  await CameraPreviewService.waitForPhotoAvailable(captureStartedAt, 350);

为什么要带 captureStartedAt?因为异步回调可能晚到。如果不区分时间,上一轮拍照回调可能被误认为本次拍照成功,导致页面显示旧图或错误状态。

拍照设置里还处理前置镜像:

const setting: camera.PhotoCaptureSetting = {
  quality: CameraPreviewService.captureQualityLevel(qualityPreference),
  mirror: CameraPreviewService.activeCameraPosition === 'front'
};

这类细节不是资源释放本身,但和“拍照结果是否可靠”直接相关。

九、stopPreview() 先断内部引用,再释放真实资源

CameraPreviewService.stopPreview() 开头先保存当前资源引用:

const session: camera.PhotoSession | null = CameraPreviewService.session;
const previewOutput: camera.PreviewOutput | null = CameraPreviewService.previewOutput;
const photoOutput: camera.PhotoOutput | null = CameraPreviewService.photoOutput;
const metadataOutput: camera.MetadataOutput | null = CameraPreviewService.metadataOutput;
const cameraInput: camera.CameraInput | null = CameraPreviewService.cameraInput;

然后立刻把静态状态清空:

CameraPreviewService.session = null;
CameraPreviewService.previewOutput = null;
CameraPreviewService.photoOutput = null;
CameraPreviewService.metadataOutput = null;
CameraPreviewService.cameraInput = null;
CameraPreviewService.photoAvailableCallback = null;
CameraPreviewService.metadataAvailableCallback = null;
CameraPreviewService.targetCallback = null;
CameraPreviewService.previewCoordinateWidth = 0;
CameraPreviewService.previewCoordinateHeight = 0;
CameraPreviewService.previewRotation = camera.ImageRotation.ROTATION_0;

这个顺序很有意义:即使后续某个 release 报错,服务层也不会继续认为旧会话还可用。新一轮启动预览时,可以从干净状态开始。

十、释放顺序:会话、输出、metadata、输入、CoreVision

释放会话:

if (session !== null) {
  await session.stop();
  await session.release();
}

释放预览输出:

if (previewOutput !== null) {
  await previewOutput.release();
}

释放拍照输出前先解绑回调:

if (photoOutput !== null) {
  if (photoAvailableCallback !== null) {
    photoOutput.off('photoAvailable', photoAvailableCallback);
  }
  await photoOutput.release();
}

释放 metadata 输出前也先解绑并 stop:

if (metadataOutput !== null) {
  if (metadataAvailableCallback !== null) {
    metadataOutput.off('metadataObjectsAvailable', metadataAvailableCallback);
  }
  await metadataOutput.stop();
  await metadataOutput.release();
}

最后关闭输入:

if (cameraInput !== null) {
  await cameraInput.close();
}

释放 CoreVision:

await CoreVisionHumanService.release();

这条顺序适合大多数相机项目复用:先停会话,再释放输出,再关输入,最后释放视觉分析资源。

十一、实时采样也要停止并等待忙碌任务结束

智能装饰模式下会有实时采样。停止逻辑:

private static async stopLiveVisionSampling(): Promise<void> {
  CameraPreviewService.cancelLiveVisionSampling();
  const deadline: number = Date.now() + 1200;
  while (CameraPreviewService.liveVisionBusy && Date.now() < deadline) {
    await new Promise<void>((resolve: () => void) => {
      setTimeout(() => resolve(), 40);
    });
  }
}

这里不是简单清 timer,而是等待当前采样任务最多 1200ms。原因是 sampleLiveVision() 可能正在从 Surface 创建 PixelMap 或调用 CoreVision,如果强行继续释放底层对象,容易产生时序问题。

实时采样内部也释放 PixelMap:

finally {
  if (pixelMap !== null) {
    await pixelMap.release();
  }
  CameraPreviewService.liveVisionBusy = false;
  CameraPreviewService.scheduleLiveVisionSample(LIVE_VISION_INTERVAL_MS, generation);
}

后续如果用户关闭智能装饰功能,也应该走同样的停止逻辑。

十二、CoreVisionHumanService 的释放边界

视觉服务里持有两个重资源:

private static skeletonDetector: skeletonDetection.SkeletonDetector | null = null;
private static segmentationReady: boolean = false;

释放方法:

static async release(): Promise<void> {
  if (CoreVisionHumanService.skeletonDetector !== null) {
    await CoreVisionHumanService.skeletonDetector.destroy();
    CoreVisionHumanService.skeletonDetector = null;
  }
  if (CoreVisionHumanService.segmentationReady) {
    await subjectSegmentation.release();
    CoreVisionHumanService.segmentationReady = false;
  }
}

如果接入智能贴纸 UI,这一步尤其关键。骨骼识别和主体分割不应该在页面退出后继续占资源。

十三、为什么相册和文档只保存元数据

拍照成功后,页面创建的是 CapturedPhoto

this.previewPhoto = PhotoAlbumService.createPhoto(...);

保存相册时:

this.album = await PhotoAlbumService.persistPhoto(this.previewPhoto);
this.previewPhoto = null;
await this.releasePreviewPixelMap();

当前项目保存的是标题、时间、分辨率、拍摄来源和水印快照,不把 PixelMap 直接放进 Preferences。这个边界非常重要:

数据 当前处理
PixelMap 用于预览,保存或关闭后释放
照片元数据 写入 Preferences
水印快照 写入照片元数据
真实图片文件 当前未长期保存
文档记录 保存元数据,不保存 PDF 文件

Preferences 适合轻量 JSON,不适合保存图片二进制或 Base64 大字段。后续如果要做真实图库,需要单独接应用沙箱文件、媒体库、缩略图和隐私说明。

十四、常见黑屏和卡顿排查

现象 可能原因 排查方式
返回页面后预览黑屏 上一次 PhotoSession 没释放 aboutToDisappear() 是否调用 stopPreview()
切换镜头失败 旧 camera input 没 close stopPreview()cameraInput.close()
连续拍照后卡顿 旧 PixelMap 没释放 captureNow() 前是否调用 releasePreviewPixelMap()
保存后内存仍高 保存后没有释放预览图 savePreviewPhoto()
关闭预览弹窗后仍占内存 closePreview() 没释放 PixelMap releasePreviewPixelMap()
Metadata 回调还在触发 释放输出前没有 off photoOutput.off()metadataOutput.off()
智能识别关闭后仍发热 实时采样 timer 未取消 stopLiveVisionSampling()
前置照片方向不对 mirror 或 rotation 未处理 PhotoCaptureSetting.mirrorresolvePreviewRotation()
拍照提示成功但没图 回调不是本次拍照 captureStartedAtwaitForPhotoAvailable()
写入相册后重启丢图 当前只保存元数据 明确是否已实现真实图片文件存储

十五、上线前验收清单

  • 页面退出时调用 releasePreviewPixelMap()
  • 页面退出时调用 CameraPreviewService.stopPreview()
  • 拍照前释放上一张预览 PixelMap。
  • 拍照时用 isCapturing 防止重复点击。
  • 倒计时期间不能重复触发拍照。
  • 未启动真实预览时不生成照片记录。
  • realPhotoReceived 为 false 时不展示假照片。
  • PixelMap 交给页面后,服务层不再释放。
  • PixelMap 没有交出去时,服务层在 finally 中释放。
  • ImageSource 在服务层 finally 中释放。
  • camera.Photo 在服务层 finally 中释放。
  • 停止预览时先清内部静态引用。
  • 释放 PhotoOutput 前解绑 photoAvailable
  • 释放 MetadataOutput 前解绑 metadata 回调并 stop。
  • 释放会话后释放输出,最后关闭输入。
  • 实时采样停止时取消 timer,并等待忙碌任务结束。
  • CoreVision 骨骼检测器和主体分割能力能释放。
  • Preferences 只保存轻量元数据,不保存图片二进制。
  • 真机回归前后台切换、连续拍照、切换镜头、关闭弹窗、保存相册和转文档。

十六、后续优化方向

当前资源生命周期已经比较清楚,但还可以继续优化:

方向 建议
预览图缩略 保存相册时生成缩略图文件,而不是长期持有 PixelMap
真实文件化 拍照后写入应用沙箱,再保存 imageUri
错误上报 统计 release failedcapture failedpreview failed 等日志
智能识别开关 只有用户开启贴纸时才采样 CoreVision
压力测试 连续拍照 50 次,观察内存和相机恢复
页面恢复 从后台回来后重新检测权限和摄像头

资源释放不是收尾工作,而是相机应用能不能长期稳定运行的核心能力。

总结

1quweixiangji 的 CameraKit 资源管理有一个清晰模式:页面只持有用于展示的 previewPhotoPixelMap,并在关闭、保存、转文档和退出时释放;CameraPreviewService 持有相机会话、输入输出、回调和视觉采样,并在 stopPreview() 中统一清理;analyzeCapturedPhoto()keepPixelMap 明确 PixelMap 所有权转移,避免服务层和页面重复释放;CoreVision 相关资源由 CoreVisionHumanService.release() 收口。

对 HarmonyOS 相机类项目来说,这套生命周期比 UI 本身更重要。只要资源边界清楚,黑屏、卡顿、相机占用和内存压力就有明确排查路径;后续再接真实图库、智能贴纸和 PDF 导出,也能在稳定的相机底座上继续扩展。

Logo

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

更多推荐