HarmonyOS趣味相机实战第21篇:JPEG解码、PixelMap所有权转移与结果弹层闭环
HarmonyOS趣味相机实战第21篇:JPEG解码、PixelMap所有权转移与结果弹层闭环
摘要
PhotoOutput.capture() 返回成功,只表示拍照请求已经提交,不代表页面已经拿到可显示的照片。真实图像会通过 photoAvailable 异步回调到达,再经过 JPEG Component、ImageSource 和 PixelMap 解码。任一资源释放过早都会导致结果弹层黑图,释放过晚又会造成 native 内存持续增长。
本文基于 D:/APP/1quweixiangji 趣味相机工程,复盘 CameraPreviewService.ets 与 Index.ets 的拍照资源链路。我们明确 camera.Photo、image.ImageSource 和 image.PixelMap 的所有者,使用 keepPixelMap 完成跨层转移,并让“重拍、保存到相册、转文档、页面退出”都进入可验证的释放闭环。
工程背景与源码定位
| 文件 | 责任 | 本文关注点 |
|---|---|---|
entry/src/main/ets/service/CameraPreviewService.ets |
PhotoOutput 回调与 JPEG 解码 | 资源创建和转移 |
entry/src/main/ets/pages/Index.ets |
拍照、结果弹层、保存和关闭 | PixelMap 最终所有者 |
entry/src/main/ets/service/PhotoAlbumService.ets |
保存照片元数据 | 不长期持有 PixelMap |
entry/src/main/ets/service/PhotoDocumentService.ets |
创建图片文档索引 | 继承业务快照 |
entry/src/main/ets/model/DecorationModels.ets |
CapturedPhoto 与 CapturedDocument |
元数据和图像资源分离 |
entry/src/main/ets/service/CoreVisionHumanService.ets |
对拍照 PixelMap 分析 | 分析不拥有最终资源 |
环境与资源边界
| 项目 | 当前值 | 说明 |
|---|---|---|
| 应用版本 | 1.0.4 |
当前工程 |
| target SDK | 6.0.2(22) |
构建配置 |
| 图像回调 | photoAvailable |
异步到达 |
| 编码组件 | JPEG | ComponentType.JPEG |
| 结果图像 | PixelMap | 页面弹层显示 |
| 拍照到达超时 | 项目常量控制 | 请求与回调分离 |
| UI 显示策略 | ImageFit.Cover |
结果弹层 250 高度 |

