HarmonyOS趣味相机实战第1篇:工程结构、相机权限申请与真实拍摄主流程

HarmonyOS趣味相机实战第1篇:工程结构、相机权限申请与真实拍摄主流程

摘要

相机类 App 的难点不在"页面放一个拍照按钮",而在权限、设备、预览、拍照、资源释放和本地保存这些环节必须连成闭环。D:/APP/1quweixiangji 是一个 HarmonyOS ArkTS 本地水印相机项目,当前已经覆盖相机权限申请、摄像头检测、前后摄像头切换、CameraKit 预览、定时拍照、拍照预览弹窗、本地相册保存和水印信息持久化。

本文作为这个项目系列的第 1 篇,先复盘最核心的拍摄主链路:

  • module.json5 如何声明 ohos.permission.CAMERA
  • EntryAbility 为什么要初始化相册和文档服务;
  • CameraPermissionService 如何封装运行时权限申请;
  • CameraDeviceService 如何发现前后摄像头并选择可用镜头;
  • Index.ets 如何维护相机、预览、拍照和相册状态;
  • XComponent 如何提供 CameraKit 预览 Surface;
  • CameraPreviewService 如何启动 PhotoSession、绑定 PreviewOutputPhotoOutput
  • 拍照后如何拿到 PixelMap,生成 CapturedPhoto,再保存进 Preferences 本地相册;
  • 最后给出调试排查和发布前验收清单。

这篇文章的目标是把"相机能拍"拆成可验证的工程步骤,而不是停留在 UI 效果图。

工程背景与源码定位

文件 作用
README.md 项目说明、功能清单和构建命令
build-profile.json5 Stage 工程、target SDK、构建产物配置
AppScope/app.json5 应用包名、版本号、图标和应用名
entry/src/main/module.json5 EntryAbility、页面入口、相机权限声明
entry/src/main/ets/entryability/EntryAbility.ets 应用启动入口,初始化相册和文档服务
entry/src/main/ets/pages/Index.ets 主页面,串起拍摄、相册、文档、设置四个 Tab
entry/src/main/ets/service/CameraPermissionService.ets 运行时相机权限申请
entry/src/main/ets/service/CameraDeviceService.ets 前后摄像头发现与镜头切换
entry/src/main/ets/service/CameraPreviewService.ets CameraKit 预览、拍照、输出释放
entry/src/main/ets/service/PhotoAlbumService.ets Preferences 本地相册持久化
entry/src/test/LocalUnit.test.ets 水印照片快照和保存状态单元测试

环境与版本信息

项目 当前值
工程类型 HarmonyOS Stage 模型
modelVersion 6.0.2
target SDK 6.0.2(22)
compatible SDK 6.0.2(22)
bundleName com.fun.quweixiangji
versionName 1.0.1
核心 Kit @kit.AbilityKit@kit.CameraKit@kit.ArkData@ohos.multimedia.image
测试依赖 @ohos/hypium: 1.0.25@ohos/hamock: 1.0.0

构建命令可以参考项目 README:

powershell 复制代码
$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

注意:正式上架前要检查签名配置、权限用途说明、隐私政策和真机兼容性。文章里不展示本地签名密码、证书路径等敏感配置,只讨论工程结构和运行逻辑。

一、项目为什么适合写成系列

1quweixiangji 不只是一个单页 Demo。README 里列出的已完成功能包括:

能力 工程意义
相机权限申请 涉及 module.json5 声明和运行时授权
摄像头检测 需要处理前置、后置、无摄像头、检测失败
CameraKit 预览 需要 XComponent Surface 和 PhotoSession
前后摄像头切换 需要可用镜头解析和重新启动预览
定时拍照 需要防重复点击和倒计时状态
本地相册 需要 Preferences 持久化和数据克隆
水印模板 需要拍照时保存水印快照
文档转换 需要从照片记录生成文档记录
单元测试 可以验证照片快照和保存状态

后续系列可以继续写:水印模板、相册持久化、文档转换、人物识别装饰、CoreVision 目标对齐、布局回归截图、设置项持久化等。第 1 篇先把相机主链路讲清楚。

二、相机权限声明

相机权限首先要在 entry/src/main/module.json5 中声明:

json 复制代码
{
  "requestPermissions": [
    {
      "name": "ohos.permission.CAMERA",
      "reason": "$string:camera_permission_reason",
      "usedScene": {
        "abilities": [
          "EntryAbility"
        ],
        "when": "inuse"
      }
    }
  ]
}

