HarmonyOS趣味相机实战第8篇:PhotoOutput拍照回调、质量档位与真实照片确认机制

摘要

相机项目里,“点击拍照”不是调用一次 capture() 就结束。真正稳定的拍照链路要回答几个问题:拍照输出是否就绪,用户选择的分辨率如何映射到 CameraKit 质量档位,前置镜头是否要镜像,photoAvailable 回调是不是本次拍照产生的,回调里的 JPEG 如何变成 PixelMap,人体识别是否需要等待,失败时如何返回不会误导用户的状态。

本文继续基于 D:/APP/1quweixiangji HarmonyOS ArkTS 水印相机项目,复盘 CameraPreviewService.capturePhoto() 的真实拍照机制。它不是单纯的 UI 文章,而是围绕 PhotoOutputPhotoCaptureSettingphotoAvailablewaitForPhotoAvailable()waitForPhotoVision()analyzeCapturedPhoto()captureMessage() 和页面 captureNow() 串起一条可验证的工程链路。

本文覆盖:

  1. 页面分辨率标签如何映射到 CameraPhotoQualityPreference
  2. capturePhoto() 如何检查 photoOutput 是否就绪。
  3. 拍照前如何记录 captureStartedAt,避免旧回调污染本次结果。
  4. PhotoCaptureSetting 如何处理质量档位和前置镜像。
  5. photoAvailable 回调如何触发照片分析。
  6. JPEG 如何解码成 PixelMap,并在所有权转移后交给页面展示。
  7. 拍照成功、未收到真实照片、识别目标数量如何转换成用户文案。

工程背景与源码定位

文件 作用
entry/src/main/ets/service/CameraPreviewService.ets 拍照主服务,封装 PhotoOutput.capture()、回调、PixelMap 解码和资源释放
entry/src/main/ets/pages/Index.ets 页面拍照入口、质量档位选择、结果预览和照片元数据创建
entry/src/main/ets/service/PhotoAlbumService.ets 拍照成功后创建 CapturedPhoto 元数据
entry/src/main/ets/model/DecorationModels.ets CapturedPhotoWatermarkSnapshot 等模型
entry/src/main/module.json5 相机权限声明
entry/src/test/LocalUnit.test.ets 当前已有水印快照测试,可补拍照状态纯函数测试

环境与版本边界

项目 当前值 说明
当前复盘日期 2026-07-14 按当前源码和本地配置复盘
工程路径 D:/APP/1quweixiangji 本文只引用该项目已有源码
工程类型 HarmonyOS Stage 模型 EntryAbility 加载 pages/Index
target SDK 6.0.2(22) 升级 SDK 后要回归 CameraKit PhotoOutput 行为
相机能力 @kit.CameraKit PhotoOutputPhotoCaptureSettingphotoAvailable
图像能力 @ohos.multimedia.image JPEG 解码为 PixelMap
视觉能力 @kit.CoreVisionKit 可选人体目标分析
页面语言 ArkTS + ArkUI @State 承接拍照结果

HarmonyOS CameraKit PhotoOutput 拍照回调链路

构建命令:

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

版本兼容、EOL 与真机回归策略

截至本文复盘日期 2026-07-14,文章只保证和当前项目源码、当前 DevEco 本地构建配置一致。target SDK: 6.0.2(22) 是否已经 EOL、是否存在 CameraKit Breaking Change,不能靠文章静态判断,正式发版前必须以 DevEco Studio、HarmonyOS SDK 发布说明和 AppGallery Connect 审核要求为准。

建议把 CameraKit 拍照链路的版本边界写成检查表:

