第99篇 | HarmonyOS 最小闭环复盘:从拍照到相册的一次可复现路径

前面 98 篇已经把相机、地图、AI、视频、分享、隐私和发布材料拆开讲过。第 99 篇收回来,只看一个最小闭环:用户点击拍照,应用拿到文件路径,生成 GalleryMoment,写入本地记录,再让相册和地图都能消费这条记录。

这篇不是做概念总结,而是把主链路重新压成一条可复现路径。只要读者能沿着本文从状态字段找到函数,从函数找到记录模型,再回到真机页面确认结果,就说明系列文章真的形成了工程闭环。

版本与环境

本文复测口径为 DevEco Studio 6.1 Release、HarmonyOS SDK 6.1.0(23)、Stage 模型 ArkTS 页面。涉及相机、地图、AI 在线能力、华为账号、系统分享或多端同步时,以真机结果为准;预览器只能用来检查页面结构和文案层级,不能替代权限、设备能力和系统弹窗验证。

对应源码位置

  • entry/src/main/ets/pages/Index.ets
  • entry/src/main/ets/services/GalleryRecordService.ets
  • entry/src/main/ets/services/DualPhotoComposerService.ets

本篇目标

  • 确认拍照结果不是只停留在图片文件,而是进入 GalleryMoment 记录。
  • 理解 appendGalleryRecord 为什么要同时更新选中态、相册、地图和持久化。
  • 把成功态、取消态、保存失败态都放进验收路径。
  • 形成 6.14 继续发布时可复用的文章结构。

从页面状态看最小闭环

最小闭环的起点在 Index 页面。页面同时维护相机权限、双摄能力、拍摄模式、预览状态、相册记录和地图状态,这些字段共同决定用户看到的是“准备拍摄”“正在保存”还是“已进入相册”。

写文章时不能只展示一张相机页截图,还要说明这些状态如何连到结果记录。否则读者只能看到界面,无法判断拍照完成后数据去了哪里。

相机页是最小闭环入口,拍照结果要一路进入相册和地图

相机页是最小闭环入口,拍照结果要一路进入相册和地图

  @State private holdingHandSide: HoldingHandSide = 'right';
  @State private holdingHandAwarenessStatusText: string = '握姿感应待命';
  @State private mapReady: boolean = false;
  @State private mapErrorText: string = '';
  @State private showDetailPanel: boolean = false;
  @State private cameraPermissionReady: boolean = false;
  @State private cameraCapabilityChecked: boolean = false;
  @State private dualCameraSupported: boolean = false;
  @State private cameraStatusText: string = '拍照准备中';
  @State private cameraDeviceCount: number = 0;
  @State private cameraConcurrentProfileCount: number = 0;
  @State private cameraProbeResultText: string = '拍照能力检测完成';
  @State private singleCameraSupported: boolean = false;
  @State private selectedCaptureMode: CaptureMode = 'dual';
  @State private singleCameraRole: CameraLensRole = 'back';
  @State private singlePreviewLive: boolean = false;
  @State private backPreviewLive: boolean = false;
  @State private frontPreviewLive: boolean = false;
  @State private cameraFlashAvailable: boolean = false;
  @State private cameraFlashMode: camera.FlashMode = camera.FlashMode.FLASH_MODE_CLOSE;
  @State private cameraZoomMin: number = 1;
  @State private cameraZoomMax: number = 1;
  @State private cameraZoomCurrent: number = 1;
  @State private cameraZoomReady: boolean = false;
  @State private backLensChoiceKey: string = '';
  @State private captureBusy: boolean = false;
  @State private captureOutputReady: boolean = true;
  @State private capturePairCount: number = 0;
  @State private lastCaptureSummary: string = '拍完自动进入相册';
  @State private cameraCapturePreviewVisible: boolean = false;
  @State private cameraCapturePreviewBackUri: string = '';
  @State private cameraCapturePreviewFrontUri: string = '';
  @State private cameraCapturePreviewTitle: string = '';
  @State private cameraCapturePreviewActionsVisible: boolean = false;
  @State private cameraSequentialThumbnailUri: string = '';
  @State private cameraSequentialThumbnailLabel: string = '';
  @State private selectedCameraEffectCategory: CameraEffectCategoryKey = 'beauty';
  @State private cameraEffectOptionRevision: number = 0;
  @State private selectedBeautyPreset: BeautyPresetKey = 'natural';
  @State private selectedFillLightColor: FillLightColorKey = 'off';
  @State private selectedWatermarkStyle: WatermarkStyleKey = 'place';
  @State private galleryRecords: Array<GalleryMoment> = [];
  @State private galleryLoading: boolean = false;
  @State private gallerySelectedId: string = '';
  @State private selectedGalleryGroupKey: string = '';
  @State private galleryUserNoteDraft: string = '';

