第03篇|源码导览:从源码看地图、相机、相册、保险箱的产品闭环

第一天的第三篇,我们开始读源码。

注意,今天不是逐行啃 Index.ets。真实项目里,主页面代码往往很大,如果一上来就从第 1 行读到最后,很容易迷路。更好的方法是先抓住"产品闭环":用户从哪里进来,数据怎么流动,页面如何切换,哪些服务在背后支撑。

双镜记忆相机的闭环可以拆成四个入口:

  1. 地图:照片落点、附近推荐、故地重游。
  2. 拍照:单拍、双镜拍摄、效果控制。
  3. 相册:照片列表、详情、智能描述、视频管理、系统分享。
  4. 保险箱:本地认证、私密照片、防窥保护、恢复导出。

入口一:先看主页面状态

entry/src/main/ets/pages/Index.ets 里有大量 @State。这些状态不是随便堆的,它们对应项目的主要业务区。

ts 复制代码
@State private activeTab: string = 'map';
@StorageLink('superImage.windowWidthPx') private windowWidthPx: number = 0;
@StorageLink('superImage.windowHeightPx') private windowHeightPx: number = 0;
@StorageLink('superImage.safeAreaTopPx') private safeAreaTopPx: number = 0;
@StorageLink('superImage.safeAreaBottomPx') private safeAreaBottomPx: number = 0;
@StorageLink('superImage.isDarkMode') private isDarkMode: boolean = false;

@State private selectedMemoryId: string = '';
@State private locationPermissionReady: boolean = false;
@State private currentLocationLabel: string = '定位后显示当前回忆点';
@State private currentLatitude: number = 30.25113;
@State private currentLongitude: number = 120.15515;
@State private scenicAgentStatusText: string = '';
@State private scenicPoiSpots: Array<ScenicPoiSpot> = [];
@State private holdingHandSide: HoldingHandSide = 'right';

@State private cameraPermissionReady: boolean = false;
@State private dualCameraSupported: boolean = false;
@State private cameraStatusText: string = '拍照准备中';
@State private selectedCaptureMode: CaptureMode = 'dual';
@State private captureBusy: boolean = false;
@State private lastCaptureSummary: string = '拍完自动进入相册';

@State private galleryRecords: Array<GalleryMoment> = [];
@State private galleryMediaTab: GalleryMediaTab = 'photo';
@State private galleryViewMode: GalleryViewMode = 'album';
@State private galleryAntiPeepActive: boolean = false;

@State private vaultUnlocked: boolean = false;
@State private vaultStatusText: string = '保险箱已锁定';
@State private arkApiKey: string = '';
@State private videoTaskBusy: boolean = false;
@State private nearbyShareReady: boolean = false;
@State private huaweiIdentityReady: boolean = false;
@State private cloudSyncStatusText: string = '请先使用华为账号一键登录';

读这段代码时,可以把状态分成 6 类:

状态组 代表字段 对应效果
全局布局 windowWidthPxsafeAreaBottomPxisDarkMode 手机/平板/2in1、自适应安全区、深浅色
地图定位 currentLatitudescenicPoiSpotsholdingHandSide 地图记忆点、附近景点、握姿重心
相机 dualCameraSupportedselectedCaptureModecaptureBusy 单拍/双拍、拍照按钮状态
相册 galleryRecordsgalleryMediaTabgalleryViewMode 照片列表、视频列表、详情页
隐私 vaultUnlockedgalleryAntiPeepActive 保险箱解锁、防窥遮罩
端云/AI/分享 arkApiKeyvideoTaskBusycloudSyncStatusText AI 图解、视频任务、账号同步、分享

效果上,这些状态共同决定用户看到的是地图页、拍照页、相册页还是保险箱页。

入口二:生命周期里启动主流程

项目启动后,aboutToAppear 会准备首页智能体、加载相册和视频记录、恢复同步会话、注册附近分享、刷新定位或准备相机。

ts 复制代码
aboutToAppear(): void {
  this.applyActiveSystemBarStyle();
  this.prepareScenicAgentEntry();
  void this.loadGalleryRecords();
  void this.loadVideoManagerRecords();
  void this.loadGalleryCloudSyncSession();
  void this.registerNearbyShareListeners();
  if (this.activeTab === 'map') {
    void this.refreshCurrentLocation(true);
    void this.startHoldingHandAwareness();
  } else if (this.activeTab === 'camera') {
    this.scheduleCameraCapabilityPrepare();
  }
  void this.loadVolcengineConfig();
  this.backSurfaceController.setCreateHandler((surfaceId: string) => {
    this.backSurfaceId = surfaceId;
    this.scheduleCameraCapabilityPrepare(80);
  });
}

这段代码告诉我们一个重要事实:真实应用启动时不只是"显示首页"。它还要恢复上一次的业务上下文。

效果上:

  • 相册不是点进页面才临时加载,而是启动时就读取本地记录。
  • 视频管理会恢复未完成任务和本地视频记录。
  • 账号同步状态会从本地恢复,避免用户每次都重新登录。
  • 进入地图页时会刷新定位并启动握姿感知。
  • 相机预览 Surface 创建后,会延迟准备相机能力。

