HarmonyOS趣味相机实战第21篇:JPEG解码、PixelMap所有权转移与结果弹层闭环

摘要

PhotoOutput.capture() 返回成功,只表示拍照请求已经提交,不代表页面已经拿到可显示的照片。真实图像会通过 photoAvailable 异步回调到达,再经过 JPEG Component、ImageSource 和 PixelMap 解码。任一资源释放过早都会导致结果弹层黑图,释放过晚又会造成 native 内存持续增长。

本文基于 D:/APP/1quweixiangji 趣味相机工程,复盘 CameraPreviewService.etsIndex.ets 的拍照资源链路。我们明确 camera.Photoimage.ImageSourceimage.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 CapturedPhotoCapturedDocument 元数据和图像资源分离
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 高度

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;
}

successrealPhotoReceived 不能合并。调用成功但回调超时,需要提示用户重试,而不是生成模拟成功数据。

二、回调先记录时序再启动解码

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 转换也能保持清晰边界。

Logo

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

更多推荐