这里有三个关键点:

字段 说明
name 使用 ohos.permission.CAMERA
reason 指向资源字符串,用来解释权限用途
usedScene.when 当前项目是前台使用相机,因此配置为 inuse

相机权限不能只在代码里请求。如果 module.json5 没有声明,运行时请求即使写了也不会按预期工作。

三、启动入口初始化本地服务

EntryAbility.etsonCreate 中做了两件事:

ts 复制代码
export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    try {
      this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
      PhotoAlbumService.init(this.context);
      PhotoDocumentService.init(this.context);
    } catch (err) {
      hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err));
    }
    hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
  }
}

第一件事是颜色模式跟随系统,不强制亮暗色。第二件事是初始化本地相册和文档服务。这样 Index.ets 进入页面后调用 PhotoAlbumService.listPhotos() 时,Preferences 已经开始加载,页面不用自己处理底层存储初始化细节。

窗口创建时加载主页面:

ts 复制代码
windowStage.loadContent('pages/Index', (err) => {
  if (err.code) {
    hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
    return;
  }
  hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.');
});

四、主页面状态设计

Index.ets 是拍摄页、相册页、文档页、设置页的总入口。相机主状态集中在组件头部:

ts 复制代码
@State cameraPermissionPromptVisible: boolean = false;
@State cameraPermissionStatus: CameraPermissionStatus = 'idle';
@State cameraStatusText: string = '相机未启用';
@State cameraAvailable: boolean = false;
@State cameraDeviceText: string = '尚未检测摄像头';
@State cameraTotalCount: number = 0;
@State hasFrontCamera: boolean = false;
@State hasBackCamera: boolean = false;
@State activeCameraPosition: CameraPositionKey = 'back';
@State previewSurfaceId: string = '';
@State previewStatus: CameraPreviewStatus = 'idle';
@State previewStatusText: string = '真实预览未启动';
@State captureStatusText: string = '等待拍照';
@State isCapturing: boolean = false;

这些状态可以分成五组:

状态组 字段 用途
权限 cameraPermissionStatuscameraPermissionPromptVisible 控制授权弹窗和权限提示
设备 cameraAvailablehasFrontCamerahasBackCameraactiveCameraPosition 记录可用摄像头
预览 previewSurfaceIdpreviewStatuspreviewStatusText 控制 XComponent Surface 和预览状态
拍照 captureStatusTextisCapturingtimerSecondscountdownText 控制拍照按钮、倒计时和防重复
结果 previewPhotopreviewPhotoPixelMapalbum 控制预览弹窗和保存到相册

这种拆分比一个 cameraState 大对象更直观,因为 ArkTS 页面里很多 UI 区块只关心其中一小组状态。

五、运行时权限申请

页面里点击授权后调用:

ts 复制代码
private async requestCameraPermission(): Promise<void> {
  this.cameraPermissionPromptVisible = false;
  const context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
  const result = await CameraPermissionService.requestCamera(context);
  this.cameraPermissionStatus = result.status;
  this.cameraStatusText = result.message;
  if (result.status === 'granted') {
    this.refreshCameraDevices(context, this.activeCameraPosition);
  } else {
    this.cameraAvailable = false;
    this.captureStatusText = result.message;
  }
}

这里的流程是:

text 复制代码
用户点击申请权限
  -> 关闭自定义提示弹窗
  -> 调用系统权限请求
  -> 写入权限状态和提示文案
  -> 授权成功后发现摄像头
  -> 授权失败时禁用相机能力

对应的服务层封装如下:

ts 复制代码
export class CameraPermissionService {
  static async requestCamera(context: common.UIAbilityContext): Promise<CameraPermissionResult> {
    try {
      const atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
      const result = await atManager.requestPermissionsFromUser(context, [CAMERA_PERMISSION]);
      const granted: boolean = result.authResults.length > 0 && result.authResults[0] === 0;
      if (granted) {
        return {
          status: 'granted',
          message: '相机权限已开启,可以接入真实预览'
        };
      }
      return {
        status: 'denied',
        message: '相机权限未开启,请开启后使用真实取景'
      };
    } catch (error) {
      hilog.error(DOMAIN, TAG, 'request camera permission failed: %{public}s', JSON.stringify(error));
      return {
        status: 'error',
        message: '相机权限申请失败,请稍后重试'
      };
    }
  }
}