检查项 当前实现 升级 SDK 后要回归
PhotoOutput.capture() 使用 PhotoCaptureSetting 传入 qualitymirror 参数枚举是否变化,前置镜像是否仍生效
photoAvailable 通过 photoOutput.on('photoAvailable') 监听 回调名、回调时机、错误对象是否变化
camera.Photo 读取 photo.main 并获取 JPEG component 图片组件类型和释放策略是否变化
ImageSource image.createImageSource(component.byteBuffer) 解码 API 和 PixelMap 创建行为是否变化
QualityLevel high/medium/low 映射到三档质量 是否新增质量档或旧枚举废弃
前置镜像 mirror: activeCameraPosition === 'front' 不同设备前置预览和成片方向是否一致
超时阈值 350ms 等照片,1600ms 等视觉结果 低端机是否需要更长回调窗口

真机回归建议至少覆盖四类设备状态:

场景 操作 验收点
后置拍照 后置预览下连续拍 10 张 每张都有真实照片,结果弹窗正常
前置拍照 切前置后拍照 成片方向和用户预期一致
低质量档 切到 5MP (4:3) 拍照 captureSummary 能记录对应分辨率
异常路径 快速切后台、返回、再拍照 不出现旧回调污染或假成功

如果正式上架时接入真实文件保存或分享,还要补充相册/文件权限、隐私政策和用户授权说明。当前文章只复盘拍照回调与 PixelMap 预览交付,不宣称已经完成系统图库写入。

一、页面先把分辨率标签映射成质量偏好

页面里维护了三档分辨率标签:

private resolutionLabels: string[] = ['12MP (4:3)', '8MP (4:3)', '5MP (4:3)'];
private resolutionQualities: CameraPhotoQualityPreference[] = ['high', 'medium', 'low'];

展示标签:

private resolutionLabel(): string {
  return this.resolutionLabels[this.selectedResolutionIndex] ?? this.resolutionLabels[0];
}

拍照质量偏好:

private captureQualityPreference(): CameraPhotoQualityPreference {
  return this.resolutionQualities[this.selectedResolutionIndex] ?? 'high';
}

这一步把 UI 标签和拍照参数解耦:页面展示“12MP/8MP/5MP”,服务层只接受 high/medium/low 三档偏好。

二、拍照入口先防重复和倒计时

页面拍照入口:

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 = '正在拍照';
}

这里先防重复,再释放上一张预览图。连续拍照时这一步很关键,否则旧 PixelMap 会一直挂在页面状态里。

三、没有真实预览时不拍照

页面在调用服务前检查预览状态:

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

只有真正收到照片并拿到 PixelMap,页面才创建照片记录。这样能避免“没拍到真实照片却保存相册”的假成功。

四、capturePhoto() 先检查 PhotoOutput

服务层入口:

static async capturePhoto(
  qualityPreference: CameraPhotoQualityPreference = 'high',
  analyzeTargets: boolean = true
): Promise<CameraCaptureState> {
  if (CameraPreviewService.photoOutput === null) {
    return {
      success: false,
      message: '相机拍照输出尚未就绪',
      realPhotoReceived: false,
      photoEventCount: CameraPreviewService.photoEventCount,
      visionTargetCount: 0
    };
  }
}

photoOutput === null 通常说明预览会话还没启动、启动失败或已经释放。这里直接返回业务状态,不让页面继续等待不存在的回调。

五、用 captureStartedAt 区分本次拍照

拍照前记录时间:

const captureStartedAt: number = Date.now();

清空上一轮视觉结果:

CameraPreviewService.lastPhotoVisionAt = 0;
CameraPreviewService.lastPhotoVisionTargets = [];
CameraPreviewService.lastPhotoVisionPromise = null;

为什么要这样做?因为 photoAvailable 是异步回调。如果上一轮回调很晚才到,不能把它误判成本次拍照结果。captureStartedAt 就是本次拍照的时间边界。

六、PhotoCaptureSetting 处理质量档位和前置镜像

拍照设置:

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

质量映射:

private static captureQualityLevel(qualityPreference: CameraPhotoQualityPreference): camera.QualityLevel {
  if (qualityPreference === 'medium') {
    return camera.QualityLevel.QUALITY_LEVEL_MEDIUM;
  }
  if (qualityPreference === 'low') {
    return camera.QualityLevel.QUALITY_LEVEL_LOW;
  }
  return camera.QualityLevel.QUALITY_LEVEL_HIGH;
}

映射表:

页面偏好 CameraKit 质量
high QUALITY_LEVEL_HIGH
medium QUALITY_LEVEL_MEDIUM
low QUALITY_LEVEL_LOW

前置镜头设置 mirror=true,避免自拍结果和预览体验割裂。

七、photoAvailable 回调负责触发照片分析

绑定回调:

private static bindPhotoAvailable(photoOutput: camera.PhotoOutput): void {
  const callback: AsyncCallback<camera.Photo> = (error: BusinessError, photo: camera.Photo): void => {
    if (error) {
      hilog.warn(DOMAIN, TAG, 'photo available failed: %{public}s', JSON.stringify(error));
      return;
    }
    CameraPreviewService.lastPhotoAvailableAt = Date.now();
    CameraPreviewService.photoEventCount += 1;
    const promise: Promise<CameraPhotoVisionResult> =
      CameraPreviewService.analyzeCapturedPhoto(photo, CameraPreviewService.shouldAnalyzeTargetsOnCapture);
    CameraPreviewService.lastPhotoVisionPromise = promise;
  };
  CameraPreviewService.photoAvailableCallback = callback;
  photoOutput.on('photoAvailable', callback);
}

回调做三件事:

  1. 标记最后一次收到真实照片的时间。
  2. 累加照片事件计数。
  3. camera.Photo 交给 analyzeCapturedPhoto() 解码和可选识别。

八、等待真实照片回调

等待逻辑:

private static waitForPhotoAvailable(captureStartedAt: number, timeout: number): Promise<boolean> {
  if (CameraPreviewService.lastPhotoAvailableAt >= captureStartedAt) {
    return Promise.resolve(true);
  }
  return new Promise<boolean>((resolve: (value: boolean) => void) => {
    setTimeout(() => {
      resolve(CameraPreviewService.lastPhotoAvailableAt >= captureStartedAt);
    }, timeout);
  });
}

当前超时时间是 350ms:

const realPhotoReceived: boolean =
  await CameraPreviewService.waitForPhotoAvailable(captureStartedAt, 350);

这不是等待无限长,而是给真实照片回调一个短窗口。如果窗口内没有收到本次回调,就返回 false,页面不会创建假照片。

九、等待视觉分析结果

如果收到了真实照片,再等待视觉分析:

const visionResult: CameraPhotoVisionResult = realPhotoReceived ?
  await CameraPreviewService.waitForPhotoVision(captureStartedAt, 1600) :
  { targets: [] };

等待实现:

private static async waitForPhotoVision(captureStartedAt: number, timeout: number): Promise<CameraPhotoVisionResult> {
  const deadline: number = Date.now() + timeout;
  while (Date.now() < deadline) {
    if (CameraPreviewService.lastPhotoVisionPromise !== null) {
      try {
        return await CameraPreviewService.lastPhotoVisionPromise;
      } catch (error) {
        hilog.warn(DOMAIN, TAG, 'wait photo vision failed: %{public}s', JSON.stringify(error));
        return { targets: [] };
      }
    }
    if (CameraPreviewService.lastPhotoVisionAt >= captureStartedAt) {
      return { targets: CameraPreviewService.lastPhotoVisionTargets };
    }
    await new Promise<void>((resolve: () => void) => {
      setTimeout(() => resolve(), 40);
    });
  }
  return { targets: CameraPreviewService.lastPhotoVisionTargets };
}

这里的策略是:可以等视觉结果,但不能让用户无限等。1600ms 超时后返回已有目标,保证拍照流程继续。

十、JPEG 解码成 PixelMap,并控制所有权

照片分析入口:

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

解码:

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

