留痕 HarmonyOS 实战系列

图1:第二章封面,先从首页串起拍照、录音、记录和统计。

先把源码地图铺开

第一篇已经把工程跑起来了,这一篇开始进入源码导览。对于留痕这个项目来说,真正值得先看的不是某一个组件,而是首页、服务层、路由和数据模型如何连成一条线。只要把这条线看清楚,后面拍照、录音、水印、记录、统计五条功能支路就都好理解了。

本章的阅读目标很明确:先知道哪些文件负责什么,再看首页怎么刷新,最后确认页面切换和数据重算是怎样把整个应用串起来的。

源码地图

文件/模块

职责

这一章会怎么用

entry/src/main/ets/pages/Index.ets

首页工作台和 Tab 切换

看它怎么刷新概览、功能入口和当前页

dynamiclibrary/src/main/ets/services/WorkClockService.ets

业务服务、状态持久化和统计汇总

看它怎么提供首页数据和记录数据

staticlibrary/src/main/ets/models/WorkClockModels.ets

WorkRecord、WatermarkTemplate 等共享模型

看数据字段是如何统一的

entry/src/main/ets/common/Routes.ets

页面路由和功能入口映射

看首页按钮如何跳到各功能页

staticlibrary/src/main/ets/common/AppTabs.ets

底部 Tab 常量

看首页、记录、相机、统计、我的如何切换

entry/src/main/ets/entryability/EntryAbility.ets

入口初始化和主页加载

看应用启动后首页怎么出现

图2:应用源码架构图,UI、Service、Model 和 Route 四层关系一眼就能看清。

首页为什么能自己刷新

首页的关键不是静态页面,而是它和服务层之间的联动。Index.ets 会在页面显示时读取本地的 Tab 状态和服务层数据,只要记录或模板有变化,页面就会随着版本号刷新。这样首页、记录页和统计页看到的就是同一份数据。

@StorageLink(AppStorageKeys.WORKCLOCK_VERSION)
@Watch('handleWorkClockVersionChanged')
private workclockVersion: number = 0;

private refreshAllData(resetCalendarDate: boolean): void {
  const storedTab: string | undefined = AppStorage.get(AppStorageKeys.MAIN_TAB) as string | undefined;
  if (storedTab && storedTab.length > 0) {
    this.currentTab = storedTab;
  }

  this.overview = this.service.getOverviewSnapshot();
  this.actions = this.service.getHomeActions();
  this.records = this.service.getRecords();
  this.recentRecords = this.service.getRecentRecords();
  this.featureBullets = this.service.getFeatureBullets();
  this.settings = this.service.getSettings();
}

刷新来源

读取内容

页面结果

AppStorageKeys.MAIN_TAB

上次选中的 Tab

页面恢复到用户离开时的模块

WORKCLOCK_VERSION

服务层版本号

数据变化后首页重新读取

getOverviewSnapshot()

今日记录、本月记录、项目数等概览

首页头部摘要同步更新

getRecords()/getRecentRecords()

全部记录和最近记录

记录卡片和最近记录区刷新

底部 Tab 是怎么切页的

底部 Tab 看起来只是五个按钮,实际上它把整个应用的主导航固定了下来。AppTabs 给出五个稳定的 tab id,Index 再根据当前 tab 决定要渲染哪个页面。

export class AppTabs {
  static readonly HOME: string = 'home';
  static readonly RECORDS: string = 'records';
  static readonly CAMERA: string = 'camera';
  static readonly STATS: string = 'stats';
  static readonly PROFILE: string = 'profile';
}

@Builder
private buildCurrentTab() {
  if (this.currentTab === AppTabs.RECORDS) {
    this.buildCalendarTab()
  } else if (this.currentTab === AppTabs.CAMERA) {
    this.buildCameraPreviewTab()
  } else if (this.currentTab === AppTabs.STATS) {
    this.buildStatsTab()
  } else if (this.currentTab === AppTabs.PROFILE) {
    this.buildProfileTab()
  } else {
    this.buildHomeTab()
  }
}

图3:路由地图,首页和各功能页之间的关系更容易理解。

路由表把页面串起来

Routes.ets 负责把首页按钮和设置入口映射到具体页面。这样做的好处是:页面名集中管理,后续改路由时不用在每个按钮里到处找字符串。