服务层没有直接操作 UI,只返回状态和文案。页面决定怎么展示,服务层只处理权限 API 和异常兜底。

六、权限弹窗和页面入口

主预览区域在没有权限时展示遮罩:

ts 复制代码
if (!this.isCameraGranted()) {
  this.CameraPermissionOverlay();
} else if (!this.isRealPreviewRunning()) {
  this.CameraDeviceBadge();
}

遮罩里的按钮不是直接调用系统授权,而是先打开自定义说明弹窗:

ts 复制代码
Button('启用相机')
  .fontSize(13)
  .fontColor('#FFFFFF')
  .backgroundColor('#8D74F7')
  .borderRadius(18)
  .onClick(() => {
    this.openCameraPermissionPrompt();
  })

真正确认时:

ts 复制代码
Button('申请权限')
  .fontSize(14)
  .fontColor('#FFFFFF')
  .backgroundColor('#8D74F7')
  .borderRadius(20)
  .layoutWeight(1)
  .onClick(() => {
    this.confirmCameraPermission();
  })

这样做的好处是用户在系统权限弹窗前先看到业务说明,不会突然被系统权限请求打断。

七、摄像头发现

权限通过后,页面调用设备发现:

ts 复制代码
private refreshCameraDevices(context: common.UIAbilityContext, preferredPosition: CameraPositionKey): void {
  const state: CameraDeviceState = CameraDeviceService.discover(context, preferredPosition);
  this.cameraAvailable = state.available;
  this.cameraTotalCount = state.totalCount;
  this.hasFrontCamera = state.hasFront;
  this.hasBackCamera = state.hasBack;
  this.activeCameraPosition = state.activePosition;
  this.cameraDeviceText = state.message;
  this.captureStatusText = state.available ? state.message : '相机权限已开,但没有检测到可用摄像头';
  if (state.available && this.previewSurfaceId.length > 0) {
    this.startCameraPreview();
  }
}

CameraDeviceService.discover() 负责和 CameraKit 交互:

ts 复制代码
const manager: camera.CameraManager = camera.getCameraManager(context);
const supportedDevices: camera.CameraDevice[] = manager.getSupportedCameras();
const hasFront: boolean = CameraDeviceService.hasCamera(
  manager,
  camera.CameraPosition.CAMERA_POSITION_FRONT
);
const hasBack: boolean = CameraDeviceService.hasCamera(
  manager,
  camera.CameraPosition.CAMERA_POSITION_BACK
);
const activePosition: CameraPositionKey =
  CameraDeviceService.resolveActivePosition(preferredPosition, hasFront, hasBack);

这里没有假设设备一定有前后双摄,而是通过 hasFronthasBack 动态判断。

八、镜头选择策略

镜头选择逻辑:

ts 复制代码
private static resolveActivePosition(
  preferredPosition: CameraPositionKey,
  hasFront: boolean,
  hasBack: boolean
): CameraPositionKey {
  if (preferredPosition === 'front' && hasFront) {
    return 'front';
  }
  if (preferredPosition === 'back' && hasBack) {
    return 'back';
  }
  if (hasBack) {
    return 'back';
  }
  return 'front';
}

这套策略的优先级是:

  1. 用户偏好的镜头可用,就使用偏好;
  2. 偏好不可用时,优先后置;
  3. 后置也不可用时,退到前置。

切换镜头时:

ts 复制代码
private switchCameraPosition(): void {
  if (!this.isCameraGranted()) {
    this.openCameraPermissionPrompt();
    return;
  }
  const nextPosition: CameraPositionKey =
    CameraDeviceService.nextPosition(this.activeCameraPosition, this.hasFrontCamera, this.hasBackCamera);
  const context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
  this.refreshCameraDevices(context, nextPosition);
}

如果还没授权,切换按钮不会静默失败,而是打开权限说明。

九、XComponent 提供预览 Surface

CameraKit 预览需要一个 Surface。页面使用 XComponent

ts 复制代码
@Builder
CameraPreviewSurface() {
  XComponent({
    id: 'cameraPreviewSurface',
    type: XComponentType.SURFACE,
    controller: this.previewController
  })
    .width('100%')
    .height('100%')
    .onLoad(() => {
      this.onPreviewSurfaceReady();
    })
    .onDestroy(() => {
      this.onPreviewSurfaceDestroy();
    })
}