keepPixelMap=true 表示 PixelMap 所有权已经交给页面,服务层不能释放它。

十一、finally 释放 ImageSource 和 camera.Photo

资源释放:

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

这里的所有权边界很清楚:

对象 成功时谁释放 失败时谁释放
PixelMap 页面释放 服务层释放
ImageSource 服务层释放 服务层释放
camera.Photo 服务层释放 服务层释放

这能避免重复释放,也避免失败路径泄漏资源。

十二、返回 CameraCaptureState 给页面

拍照成功返回:

return {
  success: true,
  message: CameraPreviewService.captureMessage(realPhotoReceived, visionResult.targets.length),
  realPhotoReceived,
  photoEventCount: CameraPreviewService.photoEventCount,
  visionTargetCount: visionResult.targets.length,
  photoPixelMap: visionResult.pixelMap
};

失败返回:

return {
  success: false,
  message: '真实拍照失败,已使用当前预览合成',
  realPhotoReceived: false,
  photoEventCount: CameraPreviewService.photoEventCount,
  visionTargetCount: 0
};

页面只需要看 realPhotoReceivedphotoPixelMap,不用关心 CameraKit 回调细节。

十三、captureMessage 让状态文案不误导用户

文案生成:

private static captureMessage(realPhotoReceived: boolean, visionTargetCount: number): string {
  if (!realPhotoReceived) {
    return '未获取到真实照片,请重试';
  }
  if (visionTargetCount > 0) {
    return `真实照片已捕获,并按 ${visionTargetCount} 个人物目标对齐`;
  }
  return '真实照片已捕获,正在使用预览目标对齐';
}

这里分三种:

状态 文案 页面策略
没收到真实照片 未获取到真实照片,请重试 不创建照片记录
收到照片且有人物目标 真实照片已捕获,并按 N 个目标对齐 可用于智能装饰
收到照片但无目标 真实照片已捕获,使用预览目标对齐 可正常保存

文案不能一律写“拍照成功”,否则失败路径会误导用户。

十四、页面创建照片元数据

服务返回 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()
);

这条记录保存的是元数据:

字段 来源
captureSource 固定为 real
captureSummary 拍照文案 + 分辨率标签
resolutionLabel 页面当前分辨率
watermark 拍照瞬间快照
status 初始为 preview

真正的 PixelMap 只用于结果预览,保存或关闭后会释放。

十五、完整时序图

用户点击拍照
  -> capturePhoto()
  -> 防重复和倒计时
  -> captureNow()
  -> releasePreviewPixelMap()
  -> CameraPreviewService.capturePhoto(qualityPreference, analyzeTargets)
  -> 检查 photoOutput
  -> captureStartedAt = Date.now()
  -> PhotoCaptureSetting(quality, mirror)
  -> photoOutput.capture(setting)
  -> photoAvailable 回调
  -> analyzeCapturedPhoto()
  -> JPEG -> ImageSource -> PixelMap
  -> waitForPhotoAvailable(350ms)
  -> waitForPhotoVision(1600ms)
  -> 返回 CameraCaptureState
  -> 页面写 previewPhotoPixelMap
  -> PhotoAlbumService.createPhoto()
  -> ResultPreviewDialog

这条链路把异步 CameraKit 回调收束成页面能理解的状态对象。

十六、异常状态机:不要把失败包装成成功

拍照链路可以抽象为四个状态:

状态 条件 页面动作
output_not_ready photoOutput === null 提示输出未就绪,不创建照片
capture_failed photoOutput.capture() 抛异常 提示真实拍照失败,不创建照片
photo_timeout 350ms 内没有本次 photoAvailable 提示未获取真实照片,不创建照片
photo_ready 收到本次照片且有 PixelMap 显示结果预览,可保存或转文档

页面现在判断的是:

if (!captureState.realPhotoReceived || captureState.photoPixelMap === undefined) {
  this.isCapturing = false;
  return;
}