markCaptureDelivered 负责收口拍摄结果

拍摄完成后,关键不是回调本身,而是 markCaptureDelivered 如何判断后摄、前摄、单拍和双拍路径是否都已交付。它把不同拍摄模式收束为 GalleryMoment,这也是最小闭环里最值得定位的函数。

真机验收时可以分别跑单拍、双拍和顺序双拍。每一种模式最终都应该生成可读记录,而不是只在日志里显示拍摄成功。

markCaptureDelivered 把 PhotoOutput 结果转成后续可消费的记录

markCaptureDelivered 把 PhotoOutput 结果转成后续可消费的记录

  private async markCaptureDelivered(role: 'back' | 'front'): Promise<void> {
    this.logCaptureTrace(
      'mark-capture-delivered-enter',
      `role=${role} backPath=${this.pendingBackCapturePath} frontPath=${this.pendingFrontCapturePath}`
    );
    if (this.pendingCaptureMode === 'sequence') {
      if (role === 'back') {
        this.backCaptureDelivered = true;
        if (!this.frontCaptureDelivered) {
          this.captureBusy = false;
          this.pendingSingleCaptureRole = 'front';
          this.cameraSequentialThumbnailUri = this.toPhotoImageUri(this.pendingBackCapturePath, '');
          this.cameraSequentialThumbnailLabel = '主图已拍';
          this.hideCameraCapturePreview();
          this.cameraStatusText = '请确认副图画面后继续拍照';
          this.lastCaptureSummary = '';
          void this.prepareSequentialFrontCapture();
          return;
        }
      } else {
        this.frontCaptureDelivered = true;
        if (!this.backCaptureDelivered) {
          this.captureBusy = false;
          this.pendingSingleCaptureRole = 'back';
          this.cameraSequentialThumbnailUri = this.toPhotoImageUri(this.pendingFrontCapturePath, '');
          this.cameraSequentialThumbnailLabel = '副图已拍';
          this.hideCameraCapturePreview();
          this.cameraStatusText = '请确认主图画面后继续拍照';
          this.lastCaptureSummary = '';
          void this.prepareSequentialBackCapture();
          return;
        }
      }

      if (this.backCaptureDelivered && this.frontCaptureDelivered) {
        const selectedMemory = this.getSelectedMapMemory();
        const captureId = this.pendingCaptureId.length > 0 ? this.pendingCaptureId : `${Date.now()}`;
        const createdAt = parseInt(captureId, 10);
        const capturePlace = this.pendingCapturePlace.length > 0 ? this.pendingCapturePlace : selectedMemory.place;
        const captureTitle = this.pendingCaptureTitle.length > 0 ? this.pendingCaptureTitle : selectedMemory.title;

appendGalleryRecord 让记录真正进入应用

appendGalleryRecord 的职责比“数组 unshift 一条记录”更大。它会把记录补成本地图解状态,更新当前选中项,同步相册焦点,再触发持久化。这个函数跑通以后,用户才会在相册、地图和详情里看到一致的结果。

如果文章只讲拍照 API,却不讲 appendGalleryRecord,读者会缺少从系统回调到业务数据的关键桥。

appendGalleryRecord 同步记录、选中态和持久化

appendGalleryRecord 同步记录、选中态和持久化

  private async appendGalleryRecord(record: GalleryMoment): Promise<void> {
    this.logCaptureTrace(
      'append-gallery-record-start',
      `recordId=${record.id} pairIndex=${record.pairIndex} backPath=${record.backPath} frontPath=${record.frontPath}`
    );
    const readyRecord = record.aiStatus === 'ready' ? record : GalleryRecordService.applyLocalInsight(record);
    const nextRecords = [readyRecord, ...this.galleryRecords.filter((item: GalleryMoment) => item.id !== readyRecord.id)];
    this.galleryRecords = nextRecords;
    this.syncRecordSelections(nextRecords);
    this.gallerySelectedId = readyRecord.id;
    this.selectedGalleryGroupKey = this.buildGalleryRecordGroupKey(readyRecord);
    this.galleryUserNoteDraft = this.getRecordUserNote(readyRecord);
    this.showCameraCapturePreview(readyRecord);
    this.syncSelectedMapMemory(true);
    this.capturePairCount = nextRecords.length;
    this.galleryNoticeText = this.hasGalleryFocus()
      ? this.getGalleryScopeDescription()
      : ''
    await this.syncMapMarkers();
    this.updateAwarenessRecommendation(false);
    await this.persistGalleryRecords(nextRecords);
    this.gallerySelectedId = readyRecord.id;
    this.selectedGalleryGroupKey = this.buildGalleryRecordGroupKey(readyRecord);

持久化是闭环的最后一道验收

记录写入内存只是中间态,重启应用后还能看到才算落盘。GalleryRecordService 负责把记录保存到 Preferences,并在读取时做归一化,旧字段、空字段和 fileUri 都需要在这里收口。

所以本篇的验收不止是“拍完出现一张图”,还要重启应用、返回相册、打开详情,确认记录仍然可读。

GalleryRecordService 把相册记录保存到本地并做恢复归一化

GalleryRecordService 把相册记录保存到本地并做恢复归一化

  longitude: number;
  backPath: string;
  frontPath: string;
  watermarkStyle?: GalleryWatermarkStyle;
  watermarkText?: string;
}

export class GalleryRecordService {
  private static readonly STORE_NAME: string = 'super_image_gallery';
  private static readonly STORE_KEY: string = 'gallery_records';
  private static readonly DEFAULT_USER_NOTE: string = '';
  private static readonly DEFAULT_AI_POEM: string = '';
  private static readonly DEFAULT_AI_CAPTION: string = '这份照片会保留拍摄地点、时间和画面氛围,你可以继续补充备注。';
  private static readonly DEFAULT_VIDEO_PROMPT: string = '选择多张照片后,可以整理成一条回忆短片。';

  static async loadRecords(context: common.UIAbilityContext): Promise<Array<GalleryMoment>> {
    try {
      const store = await preferences.getPreferences(context, GalleryRecordService.STORE_NAME);
      const rawValue = store.getSync(GalleryRecordService.STORE_KEY, '[]') as string;
      return GalleryRecordService.parseRecords(rawValue);
    } catch (error) {
      console.error(`Failed to load gallery records: ${JSON.stringify(error)}`);
      return [];
    }
  }

  static async saveRecords(context: common.UIAbilityContext, records: Array<GalleryMoment>): Promise<void> {
    try {
      const store = await preferences.getPreferences(context, GalleryRecordService.STORE_NAME);
      store.putSync(GalleryRecordService.STORE_KEY, JSON.stringify(records));
      await store.flush();
    } catch (error) {
      console.error(`Failed to save gallery records: ${JSON.stringify(error)}`);
    }
  }

真机验收步骤

验收点 操作 预期结果
拍照成功 在真机完成一次单拍或双拍 相册新增记录,地图或详情能读到同一条记录
取消拍摄 中途取消或拒绝权限 页面恢复可点击,不新增空记录
重启恢复 关闭应用后重新进入相册 记录仍存在,图片 Uri 可读
失败提示 模拟文件路径不可读或保存失败 显示可读文案并允许重试

复现边界

这篇只验证最小拍照闭环,不承诺所有设备都支持前后双摄并发。并发能力仍以 getCameraConcurrentInfos 的真机返回为准。

如果设备不支持双摄并发,最小闭环可以降级为单拍或顺序双拍,只要最终记录能保存、恢复、查看,就仍然是有效闭环。

社区同步摘要

本文适合同步到 HarmonyOS 开发者社区,摘要可以聚焦“从 PhotoOutput 到 GalleryMoment 的最小闭环”,并附上 markCaptureDelivered、appendGalleryRecord、GalleryRecordService 三个源码入口。

今日练习

  1. 在 Index.ets 中搜索 markCaptureDelivered,并画出单拍和双拍两条分支。
  2. 拍一张照片后重启应用,确认记录是否还能打开。
  3. 故意拒绝相机权限,记录页面如何恢复。
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