第一天的第三篇,我们开始读源码。
注意,今天不是逐行啃 Index.ets。真实项目里,主页面代码往往很大,如果一上来就从第 1 行读到最后,很容易迷路。更好的方法是先抓住"产品闭环":用户从哪里进来,数据怎么流动,页面如何切换,哪些服务在背后支撑。
双镜记忆相机的闭环可以拆成四个入口:




- 地图:照片落点、附近推荐、故地重游。
- 拍照:单拍、双镜拍摄、效果控制。
- 相册:照片列表、详情、智能描述、视频管理、系统分享。
- 保险箱:本地认证、私密照片、防窥保护、恢复导出。
入口一:先看主页面状态

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 类:
| 状态组 | 代表字段 | 对应效果 |
|---|---|---|
| 全局布局 | windowWidthPx、safeAreaBottomPx、isDarkMode |
手机/平板/2in1、自适应安全区、深浅色 |
| 地图定位 | currentLatitude、scenicPoiSpots、holdingHandSide |
地图记忆点、附近景点、握姿重心 |
| 相机 | dualCameraSupported、selectedCaptureMode、captureBusy |
单拍/双拍、拍照按钮状态 |
| 相册 | galleryRecords、galleryMediaTab、galleryViewMode |
照片列表、视频列表、详情页 |
| 隐私 | vaultUnlocked、galleryAntiPeepActive |
保险箱解锁、防窥遮罩 |
| 端云/AI/分享 | arkApiKey、videoTaskBusy、cloudSyncStatusText |
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;
}
这段代码里有三个值得学的点:
- 重复点击当前 Tab 也有意义:拍照页重新准备相机,地图页刷新定位,相册页重载记录。
- 离开页面要释放能力:离开地图停止位置感知和握姿监听,离开相册停止防窥。
- 切换页面要重置局部状态:详情面板、相机预览、保险箱解锁态都不能无脑保留。
效果上,用户在四个入口之间切换时,不会出现相机资源一直占着、地图控件盖住别的页面、保险箱离开后仍保持解锁这类问题。
入口四:最终渲染只看一个分发函数
主内容渲染集中在 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 = '';
}
效果上,用户重新打开应用时,如果之前登录过,就能看到"已登录""照片会自动同步"等状态,而不是每次从零开始。
跑出来是什么效果
这一篇读完源码后,你应该能在真机或模拟器里对应下面这些效果:
- 启动默认进入地图页,因为
activeTab初始值是map。 - 地图页会叠加智能体入口,因为
buildActiveTabContent在地图分支里构建buildScenicAgentOverlay。 - 切到拍照页时会准备相机能力;离开拍照页会释放预览。
- 切到相册页时会加载本地照片记录,并按照片/视频模式展示。
- 切到保险箱后,未解锁时只显示认证入口;离开保险箱会锁定。
- 宽屏设备下会使用侧边导航,手机上使用底部导航。
换句话说,我们已经从源码层面看到了项目的主骨架:状态决定入口,生命周期准备能力,Tab 切换管理资源,服务层承接业务数据。
工程质量点
- 大页面要先读状态和分发函数,不要直接从头逐行读。
- Tab 切换要处理资源释放,尤其是地图、相机、防窥、视频播放这类能力。
- 服务层要承担持久化、同步、合成、网络任务,页面层负责状态和交互。
- 启动时要恢复本地记录、视频任务、账号同步会话,而不是等用户点进页面才临时读。
- 多设备布局应该放在根布局层解决,避免每个业务页都重复判断。
质量分自评
代码真实度:30/30
效果完整度:22/25
场景价值:19/20
工程质量:15/15
表达质量:9/10
总分:95/100
今日作业
- 在
Index.ets中搜索activeTab,画出四个入口的页面分支。 - 在
switchTab中标出"进入页面"和"离开页面"分别做了什么。 - 找到
GalleryRecordService.loadRecords,说明它和相册页面之间的数据关系。 - 用一句话总结这个项目的源码骨架:状态、页面、服务层分别负责什么。