Surface 就绪后:

ts 复制代码
private onPreviewSurfaceReady(): void {
  this.previewSurfaceId = this.previewController.getXComponentSurfaceId();
  if (this.isCameraGranted() && this.cameraAvailable) {
    this.startCameraPreview();
  }
}

销毁时释放预览:

ts 复制代码
private async onPreviewSurfaceDestroy(): Promise<void> {
  this.previewSurfaceId = '';
  this.previewStatus = 'idle';
  this.previewStatusText = '真实预览未启动';
  await CameraPreviewService.stopPreview();
}

这一点非常重要:相机预览不是普通 UI 组件,Surface 销毁后必须停止会话并释放输出,否则下次进入页面可能出现相机被占用。

十、启动 CameraKit 预览

页面启动预览:

ts 复制代码
private async startCameraPreview(): Promise<void> {
  if (!this.isCameraGranted() || !this.cameraAvailable || this.previewSurfaceId.length === 0) {
    return;
  }
  this.previewStatus = 'starting';
  this.previewStatusText = '正在启动相机预览';
  const context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
  const result: CameraPreviewState = await CameraPreviewService.startPreview(
    context,
    this.previewSurfaceId,
    this.activeCameraPosition
  );
  this.previewStatus = result.status;
  this.previewStatusText = result.message;
  if (result.status !== 'running') {
    this.captureStatusText = result.message;
  } else {
    this.captureStatusText = '真实相机预览中';
  }
  if (result.status === 'running' && this.flashEnabled) {
    CameraPreviewService.setFlashEnabled(true);
  }
}

这里先做三重判断:

  • 权限必须已通过;
  • 设备必须可用;
  • Surface ID 必须存在。

这三个条件缺一个都不能启动 CameraKit 预览。

十一、预览服务创建会话

CameraPreviewService.startPreview() 的核心流程:

ts 复制代码
const manager: camera.CameraManager = camera.getCameraManager(context);
const cameraPosition: camera.CameraPosition = position === 'front' ?
  camera.CameraPosition.CAMERA_POSITION_FRONT :
  camera.CameraPosition.CAMERA_POSITION_BACK;
const cameraDevice: camera.CameraDevice =
  manager.getCameraDevice(cameraPosition, camera.CameraType.CAMERA_TYPE_DEFAULT);

const input: camera.CameraInput = manager.createCameraInput(cameraDevice);
await input.open();

const capability: camera.CameraOutputCapability =
  manager.getSupportedOutputCapability(cameraDevice, camera.SceneMode.NORMAL_PHOTO);
const profile: camera.Profile | null =
  capability.previewProfiles.length > 0 ? capability.previewProfiles[0] : null;
const output: camera.PreviewOutput = profile ?
  manager.createPreviewOutput(profile, surfaceId) :
  manager.createPreviewOutput(surfaceId);
const photoProfile: camera.Profile | null =
  capability.photoProfiles.length > 0 ? capability.photoProfiles[0] : null;
const photoOutput: camera.PhotoOutput = photoProfile ?
  manager.createPhotoOutput(photoProfile) :
  manager.createPhotoOutput();

然后创建 PhotoSession

ts 复制代码
const session: camera.PhotoSession =
  manager.createSession<camera.PhotoSession>(camera.SceneMode.NORMAL_PHOTO);

session.beginConfig();
session.addInput(input);
session.addOutput(output);
session.addOutput(photoOutput);
await session.commitConfig();
await session.start();

这个顺序很关键:

text 复制代码
CameraManager
  -> CameraDevice
  -> CameraInput.open()
  -> PreviewOutput
  -> PhotoOutput
  -> PhotoSession.beginConfig()
  -> addInput/addOutput
  -> commitConfig()
  -> start()

只创建预览输出还不能拍照,拍照还需要 PhotoOutput

十二、绑定拍照回调

启动预览时会绑定 photoAvailable 回调:

ts 复制代码
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 篇只关注真实照片回调。后续人物识别和装饰对齐可以单独写一篇,因为 CameraPreviewService 已经扩展了 metadata、CoreVision、人物目标映射等逻辑。

十三、闪光灯状态

闪光灯不是简单布尔值。页面切换时调用:

