HarmonyOS趣味相机实战第5篇:CameraKit资源释放、PixelMap生命周期与预览黑屏排查
HarmonyOS趣味相机实战第5篇:CameraKit资源释放、PixelMap生命周期与预览黑屏排查
摘要
相机应用最容易出现的不是“代码编译不过”,而是运行一段时间后预览黑屏、切后台回来相机被占用、连续拍照后卡顿、保存后内存涨、前后摄像头切换失败。这些问题通常不是一个 UI 按钮能解决的,而是 CameraKit 会话、PreviewOutput、PhotoOutput、MetadataOutput、CameraInput、PixelMap、ImageSource、CoreVision 检测器和页面状态没有形成清晰的生命周期。
本文继续基于 D:/APP/1quweixiangji HarmonyOS ArkTS 水印相机项目,复盘第 5 条工程主线:资源释放和稳定性治理。重点看四个位置:
Index.ets页面消失时如何释放预览 PixelMap 和相机会话。captureNow()拍照前为什么要先释放上一张预览图。CameraPreviewService.analyzeCapturedPhoto()如何在“交给页面展示”和“内部失败释放”之间控制 PixelMap 所有权。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 |
只保存元数据,不保存大图二进制 |

本地构建命令:
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 交给页面,ImageSource 和 camera.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.mirror 和 resolvePreviewRotation() |
| 拍照提示成功但没图 | 回调不是本次拍照 | 查 captureStartedAt 和 waitForPhotoAvailable() |
| 写入相册后重启丢图 | 当前只保存元数据 | 明确是否已实现真实图片文件存储 |
十五、上线前验收清单
- 页面退出时调用
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 failed、capture failed、preview failed 等日志 |
| 智能识别开关 | 只有用户开启贴纸时才采样 CoreVision |
| 压力测试 | 连续拍照 50 次,观察内存和相机恢复 |
| 页面恢复 | 从后台回来后重新检测权限和摄像头 |
资源释放不是收尾工作,而是相机应用能不能长期稳定运行的核心能力。
总结
1quweixiangji 的 CameraKit 资源管理有一个清晰模式:页面只持有用于展示的 previewPhotoPixelMap,并在关闭、保存、转文档和退出时释放;CameraPreviewService 持有相机会话、输入输出、回调和视觉采样,并在 stopPreview() 中统一清理;analyzeCapturedPhoto() 用 keepPixelMap 明确 PixelMap 所有权转移,避免服务层和页面重复释放;CoreVision 相关资源由 CoreVisionHumanService.release() 收口。
对 HarmonyOS 相机类项目来说,这套生命周期比 UI 本身更重要。只要资源边界清楚,黑屏、卡顿、相机占用和内存压力就有明确排查路径;后续再接真实图库、智能贴纸和 PDF 导出,也能在稳定的相机底座上继续扩展。
更多推荐



所有评论(0)