export class Routes {
  static readonly MAIN_PAGE: string = 'pages/Index';
  static readonly FEATURE_CAPTURE: string = 'pages/FeatureCapturePage';
  static readonly FEATURE_VOICE: string = 'pages/FeatureVoicePage';
  static readonly FEATURE_WATERMARK: string = 'pages/FeatureWatermarkPage';
  static readonly FEATURE_PROJECT: string = 'pages/FeatureProjectPage';
  static readonly FEATURE_RECORD: string = 'pages/FeatureRecordPage';
  static readonly FEATURE_CALENDAR: string = 'pages/FeatureCalendarPage';
  static readonly FEATURE_STATS: string = 'pages/FeatureStatsPage';
  static readonly FEATURE_SETTINGS: string = 'pages/FeatureSettingsPage';

  static resolveFeatureRoute(featureId: string): string | undefined {
    switch (featureId) {
      case 'capture': return Routes.FEATURE_CAPTURE;
      case 'voice': return Routes.FEATURE_VOICE;
      case 'watermark': return Routes.FEATURE_WATERMARK;
      case 'project': return Routes.FEATURE_PROJECT;
      case 'record': return Routes.FEATURE_RECORD;
      case 'calendar': return Routes.FEATURE_CALENDAR;
      case 'stats': return Routes.FEATURE_STATS;
      case 'settings': return Routes.FEATURE_SETTINGS;
      default: return undefined;
    }
  }
}

入口 id

对应页面

典型场景

capture

FeatureCapturePage

现场拍照

voice

FeatureVoicePage

现场录音

watermark

FeatureWatermarkPage

水印模板

project

FeatureProjectPage

项目管理

record

FeatureRecordPage

记录管理

calendar

FeatureCalendarPage

日历回看

stats

FeatureStatsPage

统计分析

settings

FeatureSettingsPage

设置中心

服务层负责什么

WorkClockService 是整个应用的数据中枢。它负责启动时初始化本地仓库、读取持久化状态、构造首页概览、提供最近记录、管理水印模板、保存分类和备注选项,以及在记录变化时重新发布版本号。

bootstrap(context: common.UIAbilityContext): void {
  AppStorage.setOrCreate<number>(AppStorageKeys.WORKCLOCK_VERSION, 0);
  AppStorage.setOrCreate<string>(AppStorageKeys.MAIN_TAB, AppTabs.HOME);
  WorkClockRepository.initialize(context);

  const raw: string = WorkClockRepository.readState();
  if (raw.length > 0) {
    const state: WorkClockState = this.parseState(raw);
    this.records = state.records;
    this.watermarkTemplates = state.watermarkTemplates ?? WorkClockService.buildDefaultWatermarkTemplates();
    this.selectedWatermarkTemplateId = state.selectedWatermarkTemplateId ?? this.watermarkTemplates[0].id;
    this.categoryOptions = state.categoryOptions ?? WorkClockService.buildDefaultCategoryOptions();
    this.noteOptions = state.noteOptions ?? WorkClockService.buildDefaultNoteOptions();
    this.publishSelectedWatermarkSnapshot();
    this.bumpVersion();
    return;
  }

  this.publishSelectedWatermarkSnapshot();
  this.persist();
}

getOverviewSnapshot(): OverviewSnapshot {
  let totalMinutes: number = 0;
  const projectMap: Record<string, boolean> = {};
  this.records.forEach((record: WorkRecord) => {
    totalMinutes += record.durationMinutes;
    projectMap[record.projectName] = true;
  });
  const latestRecord: WorkRecord | undefined = this.records[0];

  return {
    todayPunchCount: this.getSelectedDayRecords().length,
    monthlyRecordCount: this.records.length,
    projectCount: Object.keys(projectMap).length,
    totalMinutes: totalMinutes,
    latestProject: latestRecord?.projectName ?? '--',
    latestLocation: latestRecord?.location ?? '--'
  };
}

private persist(): void {
  WorkClockRepository.writeState(JSON.stringify({
    records: this.records,
    watermarkTemplates: this.watermarkTemplates,
    selectedWatermarkTemplateId: this.selectedWatermarkTemplateId,
    categoryOptions: this.categoryOptions,
    noteOptions: this.noteOptions
  } as WorkClockState));
  this.publishSelectedWatermarkSnapshot();
  this.bumpVersion();
}

数据模型长什么样

UI 层和 Service 层之所以能互相理解,是因为它们共用一套模型定义。WorkRecord 承载照片和录音记录,WatermarkTemplate 承载水印样式,StatsSummary 承载统计图表的数据。