ts 复制代码
private toggleFlash(): void {
  this.flashEnabled = !this.flashEnabled;
  const state: CameraPreviewState = CameraPreviewService.setFlashEnabled(this.flashEnabled);
  this.captureStatusText = state.message;
  if (state.status === 'error') {
    this.flashEnabled = false;
  }
}

服务层会检查当前会话和设备能力:

ts 复制代码
if (!session.hasFlash()) {
  return {
    status: 'error',
    message: '当前设备不支持闪光灯'
  };
}
const targetMode: camera.FlashMode = enabled ?
  camera.FlashMode.FLASH_MODE_OPEN :
  camera.FlashMode.FLASH_MODE_CLOSE;
if (!session.isFlashModeSupported(targetMode)) {
  return {
    status: 'error',
    message: enabled ? '当前相机不支持开启闪光灯' : '当前相机不支持关闭闪光灯'
  };
}
session.setFlashMode(targetMode);

这种写法避免了"按钮显示已开启,但设备实际不支持"的状态错乱。

十四、拍照按钮和定时拍照

拍照按钮区域:

ts 复制代码
Button(this.timerSeconds > 0 ? `${this.timerSeconds}s 后拍照` : '拍照')
  .fontSize(15)
  .fontColor('#FFFFFF')
  .backgroundColor('#8D74F7')
  .borderRadius(20)
  .width('100%')
  .height(44)
  .onClick(() => {
    this.capturePhoto();
  })

拍照入口先防重复:

ts 复制代码
private async capturePhoto(): Promise<void> {
  if (this.isCapturing || this.countdownText.length > 0) {
    return;
  }
  if (this.timerSeconds > 0) {
    await this.runCountdown();
  }
  await this.captureNow();
}

倒计时逻辑:

ts 复制代码
private async runCountdown(): Promise<void> {
  for (let seconds: number = this.timerSeconds; seconds > 0; seconds--) {
    this.countdownText = `${seconds}`;
    this.captureStatusText = `${seconds}s 后拍照`;
    await this.delay(1000);
  }
  this.countdownText = '';
}

这能避免用户连续点击拍照按钮导致多个 photoOutput.capture() 并发。

十五、真实拍照流程

真正拍照在 captureNow()

ts 复制代码
private async captureNow(): Promise<void> {
  if (this.isCapturing) {
    return;
  }
  this.isCapturing = true;
  await this.releasePreviewPixelMap();
  if (this.shutterSoundEnabled) {
    this.triggerShutterFeedback();
  }
  this.captureStatusText = '正在拍照';

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

  this.previewPhotoPixelMap = captureState.photoPixelMap;
  this.previewShotIndex += 1;
  this.previewPhoto = PhotoAlbumService.createPhoto(
    this.previewShotIndex,
    [],
    '无滤镜',
    '无相框',
    '标准模式',
    'real',
    `${captureState.message} · ${this.resolutionLabel()}`,
    0,
    '标准',
    0,
    this.resolutionLabel(),
    this.watermarkSnapshot()
  );
  this.isCapturing = false;
}

这里有几个工程细节:

  • 拍照前释放上一次 PixelMap
  • 未启动真实预览时不拍照;
  • realPhotoReceived 为 false 时不生成照片记录;
  • 拍照成功后保存 PixelMap 给预览弹窗;
  • 同时创建一条 CapturedPhoto 元数据记录;
  • 水印不是拍完再查 UI,而是拍照时保存快照。

十六、CameraPreviewService 拍照输出

服务层拍照:

ts 复制代码
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
    };
  }

  try {
    const captureStartedAt: number = Date.now();
    const setting: camera.PhotoCaptureSetting = {
      quality: CameraPreviewService.captureQualityLevel(qualityPreference),
      mirror: CameraPreviewService.activeCameraPosition === 'front'
    };
    await CameraPreviewService.photoOutput.capture(setting);
    const realPhotoReceived: boolean =
      await CameraPreviewService.waitForPhotoAvailable(captureStartedAt, 350);
    const visionResult: CameraPhotoVisionResult = realPhotoReceived ?
      await CameraPreviewService.waitForPhotoVision(captureStartedAt, 1600) :
      { targets: [] };
    return {
      success: true,
      message: CameraPreviewService.captureMessage(realPhotoReceived, visionResult.targets.length),
      realPhotoReceived,
      photoEventCount: CameraPreviewService.photoEventCount,
      visionTargetCount: visionResult.targets.length,
      photoPixelMap: visionResult.pixelMap
    };
  } catch (error) {
    hilog.error(DOMAIN, TAG, 'capture photo failed: %{public}s', JSON.stringify(error));
    return {
      success: false,
      message: '真实拍照失败,已使用当前预览合成',
      realPhotoReceived: false,
      photoEventCount: CameraPreviewService.photoEventCount,
      visionTargetCount: 0
    };
  }
}