这就是工程闭环:页面出现之前,数据和能力已经开始准备。

入口三:Tab 切换不是换个 UI,它要释放和启动能力

很多练习项目会把 Tab 切换写成 activeTab = tab。双镜记忆相机不能这么简单,因为地图、相机、相册、保险箱背后都有资源占用。

ts 复制代码
private switchTab(nextTab: string): void {
  if (this.activeTab === nextTab) {
    if (nextTab === 'camera') {
      this.scheduleCameraCapabilityPrepare();
    } else if (nextTab === 'map') {
      this.showMapControllerIfActive();
      void this.refreshCurrentLocation(true);
      void this.startHoldingHandAwareness();
    } else if (nextTab === 'gallery') {
      this.syncGalleryGroupSelection();
      void this.loadGalleryRecords();
    }
    return;
  }

  const leavingCamera = this.activeTab === 'camera' && nextTab !== 'camera';
  const leavingVault = this.activeTab === 'vault' && nextTab !== 'vault';
  const leavingMap = this.activeTab === 'map' && nextTab !== 'map';
  const leavingGallery = this.activeTab === 'gallery' && nextTab !== 'gallery';
  if (leavingMap) {
    this.stopLocationAwareness();
    this.stopHoldingHandAwareness();
  }
  if (leavingGallery) {
    this.stopGalleryAntiPeepProtection();
  }
  this.activeTab = nextTab;
  this.applyActiveSystemBarStyle();
  this.showDetailPanel = false;
}

这段代码里有三个值得学的点:

  1. 重复点击当前 Tab 也有意义:拍照页重新准备相机,地图页刷新定位,相册页重载记录。
  2. 离开页面要释放能力:离开地图停止位置感知和握姿监听,离开相册停止防窥。
  3. 切换页面要重置局部状态:详情面板、相机预览、保险箱解锁态都不能无脑保留。

效果上,用户在四个入口之间切换时,不会出现相机资源一直占着、地图控件盖住别的页面、保险箱离开后仍保持解锁这类问题。

入口四:最终渲染只看一个分发函数

主内容渲染集中在 buildActiveTabContent

ts 复制代码
@Builder
private buildActiveTabContent() {
  if (this.isActiveTab('map')) {
    Stack({ alignContent: Alignment.TopStart }) {
      this.buildMapTab()
      if (this.scenicAgentEntryVisible) {
        this.buildScenicAgentOverlay()
        if (this.shouldShowHomeXiaoYiOverlay()) {
          this.buildHomeXiaoYiOverlay()
        }
      }
      if (this.scenicPoiDetailOverlayVisible && this.getSelectedScenicPoi()) {
        this.buildScenicPoiDetailOverlay(this.getSelectedScenicPoi() as ScenicPoiSpot)
      }
    }
    .width('100%')
    .height('100%')
  } else if (this.isActiveTab('camera')) {
    this.buildCameraTab()
  } else if (this.isActiveTab('gallery')) {
    this.buildEnhancedGalleryTab()
  } else {
    this.buildEnhancedVaultTab()
  }
}

再看自适应根布局:

ts 复制代码
@Builder
private buildAdaptiveRoot() {
  if (this.shouldUseSideNavigation()) {
    Row() {
      this.buildSideNavigation()

      Stack() {
        this.buildActiveTabContent()
      }
      .layoutWeight(1)
      .height('100%')
    }
    .width('100%')
    .height('100%')
    .backgroundColor($r('app.color.album_background'))
  } else {
    this.buildActiveTabContent()
  }
}

效果上:

  • 手机屏幕走底部导航。
  • 平板或 2in1 可以走侧边导航。
  • 地图页会叠加智能体入口、小艺浮层、景点详情浮层。
  • 相机、相册、保险箱各自拥有完整页面。

这个结构很好讲:先分发业务页,再根据设备决定导航壳。

服务层如何支撑闭环

不要把所有逻辑都看成 Index.ets 的内容。项目已经拆出多个服务层:

ts 复制代码
import { GalleryMoment, GalleryRecordService } from '../services/GalleryRecordService';
import { GalleryVideoRecord, GalleryVideoService } from '../services/GalleryVideoService';
import { GallerySyncService } from '../services/GallerySyncService';
import { LocalImageMovieService } from '../services/LocalImageMovieService';
import { VolcengineArkService } from '../services/VolcengineArkService';
import { DualPhotoComposerService } from '../services/DualPhotoComposerService';

对应关系如下:

服务 负责什么
GalleryRecordService 照片记录、本地持久化、本地图解
DualPhotoComposerService 双镜照片合成、水印处理
GalleryVideoService 视频记录管理
GallerySyncService 华为账号身份、同步快照、运行状态
LocalImageMovieService 本地图片成片、配乐、Native 转码入口
VolcengineArkService 在线图解、美文、视频任务、图片生成