一、区分“请求成功”和“照片到达”
拍照入口:
const setting: camera.PhotoCaptureSetting = {
quality: captureQualityLevel(qualityPreference),
mirror: activeCameraPosition === 'front'
};
await photoOutput.capture(setting);
这一步之后仍要等待:
const realPhotoReceived: boolean =
await waitForPhotoAvailable(
captureStartedAt,
PHOTO_AVAILABLE_TIMEOUT_MS
);
因此返回模型包含:
export interface CameraCaptureState {
success: boolean;
message: string;
realPhotoReceived: boolean;
photoEventCount: number;
visionTargetCount: number;
photoPixelMap?: image.PixelMap;
}
success 和 realPhotoReceived 不能合并。调用成功但回调超时,需要提示用户重试,而不是生成模拟成功数据。
二、回调先记录时序再启动解码
private static bindPhotoAvailable(
photoOutput: camera.PhotoOutput
): void {
const callback: AsyncCallback<camera.Photo> =
(error, photo): void => {
if (error) {
hilog.warn(DOMAIN, TAG, 'photo available failed');
return;
}
lastPhotoAvailableAt = Date.now();
photoEventCount += 1;
const promise = analyzeCapturedPhoto(
photo,
shouldAnalyzeTargetsOnCapture
);
lastPhotoVisionPromise = promise;
promise.then(result => {
lastPhotoVisionTargets = result.targets;
lastPhotoVisionAt = Date.now();
if (result.targets.length > 0) {
notifyTargets(result.targets);
}
}).catch(() => {
hilog.warn(DOMAIN, TAG, 'photo vision analysis failed');
});
};
photoAvailableCallback = callback;
photoOutput.on('photoAvailable', callback);
}
先更新时间戳,waitForPhotoAvailable() 才能知道回调已经发生;解码和识别则通过 Promise 单独等待。
三、时间戳用于区分本次拍照
const captureStartedAt: number = Date.now();
等待函数:
private static async waitForPhotoAvailable(
captureStartedAt: number,
timeout: number
): Promise<boolean> {
if (lastPhotoAvailableAt >= captureStartedAt) {
return true;
}
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
await delay(80);
if (lastPhotoAvailableAt >= captureStartedAt) {
return true;
}
}
return lastPhotoAvailableAt >= captureStartedAt;
}
如果只判断事件计数大于 0,上一张照片的回调会让新拍照误判为已完成。时间戳提供最小任务归属边界。
更严格的实现可维护单调 captureId,并让回调结果进入任务队列,避免系统时间变化影响判断。
四、JPEG解码链路
private static async analyzeCapturedPhoto(
photo: camera.Photo,
analyzeTargets: boolean = true
): Promise<CameraPhotoVisionResult> {
let pixelMap: image.PixelMap | null = null;
let source: image.ImageSource | null = null;
let keepPixelMap: boolean = false;
try {
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();
// ...
} finally {
// ...
}
}
资源关系:
camera.Photo
-> photo.main: image.Image
-> JPEG Component.byteBuffer
-> ImageSource
-> PixelMap
后一个对象创建成功不代表前一个可以永远不释放。函数必须为每一层定义释放路径。
五、keepPixelMap表达所有权转移
识别关闭时:
if (!analyzeTargets) {
keepPixelMap = true;
return {
targets: [],
pixelMap
};
}
识别开启时:
const targets = await CoreVisionHumanService.analyzePixelMap(
pixelMap,
previewRotation,
activeCameraPosition === 'front'
);
keepPixelMap = true;
return {
targets: toCameraTargets(targets),
pixelMap
};
keepPixelMap = true 表示函数把 PixelMap 的释放责任转交给调用方。它不是“不释放”,而是“由下一位所有者释放”。
六、finally释放未转移资源
finally {
if (pixelMap !== null && !keepPixelMap) {
try {
await pixelMap.release();
} catch (releaseError) {
hilog.warn(DOMAIN, TAG, 'release pixelMap failed');
}
}
if (source !== null) {
try {
await source.release();
} catch (releaseError) {
hilog.warn(DOMAIN, TAG, 'release image source failed');
}
}
try {
await photo.release();
} catch (releaseError) {
hilog.warn(DOMAIN, TAG, 'release captured photo failed');
}
}
无论成功、识别失败还是解码异常,ImageSource 和 Photo 都会释放。PixelMap 只有在成功返回给页面时保留。
七、为什么ImageSource可以先释放
createPixelMap() 成功后,PixelMap 成为独立图像对象。页面显示的是 PixelMap,不需要持续保留 ImageSource。因此函数在返回前释放 source,可以缩短资源持有时间。
但必须以实际 SDK 行为和真机测试为准。验收时连续打开结果弹层,确认 source 释放后 PixelMap 仍稳定显示。
八、等待识别Promise而不是猜固定时间
private static async waitForPhotoVision(
captureStartedAt: number,
timeout: number
): Promise<CameraPhotoVisionResult> {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
if (lastPhotoVisionPromise !== null) {
try {
return await lastPhotoVisionPromise;
} catch (error) {
return { targets: [] };
}
}
if (lastPhotoVisionAt >= captureStartedAt) {
return { targets: lastPhotoVisionTargets };
}
await delay(40);
}
return { targets: lastPhotoVisionTargets };
}
固定 sleep(1000) 在快设备上浪费时间,在慢设备上又可能不够。保存实际 Promise 能让调用方等待真实任务完成。
九、超时返回时要处理迟到PixelMap
一个隐蔽风险:等待函数超时返回后,解码 Promise 仍可能稍后完成并产生 PixelMap。如果没有接收者,该 PixelMap 可能泄漏。
可以让任务带 captureId 与取消状态:
interface CaptureTask {
id: number;
active: boolean;
promise: Promise<CameraPhotoVisionResult>;
}
async function completeTask(
task: CaptureTask,
result: CameraPhotoVisionResult
): Promise<CameraPhotoVisionResult> {
if (!task.active && result.pixelMap) {
await result.pixelMap.release();
return { targets: [] };
}
return result;
}
超时时把 active=false,迟到任务负责清理自己的返回资源。
十、页面接收前先释放旧预览
页面字段:
@State previewPhotoPixelMap: image.PixelMap | null = null;
新拍照结果到达前应处理旧对象:
private async replacePreviewPixelMap(
next: image.PixelMap | undefined
): Promise<void> {
const previous = this.previewPhotoPixelMap;
this.previewPhotoPixelMap = next ?? null;
if (previous !== null && previous !== next) {
await previous.release();
}
}
先更新状态还是先释放,要结合 ArkUI 渲染时序验证。更保守的方式是先从 UI 移除旧对象,等待一帧或状态生效后释放,再赋新对象。
十一、结果弹层显示真实PixelMap
if (this.previewPhotoPixelMap !== null) {
Image(this.previewPhotoPixelMap)
.width('100%')
.height(250)
.objectFit(ImageFit.Cover)
.borderRadius(22)
} else {
Column()
.width('100%')
.height(250)
.backgroundColor('#1B1B24')
}
UI 对 null 有明确占位,避免解码失败时组件崩溃。占位不能伪装成成功照片,状态文案应提示“未获取到照片,请重试”。
十二、CapturedPhoto只保存业务快照
export interface CapturedPhoto {
id: string;
title: string;
createdAt: string;
resolutionLabel?: string;
captureSource: CaptureSource;
captureSummary: string;
status: 'preview' | 'saved';
watermark?: WatermarkSnapshot;
}
PixelMap 没有放入模型,也没有 JSON 序列化到 Preferences。这是正确边界:模型用于业务元数据,PixelMap 是进程内 native 资源。
如果未来保存真实图片,应编码到文件或媒体库,并在模型中保存 URI,而不是长期保存 PixelMap。
十三、重拍分支
用户点击“重拍”时:
关闭结果弹层
-> 页面从Image移除PixelMap
-> release PixelMap
-> 清空previewPhoto
-> 保留或恢复相机预览
项目已有:
private async closePreview(): Promise<void> {
await this.releasePreviewPixelMap();
this.previewPhoto = null;
}
关闭按钮与重拍按钮应复用同一释放函数,避免两个分支行为不一致。
十四、保存到相册分支
当前 PhotoAlbumService 保存元数据:
const savedPhoto = PhotoAlbumService.savePhoto(photo);
const nextPhotos = [savedPhoto].concat(cachedPhotos);
cachedPhotos = nextPhotos.slice(0, 60);
await flushPhotos();
保存成功后仍应释放结果 PixelMap,因为本地相册卡片目前根据元数据生成缩略视图,而不是持有该 PixelMap。
如果未来写入真实媒体文件,正确顺序:
PixelMap/JPEG写入临时文件
-> fsync或确认写入完成
-> 原子重命名或媒体库提交
-> Preferences写入URI元数据
-> flush
-> 释放PixelMap
文件失败时不能把元数据标记为 saved。
十五、转文档分支
文档服务从 CapturedPhoto 生成:
const document: CapturedDocument = {
id: `doc_${Date.now()}_${photo.id}`,
photoId: photo.id,
title: `${photo.title} 文档`,
pageCount: 1,
documentType: 'imageDocument',
status: 'ready',
summary,
captureSummary: safeCaptureSummary(photo.captureSummary),
watermark: cloneWatermark(photo.watermark)
};
当前是“图片文档索引”,不依赖 PixelMap 持久化。创建文档后可以释放预览 PixelMap。若后续生成 PDF 或增强扫描图,转码函数应明确是借用 PixelMap 还是接管它,不能双方都释放。
十六、用类型表达借用与接管
ArkTS 没有编译期所有权系统,可以用接口约定:
interface BorrowedPixelMap {
value: image.PixelMap;
owner: 'previewDialog';
}
interface OwnedPixelMapResult {
value: image.PixelMap;
releaseBy: 'caller';
}
或者通过类封装一次性释放:
class PixelMapLease {
private released: boolean = false;
constructor(readonly value: image.PixelMap) {}
async release(): Promise<void> {
if (this.released) {
return;
}
this.released = true;
await this.value.release();
}
}
幂等 Lease 可以降低关闭按钮、页面退出和 Surface 销毁多路径重复释放的风险。
十七、页面退出必须兜底
async aboutToDisappear(): Promise<void> {
await this.releasePreviewPixelMap();
await CameraPreviewService.stopPreview();
}
即使用户没有点击弹层按钮,页面销毁仍释放。Ability 进入后台时也应根据产品策略关闭弹层资源,至少停止相机会话。
十八、资源状态机
idle
-> capturing
-> photoAvailable
-> decoding
-> previewOwned
-> saving | converting | retaking
-> released
每个状态只允许一个 PixelMap 所有者:
| 状态 | 所有者 |
|---|---|
| decoding | CameraPreviewService |
| previewOwned | Index结果弹层 |
| saving | 保存函数借用,页面仍拥有 |
| converting | 转换函数借用,页面仍拥有 |
| released | 无 |
若保存函数要异步编码图像,关闭弹层不能同时释放;需要 Busy 状态或复制独立数据。
十九、测试设计
使用可注入的资源代理:
interface Releasable {
release(): Promise<void>;
}
class FakeResource implements Releasable {
releaseCount: number = 0;
async release(): Promise<void> {
this.releaseCount += 1;
}
}
断言:
- JPEG 解码失败时 Photo 释放一次。
- createPixelMap 失败时 ImageSource 释放一次。
- 成功返回时服务不释放 PixelMap。
- 页面关闭时 PixelMap 释放一次。
- 页面关闭和 aboutToDisappear 连续调用仍只释放一次。
- 超时后的迟到任务会释放无人接收的 PixelMap。
- 新预览替换旧预览时旧对象释放。
- 保存失败不会把照片状态改成 saved。
二十、真机压力验收
连续拍照并重拍50次
-> 观察native内存是否回落
连续拍照并保存30次
-> 检查相册条目与结果一致
拍照后立即退后台
-> 返回后无黑图和相机占用
拍照后立即切换Tab
-> 迟到回调不覆盖新页面
前置/后置交替拍照
-> mirror和结果方向正确
还要故障注入:
- JPEG Component 获取失败。
- ImageSource 创建失败。
- PixelMap 创建失败。
- CoreVision 分析超时。
- 用户快速双击拍照。
- 页面在解码中销毁。
二十一、常见问题排查
| 现象 | 可能原因 | 排查方式 |
|---|---|---|
| capture成功但弹层黑图 | 把请求完成当照片到达 | 检查photoAvailable和realPhotoReceived |
| 连续重拍内存上涨 | PixelMap未在关闭分支释放 | 检查所有弹层出口 |
| 偶发显示上一张照片 | 时间戳/任务ID归属错误 | 按captureStartedAt过滤 |
| 识别超时后仍涨内存 | 迟到Promise无人接收 | 取消后释放返回PixelMap |
| 图片显示一瞬后消失 | 服务过早release PixelMap | 明确所有权转移 |
| 保存后相册有记录但无文件 | 只保存元数据 | 增加媒体URI和原子写入 |
| 关闭与页面退出偶发异常 | 重复release非幂等 | 使用PixelMapLease |
| 转文档时图片被释放 | 借用和接管约定不清 | 类型化资源协议 |
二十二、上线前验收清单
- 区分 capture 请求成功和 photoAvailable 到达。
- 每次拍照使用时间戳或任务 ID 归属回调。
- JPEG Component、ImageSource、PixelMap 链路清晰。
- Photo 和 ImageSource 在 finally 中释放。
- PixelMap 只有成功转移时不由服务释放。
- 页面接管后负责关闭、重拍和退出释放。
- 新预览替换旧预览会释放旧资源。
- 迟到任务的无人接收 PixelMap 会释放。
- CapturedPhoto 不序列化 PixelMap。
- 保存真实文件时先写媒体,再提交元数据。
- 转文档明确借用或接管关系。
- 多出口释放具备幂等保护。
- 真机连续拍照后 native 内存稳定。
- 错误日志不包含照片字节和用户图像。
总结
HarmonyOS 拍照链路的难点不是把 JPEG 解码成 PixelMap,而是让异步回调和 native 资源拥有明确归属。camera.Photo 与 ImageSource 在服务内释放,成功返回的 PixelMap 转交页面;页面则在重拍、保存、转文档和退出时完成最终释放。
配合任务时间戳、真实 Promise 等待、迟到结果清理和幂等 Lease,结果弹层才能既显示真实照片,又不会在连续拍照与快速切页中泄漏。业务元数据与图像资源分离后,后续接入媒体库 URI 或 PDF 转换也能保持清晰边界。
更多推荐



所有评论(0)