这里通过 captureStartedAtlastPhotoAvailableAt 判断本次拍照是否真的收到了回调,避免把上一次拍照回调误判成这一次。

十七、解析相机照片为 PixelMap

拍照回调里的图片处理:

ts 复制代码
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 后,如果不做人像分析,就直接返回:

ts 复制代码
if (!analyzeTargets) {
  keepPixelMap = true;
  return {
    targets: [],
    pixelMap
  };
}

资源释放也写在 finally 中:

ts 复制代码
if (pixelMap !== null && !keepPixelMap) {
  await pixelMap.release();
}
if (source !== null) {
  await source.release();
}
await photo.release();

这段代码决定了相机页面是否稳定。PixelMapImageSourcecamera.Photo 都不是普通字符串对象,用完不释放会增加内存压力。

十八、结果预览弹窗

拍照成功后,页面显示结果预览:

ts 复制代码
if (this.previewPhotoPixelMap !== null) {
  Image(this.previewPhotoPixelMap)
    .width('100%')
    .height(250)
    .objectFit(ImageFit.Cover)
    .borderRadius(22)
} else {
  Column()
    .width('100%')
    .height(250)
    .backgroundColor('#1B1B24')
    .borderRadius(22)
}

弹窗底部有两个操作:

ts 复制代码
Button('转文档')
  .onClick(() => {
    this.convertPreviewPhotoToDocument();
  })
Button('保存到相册')
  .onClick(() => {
    this.savePreviewPhoto();
  })

第 1 篇重点是相册保存,文档转换后续可以单独展开。

十九、照片元数据设计

照片记录由 PhotoAlbumService.createPhoto() 创建:

ts 复制代码
static createPhoto(
  sequence: number,
  _layers: DecorationLayer[],
  _filterName: string,
  _frameName: string,
  _beautySummary: string,
  captureSource: CaptureSource = 'simulated',
  captureSummary: string = '真实相机照片',
  _filterIntensity: number = 0,
  _beautyFeature: string = '标准',
  _beautyIntensity: number = 0,
  resolutionLabel: string = '12MP (4:3)',
  watermark?: WatermarkSnapshot
): CapturedPhoto {
  return {
    id: `photo_${Date.now()}_${sequence}`,
    title: `水印照片 ${sequence}`,
    createdAt: PhotoAlbumService.formatNow(),
    layerCount: 0,
    layerSummary: PhotoAlbumService.watermarkSummary(watermark),
    filterName: '无滤镜',
    frameName: '无相框',
    beautySummary: '标准模式',
    resolutionLabel,
    captureSource,
    captureSummary,
    status: 'preview',
    watermark: PhotoAlbumService.cloneWatermark(watermark)
  };
}

这里保存的不是原图文件,而是拍照记录和水印快照。它更像相册列表的业务元数据:

字段 说明
id 照片记录 ID
title 展示标题
createdAt 拍照时间
captureSource real 或模拟来源
captureSummary 本次拍照结果说明
resolutionLabel 分辨率标签
watermark 水印快照
status previewsaved

二十、保存到本地相册

页面保存预览照片:

ts 复制代码
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 = '照片已保存到本地相册';
}

服务层持久化:

ts 复制代码
static async persistPhoto(photo: CapturedPhoto): Promise<CapturedPhoto[]> {
  await PhotoAlbumService.waitForInit();
  const savedPhoto: CapturedPhoto = PhotoAlbumService.savePhoto(photo);
  const nextPhotos: CapturedPhoto[] = [savedPhoto].concat(PhotoAlbumService.cachedPhotos);
  PhotoAlbumService.cachedPhotos = nextPhotos.slice(0, 60);
  await PhotoAlbumService.flushPhotos();
  return PhotoAlbumService.clonePhotos(PhotoAlbumService.cachedPhotos);
}

这个设计有三个点:

  • 新照片插到相册最前面;
  • 本地最多保留 60 条;
  • 返回克隆后的数组,避免页面直接修改内部缓存。