相册加载就是页面和服务协作的例子:

ts 复制代码
private async loadGalleryRecords(): Promise<void> {
  if (this.galleryLoading) {
    this.galleryLoadQueued = true;
    return;
  }

  this.galleryLoading = true;
  try {
    do {
      this.galleryLoadQueued = false;
      const records = await GalleryRecordService.loadRecords(this.getAbilityContext());
      await this.applyGalleryRecords(records);
    } while (this.galleryLoadQueued);
  } catch (error) {
    const err = error as BusinessError;
    this.galleryNoticeText = `读取相册失败 ${err.code ?? -1}`;
  } finally {
    this.galleryLoading = false;
  }
}

这里不是简单 await loadRecords()。它额外处理了:

  • 重复加载时排队,避免并发读写覆盖。
  • 读取成功后统一调用 applyGalleryRecords 更新页面状态。
  • 失败时把错误转成用户可见的相册提示。
  • 最后无论成功失败,都恢复 galleryLoading

这就是训练营后面要一直强调的质量点:用户看到的是一个按钮,工程里要处理的是并发、状态、失败和恢复。

端云同步也先从状态恢复开始

账号同步不是等用户点击同步才出现。启动时会先恢复身份和运行状态:

ts 复制代码
private async loadGalleryCloudSyncSession(): Promise<void> {
  const context = this.getAbilityContext();
  const identity = await GallerySyncService.loadIdentity(context);
  const state = await GallerySyncService.loadRuntimeState(context);
  if (identity) {
    this.cloudSyncIdentity = identity;
    this.huaweiIdentityReady = true;
    this.huaweiIdentityStatusText = `${this.getCloudAccountDisplayName()} 已登录`;
    if (state) {
      this.updateCloudSyncState(state);
    } else {
      this.cloudSyncStatusText = '已登录,照片会自动同步';
    }
    return;
  }
  this.cloudSyncStatusText = '请先使用华为账号一键登录';
  this.cloudSyncLastLabel = '';
}

效果上,用户重新打开应用时,如果之前登录过,就能看到"已登录""照片会自动同步"等状态,而不是每次从零开始。

跑出来是什么效果

这一篇读完源码后,你应该能在真机或模拟器里对应下面这些效果:

  1. 启动默认进入地图页,因为 activeTab 初始值是 map
  2. 地图页会叠加智能体入口,因为 buildActiveTabContent 在地图分支里构建 buildScenicAgentOverlay
  3. 切到拍照页时会准备相机能力;离开拍照页会释放预览。
  4. 切到相册页时会加载本地照片记录,并按照片/视频模式展示。
  5. 切到保险箱后,未解锁时只显示认证入口;离开保险箱会锁定。
  6. 宽屏设备下会使用侧边导航,手机上使用底部导航。

换句话说,我们已经从源码层面看到了项目的主骨架:状态决定入口,生命周期准备能力,Tab 切换管理资源,服务层承接业务数据。

工程质量点

  • 大页面要先读状态和分发函数,不要直接从头逐行读。
  • Tab 切换要处理资源释放,尤其是地图、相机、防窥、视频播放这类能力。
  • 服务层要承担持久化、同步、合成、网络任务,页面层负责状态和交互。
  • 启动时要恢复本地记录、视频任务、账号同步会话,而不是等用户点进页面才临时读。
  • 多设备布局应该放在根布局层解决,避免每个业务页都重复判断。

质量分自评

代码真实度:30/30

效果完整度:22/25

场景价值:19/20

工程质量:15/15

表达质量:9/10

总分:95/100

今日作业

  1. Index.ets 中搜索 activeTab,画出四个入口的页面分支。
  2. switchTab 中标出"进入页面"和"离开页面"分别做了什么。
  3. 找到 GalleryRecordService.loadRecords,说明它和相册页面之间的数据关系。
  4. 用一句话总结这个项目的源码骨架:状态、页面、服务层分别负责什么。
相关推荐
花先锋队长3 小时前
突破物理极限,华为Pura X Max重新定义折叠机音质天花板
华为·harmonyos
wordbaby3 小时前
鸿蒙 RNOH 下 DocumentPicker copyTo 失败:一个错误码,三个独立根因
harmonyos
FrameNotWork3 小时前
HarmonyOS 智感握姿开发指南:让 UI 跟着握姿自动换边
ui·华为·harmonyos
24白菜头3 小时前
鸿蒙Native C++入门
华为·harmonyos
科技与数码5 小时前
纯血鸿蒙系统深度测评:升级体验与功能全面解析
华为·harmonyos
●VON5 小时前
鸿蒙NEXT ArkUI进阶:用CustomBuilder打造高定制化品牌页签栏
java·华为·harmonyos·鸿蒙·新特性
nashane5 小时前
HarmonyOS 6学习:登录状态同步失效导致评论重复登录的解决方案
学习·华为·harmonyos
程序猿追7 小时前
在 HarmonyOS 屏幕上种一棵勾股定理长成的树——毕达哥拉斯分形绘制
华为·harmonyos