模型

核心字段

用途

WorkRecord

projectName / captureDate / location / note / mediaUri / audioUri

承载照片、录音和备注

WatermarkTemplate

title / accentColor / backgroundColor / timeLabel / locationLabel

承载水印模板

OverviewSnapshot

todayPunchCount / monthlyRecordCount / projectCount / totalMinutes

承载首页概览

StatsSummary

metrics / trend / categories

承载统计页图表

CalendarDay

day / isSelected / hasRecord

承载日历回看

export interface WorkRecord {
  id: string;
  title: string;
  projectName: string;
  captureDate: string;
  captureTime: string;
  durationLabel: string;
  durationMinutes: number;
  location: string;
  weather: string;
  temperature: string;
  note: string;
  category: string;
  mediaUri?: string;
  audioUri?: string;
}

读源码时建议的顺序

  • 先看 `Routes.ets` 和 `AppTabs.ets`,把页面与入口的名字对上。
  • 再看 `Index.ets` 的 `refreshAllData()` 和 `buildCurrentTab()`,理解首页怎样决定自己展示什么。
  • 接着看 `WorkClockService` 的 `bootstrap()`、`getOverviewSnapshot()` 和 `persist()`,理解数据如何读写。
  • 最后回到 `WorkClockModels.ets`,确认页面和服务共用的字段到底有哪些。

四、首页、路由和服务层的连接点

第二篇把源码地图铺开之后,最值得再单独讲清楚的,是首页、路由和服务层之间的连接点。首页不是孤立页面,`Index.ets` 负责把服务层快照重新渲染出来,`AppTabs` 负责保存底部栏状态,`Routes` 负责把功能入口映射到具体页面,`WorkClockService` 则负责提供真实数据。四者配在一起,整个项目才会看起来像一条完整链路。

模块

职责

这一章要盯什么

Index.ets

首页状态和刷新

看 `refreshAllData()` 如何同步概览、记录和设置

Routes.ets

功能入口映射

看 `featureId` 如何落到实际页面名

AppTabs.ets

底部 Tab 常量

看首页如何记住用户上次停留的位置

WorkClockService.ets

业务数据中枢

看概览、记录和统计如何共用同一份数据

private refreshAllData(resetCalendarDate: boolean): void {
  const storedTab: string | undefined = AppStorage.get(AppStorageKeys.MAIN_TAB) as string | undefined;
  if (storedTab && storedTab.length > 0) {
    this.currentTab = storedTab;
  }

  this.overview = this.service.getOverviewSnapshot();
  this.actions = this.service.getHomeActions();
  this.records = this.service.getRecords();
  this.recentRecords = this.service.getRecentRecords();
}

如果只看某一个页面,很容易觉得留痕只是“拍照、录音、记录、统计”四五个功能块;但从源码角度看,它更像是一个共享数据中枢。首页把入口和概览聚合起来,路由把动作分发出去,服务层把数据收回来,这样后面每个功能页的回流路径才会稳定。

本篇小结

第二篇真正做的事情,是把留痕这个项目的源码地图画出来。只要你知道首页怎么刷新、路由怎么跳转、服务层怎么持久化、模型怎么承载数据,后面再看拍照、录音、水印、记录和统计,就不会再觉得每个页面都是孤立的。

下一篇会继续看入口 Ability 和首页工作台,看看应用是怎样从启动阶段进入首页,并把拍照、录音和记录入口摆到用户面前的。

今日作业

  • 打开 `Index.ets`,找到 `refreshAllData()` 和 `buildCurrentTab()`,在脑子里画出首页刷新链路。
  • 打开 `WorkClockService.ets`,找出 `bootstrap()`、`getOverviewSnapshot()` 和 `persist()` 的关系。
  • 打开 `Routes.ets`,把 `featureId` 和实际页面名对应起来。

本章导读

这一章开始从源码地图看项目。我们不只看“能用”,还要看首页、拍照、录音、记录和统计之间是怎么互相接上的。

  • 先看首页承接几个入口。
  • 再看拍照和录音如何回到记录页。
  • 最后看统计页如何从同一批数据重算结果。

把入口和回流关系看清楚,后面再看每一页的细节就不会散。

  • 打开 `WorkClockModels.ets`,确认 `WorkRecord` 的字段能否覆盖照片和录音两条记录。
Logo

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

更多推荐