二十一、Preferences 持久化

相册使用 Preferences:

ts 复制代码
const PREF_NAME: string = 'watermark_camera_album';
const PREF_KEY_PHOTOS: string = 'captured_photos';

初始化时读取:

ts 复制代码
PhotoAlbumService.prefs = await preferences.getPreferences(context, PREF_NAME);
const raw: preferences.ValueType = PhotoAlbumService.prefs.getSync(PREF_KEY_PHOTOS, '[]');
if (typeof raw === 'string') {
  PhotoAlbumService.cachedPhotos = PhotoAlbumService.parsePhotos(raw);
}

写入时:

ts 复制代码
await PhotoAlbumService.prefs.put(PREF_KEY_PHOTOS, JSON.stringify(PhotoAlbumService.cachedPhotos));
await PhotoAlbumService.prefs.flush();

Preferences 适合保存轻量元数据。真正的原始图片文件如果未来要长期保存,建议接入媒体库或应用沙箱文件存储,文章后续可以单独写"照片文件化存储"。

二十二、页面生命周期释放资源

Index.ets 在页面消失时释放资源:

ts 复制代码
async aboutToDisappear(): Promise<void> {
  await this.releasePreviewPixelMap();
  await CameraPreviewService.stopPreview();
}

预览服务释放会话、输出和输入:

ts 复制代码
CameraPreviewService.session = null;
CameraPreviewService.previewOutput = null;
CameraPreviewService.photoOutput = null;
CameraPreviewService.metadataOutput = null;
CameraPreviewService.cameraInput = null;
CameraPreviewService.photoAvailableCallback = null;
CameraPreviewService.metadataAvailableCallback = null;

然后按顺序释放:

text 复制代码
session.stop()
session.release()
previewOutput.release()
photoOutput.off('photoAvailable')
photoOutput.release()
metadataOutput.stop()
metadataOutput.release()
cameraInput.close()
CoreVisionHumanService.release()

相机类应用一定要认真处理释放,否则返回页面、切换前后台、切换镜头时都可能出现相机占用或预览黑屏。

二十三、单元测试如何守住照片快照

项目已有本地单元测试:

ts 复制代码
it('photo snapshot keeps watermark metadata', 0, () => {
  const layers: DecorationLayer[] = [];
  const photo = PhotoAlbumService.createPhoto(
    1,
    layers,
    '无滤镜',
    '无相框',
    '标准模式',
    'real',
    '测试拍照',
    0,
    '标准',
    0,
    '12MP (4:3)',
    {
      enabled: true,
      template: 'work',
      title: '工作',
      locationText: '杭州',
      note: '今日拍摄',
      timeText: '06-30 19:49'
    }
  );

  expect(photo.title).assertEqual('水印照片 1');
  expect(photo.layerCount).assertEqual(0);
  expect(photo.watermark?.title).assertEqual('工作');
  expect(photo.watermark?.locationText).assertEqual('杭州');
});

保存状态测试:

ts 复制代码
const saved = PhotoAlbumService.savePhoto(photo);
expect(saved.watermark?.template).assertEqual('check');
expect(saved.watermark?.note).assertEqual('记录一下');
expect(saved.status).assertEqual('saved');

这两条测试没有直接启动相机,但它们守住了拍照后最关键的业务快照:水印信息不能在保存时丢失。

二十四、主流程时序图

可以把第 1 篇的主流程整理成这样:

text 复制代码
EntryAbility.onCreate
  -> PhotoAlbumService.init
  -> PhotoDocumentService.init
  -> windowStage.loadContent('pages/Index')

Index.aboutToAppear
  -> refreshAlbum
  -> refreshDocuments

用户点击启用相机
  -> CameraPermissionService.requestCamera
  -> CameraDeviceService.discover
  -> XComponent.onLoad 获取 surfaceId
  -> CameraPreviewService.startPreview
  -> PhotoSession.start

用户点击拍照
  -> capturePhoto
  -> runCountdown 可选
  -> captureNow
  -> CameraPreviewService.capturePhoto
  -> photoAvailable 回调
  -> ImageSource.createPixelMap
  -> PhotoAlbumService.createPhoto
  -> ResultPreviewDialog

用户保存
  -> PhotoAlbumService.persistPhoto
  -> Preferences.flush
  -> 切到相册 Tab

二十五、常见问题排查

