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.etsPhotoOutput 回调与 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.etsCapturedPhoto 与 CapturedDocument元数据和图像资源分离
entry/src/main/ets/service/CoreVisionHumanService.ets对拍照 PixelMap 分析分析不拥有最终资源

环境与资源边界

项目当前值说明
应用版本1.0.4当前工程
target SDK6.0.2(22)构建配置
图像回调photoAvailable异步到达
编码组件JPEGComponentType.JPEG
结果图像PixelMap页面弹层显示
拍照到达超时项目常量控制请求与回调分离
UI 显示策略ImageFit.Cover结果弹层 250 高度

HarmonyOS JPEG到PixelMap所有权链路

一、区分“请求成功”和“照片到达”

拍照入口:

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 所有者:

状态所有者
decodingCameraPreviewService
previewOwnedIndex结果弹层
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 转换也能保持清晰边界。

Logo

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

更多推荐