这个判断比只看 success 更可靠。因为 success 表示服务调用流程没有抛异常,而 realPhotoReceived 才表示本次真的拿到了 CameraKit 回调。上线前要重点避免下面两类反模式:

反模式 后果
success=true 就创建照片 可能保存没有真实图的假记录
超时后继续用旧 PixelMap 用户看到上一张照片

十七、建议补充的测试

可以优先补纯函数测试:

测试点 输入 期望
质量映射 high/medium/low 映射到对应 QualityLevel
文案生成 realPhotoReceived=false 返回“未获取到真实照片”
文案生成 realPhotoReceived=true, targets=2 文案包含 2 个目标
分辨率标签 index 越界 回退第一档
拍照状态 photoOutput=null 返回 success=false

photoAvailable 更适合集成测试或真机测试,因为它依赖 CameraKit 回调。

十八、常见问题排查

现象 可能原因 排查方式
点击拍照提示输出未就绪 photoOutput 为空 确认预览会话已经启动
拍照后没有弹窗 realPhotoReceived=false 或无 PixelMap waitForPhotoAvailable()
显示旧照片 上一轮回调污染本次 captureStartedAt 比较
前置照片左右反了 mirror 未设置 activeCameraPosition === 'front'
分辨率按钮无效 标签和质量数组不同步 resolutionLabelsresolutionQualities
连续拍照卡顿 旧 PixelMap 未释放 captureNow() 前的 releasePreviewPixelMap()
拍照后内存涨 ImageSource 或 photo 未释放 analyzeCapturedPhoto() finally
等待太久 视觉分析无限等待 waitForPhotoVision() 超时
失败还保存相册 页面只看 success,没看 realPhotoReceived captureNow() 判断
目标识别数不稳定 人体分析非必选 允许无目标但真实照片成功
升级 SDK 后拍照异常 CameraKit 回调或枚举变化 按版本兼容表回归 PhotoOutput

十九、上线前验收清单

  • 预览未启动时点击拍照不会创建照片记录。
  • photoOutput 为空时返回明确错误。
  • 三档质量按钮能循环切换。
  • high/medium/low 能映射到 CameraKit 质量档位。
  • 前置镜头拍照使用 mirror。
  • 拍照前释放上一张预览 PixelMap。
  • captureStartedAt 能区分本次回调。
  • waitForPhotoAvailable() 超时后不会假成功。
  • waitForPhotoVision() 有超时,不阻塞页面。
  • JPEG 能解码成 PixelMap。
  • PixelMap 交给页面后由页面释放。
  • ImageSource 和 camera.Photo 在服务层释放。
  • 未收到真实照片时不生成 CapturedPhoto
  • 收到真实照片后 captureSummary 包含拍照文案和分辨率。
  • 2026-07-14 之后升级 SDK 时,回归 PhotoOutput.capture()photoAvailableQualityLevel 行为。
  • 正式上架前确认当前 HarmonyOS SDK 是否 EOL 或有 CameraKit Breaking Change。
  • 前置、后置、高/中/低三档质量都完成真机回归。
  • 异常状态机四类失败路径不会生成相册记录。

总结

1quweixiangji 的拍照链路把 CameraKit 的异步回调封装成了页面可理解的状态:页面只负责防重复、选择质量档位、展示结果和创建照片元数据;CameraPreviewService 负责检查 photoOutput、构造 PhotoCaptureSetting、触发 capture()、等待本次 photoAvailable、解码 PixelMap、可选分析人体目标,并通过 CameraCaptureState 返回结果。

这种封装的核心价值是“不把异步回调直接暴露给页面”。页面看到的是 realPhotoReceivedphotoPixelMapvisionTargetCountmessage,因此可以稳定决定是否显示结果弹窗、是否保存相册、是否提示用户重试。对 HarmonyOS 相机项目来说,这比简单调用一次 capture() 要可靠得多。

Logo

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

更多推荐