现象 可能原因 排查方式
点击拍照提示"请先启用相机预览" 权限、设备或 Surface 未就绪 cameraPermissionStatuscameraAvailablepreviewSurfaceId
授权后仍没有预览 摄像头发现失败或会话启动失败 CameraDeviceService.discover()startPreview() 日志
前置切换失败 设备没有前置摄像头 hasFrontCamera
闪光灯按钮开启后又关闭 当前设备不支持闪光灯或模式不支持 session.hasFlash()isFlashModeSupported()
拍照后没有预览弹窗 realPhotoReceived 为 false 或没有 PixelMap photoAvailable 回调是否触发
多次拍照卡顿 上一次 PixelMap 没释放 releasePreviewPixelMap()
回到页面预览黑屏 上次会话没有释放干净 aboutToDisappear()stopPreview()
相册保存后列表为空 Preferences 初始化或 flush 失败 PhotoAlbumService.initInternal()flushPhotos()
水印信息丢失 保存时没有克隆水印快照 LocalUnit.test.ets
真机和模拟器表现不一致 CameraKit 能力和权限策略不同 用真机验证权限、前后摄像头、闪光灯

二十六、发布前验收清单

  • module.json5 已声明 ohos.permission.CAMERA
  • 权限用途说明资源存在,用户能理解为什么申请相机;
  • 未授权时显示自定义权限说明;
  • 拒绝授权后不启动 CameraKit;
  • 授权成功后能发现可用摄像头;
  • 前置或后置不存在时,镜头切换不会误报;
  • XComponent 加载后能拿到 surfaceId
  • Surface 销毁时调用 CameraPreviewService.stopPreview()
  • 预览启动失败时页面有明确提示;
  • 拍照时能防止重复点击;
  • 定时拍照倒计时能正确清空;
  • 拍照成功后 realPhotoReceived 为 true;
  • 预览弹窗能展示 PixelMap
  • 关闭或保存后释放 PixelMap
  • 保存后照片状态从 preview 变为 saved
  • Preferences 能写入和读取相册记录;
  • 本地单元测试能验证水印快照;
  • 前后台切换、退出页面、切换镜头后相机资源不残留;
  • 不在文章或日志里暴露本地签名密码、证书路径等敏感信息。

总结

1quweixiangji 的相机主链路已经具备真实项目的骨架:权限声明和运行时申请负责合规入口,设备发现负责前后摄像头可用性,XComponent 提供 CameraKit 预览 Surface,CameraPreviewService 管理 PhotoSessionPreviewOutputPhotoOutput,拍照成功后把真实照片转成 PixelMap,页面再生成带水印快照的 CapturedPhoto 并保存到 Preferences 本地相册。

第 1 篇先把这条链路跑通。后续系列可以继续拆解水印模板、相册持久化、文档转换、人物识别装饰、滤镜/拼图模式和布局回归测试。相机项目很容易写成"能拍就行",但真正稳定的相机 App,一定要把权限、预览、拍照、释放和保存这些状态都写清楚、测清楚。

相关推荐
木木子221 小时前
# [特殊字符] 养猫模拟器 — 鸿蒙ArkTS完整技术解析
华为·harmonyos
anling_li1 小时前
《图片华容道》三、HarmonyOS_ArkTS开发调试实战_崩溃修复全记录
ui·华为·harmonyos
千逐681 小时前
鸿蒙实战:元服务与卡片开发全攻略
华为·harmonyos·鸿蒙
国服第二切图仔10 小时前
HarmonyOS APP《画伴梦工厂》开发第61篇-鸿蒙内核应用快启与GraphicsAccelerateKit
华为·harmonyos
anling_li11 小时前
《随机乱序键盘》四、简约风格随机乱序键盘案例指南
ui·华为·harmonyos
w1395485642214 小时前
鸿蒙实战:PhoneLoginPage 6位验证码 Cell 设计与 60 秒倒计时
华为·harmonyos·鸿蒙系统
xd18557855514 小时前
敲门砖工坊-求职信定制的HarmonyOS开发实践
人工智能·华为·harmonyos·鸿蒙
心中有国也有家14 小时前
AtomGit Flutter 鸿蒙客户端:ModalBottomSheet 实战
android·javascript·学习·flutter·华为·harmonyos
tyqtyq2214 小时前
求职信生成:AI 智能求职信撰写系统的鸿蒙实现
人工智能·学习·华为·生活·harmonyos