前言

这个项目把「状态放哪里」这件事交给了 @ObservedV2:用户的学习数据不是散在页面里的 @State,而是一个带 @Trace 字段的全局对象,页面用 @Local 持它的引用,用 @Monitor 监听它里面的字段。EntryAbility 只有 35 行,除了 loadContent 什么都没做,所有初始化被搬到了启动页。25 个文件、4035 行,按九组目录切开,依赖方向是一条从页面指向服务的直线。

alt text

九组目录与依赖方向

entry/src/main/ets 下的文件不是按传统的「页面 / 组件 / 服务」三分,而是先把 common 抽出来当底座,再按功能域切 features:

目录文件数行数承担的事
common/services4911词库、存储、语音、卡片刷新
common/components4535卡片、列表行、等级弹窗、安全留白
common/models2133Poem 与 UserState
common/constants3163配色、尺寸、等级表、卡片主题
pages510435 个 @Entry 路由页
features3750三个 Tab 子页,非路由
widget/pages2376两张卡片
formability189卡片生命周期
entryability135只负责挂载首屏

common 占了 1742 行,接近一半。这不是巧合 —— 页面只做「取数据 + 排版」,凡是能被两个以上页面复用的东西都沉到了 common:StorageService.getMasteredPoemIds() 被主页、我的页、详情页、刷卡页、卡片服务五处调用,LevelSystem.getLevel() 被主页欢迎语、我的页等级卡、升级弹窗、卡片数据构建四处调用。

依赖方向可以画成三条链:

pages / features  →  common/services  →  common/models
       ↓                    ↓                  ↑
common/components  →  common/constants  ───────┘

common/services 内部有一条容易被忽略的依赖:StorageService 反过来 import 了 PoemRepository:

import { PoemRepository } from './PoemRepository';

这不是循环,是单向的 —— PoemRepository 只依赖 models、constants 和 @kit.ArkTS,不知道 StorageService 的存在。这条边存在的原因在后面那篇讲统计口径时会展开:所有「已学 / 已学会 / 已收藏」的 id 列表都要先经过词库校验,StorageService 必须能问 PoemRepository「这个 id 还存不存在」。把校验放在存储层而不是页面层,是为了让页面和卡片拿到同一个数字。

反向的边一条都没有。PoemRepository 不 import StorageService,TtsService 不 import 任何业务类,WidgetRefreshService 依赖 StorageService、PoemRepository、LevelSystem 三个但没有反向引用 —— 这一点在卡片那篇里是关键,因为卡片进程和应用进程各有一份单例。

EntryAbility 只做一件事

35 行的 EntryAbility 里没有一个 await:

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    hilog.info(DOMAIN, 'EntryAbility', '%{public}s', 'onCreate');
  }

  onDestroy(): void {
    hilog.info(DOMAIN, 'EntryAbility', '%{public}s', 'onDestroy');
  }

  onWindowStageCreate(windowStage: window.WindowStage): void {
    hilog.info(DOMAIN, 'EntryAbility', '%{public}s', 'onWindowStageCreate');
    windowStage.loadContent('pages/SplashPage', (err) => {
      if (err.code) {
        hilog.error(DOMAIN, 'EntryAbility', 'load failed %{public}s', JSON.stringify(err));
        return;
      }
      hilog.info(DOMAIN, 'EntryAbility', 'load ok');
    });
  }

  onWindowStageDestroy(): void {
  }

  onForeground(): void {
  }

  onBackground(): void {
  }
}

三个空实现(onWindowStageDestroy / onForeground / onBackground)留着不删,是为了让「这里没做事」这件事在代码里显式可见 —— 少了它们,读代码的人会以为忘了处理前后台。

初始化全部搬到 SplashPage.aboutToAppear,理由是它需要 common.UIAbilityContext:

async aboutToAppear(): Promise<void> {
  const ctx: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
  try {
    this.progressMsg = '正在加载课本诗...';
    await PoemRepository.getInstance().load(ctx);
    this.progressMsg = '正在准备朗读引擎...';
    await TtsService.getInstance().init(ctx);
    this.progressMsg = '正在读取学习进度...';
    await StorageService.getInstance().init(ctx);
    // 注册 persist 回调:学习数据每次落盘后,实时刷新桌面服务卡片(收藏/已学会统计)
    StorageService.getInstance().setPersistListener((): void => {
      WidgetRefreshService.getInstance().scheduleRefresh(ctx);
    });
    // 每次启动主动同步一次桌面卡片(覆盖修复前添加、数据已过期的旧卡片)
    WidgetRefreshService.getInstance().scheduleRefresh(ctx);
    this.ready = true;
    setTimeout(() => {
      const grade: number = StorageService.getInstance().userState.selectedGrade;
      if (grade <= 0) {
        this.getUIContext().getRouter().replaceUrl({ url: 'pages/GradeSelectPage' });
      } else {
        this.getUIContext().getRouter().replaceUrl({ url: 'pages/MainPage' });
      }
    }, 600);
  } catch (err) {
    hilog.error(0x0004, 'Splash', 'init error %{public}s', JSON.stringify(err));
    this.progressMsg = '加载失败,请重试';
  }
}

三个 await 串行,顺序不能换:

顺序调用为什么必须在这个位置
1PoemRepository.loadStorageService 的统计要做词库校验,词库没加载完校验会把所有 id 判成无效
2TtsService.init与数据无关,但必须在首屏可交互前完成,否则第一次点朗读会静默失败
3StorageService.init要读 preferences 并跑打卡逻辑,必须在词库之后

progressMsg 是三个 @Local 字符串,每步前改一次,让启动页的文字从「正在加载课本诗」走到「正在读取学习进度」。这比一个静态的转圈更能说明「卡在哪一步」。

600ms 的 setTimeout 是刻意的:数据全部就绪后立刻跳转,用户会看到启动页一闪而过,没有任何「启动完成」的确认感。600ms 让图标和标题在屏幕上停留一个可感知的时长,同时给 scheduleRefresh 留出触发时间。

setPersistListener 是唯一一处「服务注册服务」的接线:

StorageService.getInstance().setPersistListener((): void => {
  WidgetRefreshService.getInstance().scheduleRefresh(ctx);
});

StorageService 不 import WidgetRefreshService(否则 services 内部会形成双向依赖),它只持有一个 (() => void) | null。谁需要「数据落盘后做事」,谁就把自己的回调塞进来。这个模式在 addChangeListener 上用了同一套。

两个带 @Trace 的全局对象

V2 状态管理里,能被监听的字段必须写在 @ObservedV2 装饰的类上,并逐个标 @Trace。项目里有两个这样的类。

UserPoemStat 是「一首诗的学习状态」,52 行的文件里只用了 15 行写第一个类:

@ObservedV2
export class UserPoemStat {
  poemId: number = 0;
  @Trace readCount: number = 0;
  @Trace mastered: boolean = false;
  @Trace collected: boolean = false;
  lastReadTime: number = 0;

  constructor(poemId: number) {
    this.poemId = poemId;
  }
}

三个 @Trace 字段是会被 UI 观察的,poemId 和 lastReadTime 不是。这个区分有实际意义 —— 前者在构造后不再变,后者是「最后阅读时间」,只在写盘时用,没有任何 UI 直接绑它。

UserState 是真正的全局状态容器,12 个 @Trace 字段:

@ObservedV2
export class UserState {
  @Trace selectedGrade: number = 0;      // 0 表示未选择,1-6 表示年级
  @Trace collectedIds: number[] = [];
  @Trace masteredIds: number[] = [];
  @Trace readCounts: Record<number, number> = {};
  @Trace streakDays: number = 0;
  @Trace lastActiveDate: string = '';    // YYYY-MM-DD
  @Trace firstUseDate: string = '';
  @Trace lastLevelIndex: number = -1;    // 上次已知的等级索引,用于检测升级
  // ── 派生统计(由 StorageService.syncStats 集中维护,UI 直接绑定) ──
  @Trace learnedCount: number = 0;       // 已学诗数(读过/收藏/已学会任一命中,经词库校验)
  @Trace masteredCount: number = 0;      // 已学会诗数(经词库校验)
  @Trace collectedCount: number = 0;     // 收藏诗数(经词库校验)
  @Trace statsVersion: number = 0;       // 数据版本号:任何数据变更后 +1

十二个字段分三类:四个用户可变的原始数据(年级、两个 id 列表、阅读次数),三个时间与次数(打卡天、两个日期),五个派生值(三个计数 + 上次等级 + 版本号)。派生值不进 preferences,每次数据变化后由 syncStats 重算 —— 这一点让「计数」永远不可能与「原始列表」不一致。

类末尾还有三个查询方法,都不带 @Trace:

  isCollected(poemId: number): boolean {
    return this.collectedIds.indexOf(poemId) >= 0;
  }

  isMastered(poemId: number): boolean {
    return this.masteredIds.indexOf(poemId) >= 0;
  }

  getReadCount(poemId: number): number {
    const c: number | undefined = this.readCounts[poemId];
    return c !== undefined ? c : 0;
  }

indexOf 是线性查找。masteredIds 最多 111 项(全部诗的数量),VolumeRowItem 对一个册调用它 9 到 17 次,整页最多几百次比较 —— 这个量级下不值得换成 Set。真换成 Set 反而会让序列化变麻烦(JSON.stringify(new Set()) 得到 {})。

readCounts 用 Record<number, number> 而不是 Map,同样是为了序列化:JSON.stringify 对普通对象直接产出 {"1":3,"2":1} 这样的字符串,Map 则产出 {}。代价是键全变成字符串,读回来要 parseInt,这段转换在 loadAll 里。

装饰器各自的位置

V2 的装饰器分工比 V1 更细,项目里用到的有七个:

装饰器用在哪作用
@ObservedV2Poem、UserPoemStat、UserState标记可被深度观察的类
@Trace上面三个类里的字段标记具体哪个字段变化要通知
@ComponentV2全部 structV2 组件
@Local组件内部状态、userState 引用组件私有可变状态
@Param父传子的不可变入参单向数据流
@Event子传父的回调事件出口
@Monitor监听 @Local / 嵌套路径变化时执行回调

PoemCard 一个组件里七种装饰器出现了五种:

@ComponentV2
export struct PoemCard {
  @Param poem: Poem = new Poem(0, '', '', '', '', '');
  @Param isCollected: boolean = false;
  @Param isMastered: boolean = false;
  @Param showMeaning: boolean = false;
  @Param isSpeaking: boolean = false;
  @Param themeIndex: number = 0;
  @Event onSpeak: () => void = () => {
  };
  @Event onToggleCollect: () => void = () => {
  };
  @Event onToggleMastered: () => void = () => {
  };
  @Event onFlipCard: () => void = () => {
  };

六个 @Param 全是值类型或对象引用,四个 @Event 是回调节点。@Param 与 V1 的 @Prop 有一个关键差别:@Param 默认不允许子组件改写,写它会编译报错,这正好符合「卡片只显示、不决策」的定位。V1 的 @Prop 允许子组件改本地副本,容易写出「子组件改了但父组件不知道」的失同步。

@Event 与「声明一个函数类型的普通成员」等价,但语义更清晰:

  onSpeak: () => void = () => {};

这行在 V1 里是标准写法,父组件传 onSpeak: () => {...},子组件直接 this.onSpeak()。V2 用 @Event 标出来之后,「这是给父组件的事件」这件事在声明处就能看出来,不必看调用点。

VolumeRowItem 展示了 @Local 持全局对象的写法:

@ComponentV2
export struct VolumeRowItem {
  @Param volumeIcon: Resource = $r('app.media.ic_book_red');
  @Param volumeName: string = '';
  @Param poemCount: number = 0;
  @Param poems: Poem[] = [];
  @Local userState: UserState = StorageService.getInstance().userState;
  @Event onVolumeClick: (() => void) = () => {
  };

userState 是 @Local,初值直接从单例上取。@Local 在这里不是「组件自己的状态」,而是「持有这个可观察对象的引用」—— 因为 UserState 带 @ObservedV2,它内部 @Trace 字段的变化会穿透 @Local 直接触发重渲染。getMasteredCount() 里读 this.userState.isMastered(...),masteredIds 一变,进度条就重算。

这是 V2 相对 V1 最实质的改进:V1 里嵌在对象里的数组变化不会自动通知,必须重新赋值整个对象;V2 的 @Trace 让「深层字段变化」也能被捕获。项目里两处 toggle 都还保留了「重新赋值整个数组」的写法:

    // 重新赋值整个数组,确保 @Trace/@Monitor 稳定监听到引用变化
    u.collectedIds = next;

这是双保险 —— @Trace 理论上能捕获 push 这类原地修改,但重新赋值一定有效,且不依赖框架对数组方法的拦截实现。

单例与幂等

三个核心服务都是同一套单例模板:

export class PoemRepository {
  private static instance: PoemRepository | null = null;
  private allPoems: Poem[] = [];
  private poemMap: Map<number, Poem> = new Map<number, Poem>();
  private gradeGroups: PoemGrade[] = [];
  private loaded: boolean = false;

  private constructor() {
  }

  static getInstance(): PoemRepository {
    if (PoemRepository.instance === null) {
      PoemRepository.instance = new PoemRepository();
    }
    return PoemRepository.instance;
  }

private constructor 挡住外部 new,=== null 而不是 !instance 让类型收窄成立。WidgetRefreshService、StorageService、TtsService 的结构一模一样。

PoemRepository.load 自带幂等:

async load(context: common.Context): Promise<void> {
  if (this.loaded) {
    return;
  }
  try {
    const buffer: Uint8Array = await context.resourceManager.getRawFileContent('poems.json') as Uint8Array;

StorageService.init 没有这层保护,靠调用方判断。卡片进程那边用的是另一种幂等:缓存 Promise:

private prepareData(): Promise<void> {
  const repo: PoemRepository = PoemRepository.getInstance();
  if (repo.isLoaded()) {
    return StorageService.getInstance().init(this.context, true);
  }
  if (this.preparationPromise === null) {
    this.preparationPromise = repo.load(this.context)
      .then(() => StorageService.getInstance().init(this.context, true))
      .then(() => {
        this.preparationPromise = null;
      });
  }
  return this.preparationPromise;
}

缓存 Promise 而不是布尔标志,好处是「并发调用只会走一次加载,且每个调用方都能等到同一个完成时刻」。布尔标志做不到第二点 —— 第二个调用方看到 true 就返回了,但那时第一个可能还没跑完。

路由与页面集合

main_pages.json 里只有五条:

{
  "src": [
    "pages/SplashPage",
    "pages/GradeSelectPage",
    "pages/MainPage",
    "pages/PoemDetailPage",
    "pages/PoemCardLearnPage"
  ]
}

features 目录下的三个文件没有出现在这里,它们是 @ComponentV2 export struct,被 MainPage 的 TabContent 内嵌:

Tabs({ barPosition: BarPosition.End, controller: this.tabsController }) {
  TabContent() {
    HomeTabPage({ refreshTick: this.refreshTick })
  }
  .tabBar(this.buildTabBar(0))

  TabContent() {
    FavoriteTabPage({ refreshTick: this.refreshTick })
  }
  .tabBar(this.buildTabBar(1))

  TabContent() {
    MineTabPage({ refreshTick: this.refreshTick })
  }
  .tabBar(this.buildTabBar(2))
}

三个子页都接收同一个 refreshTick,MainPage 在 onPageShow 和 onChange 里各递增一次:

onPageShow(): void {
  this.refreshTick++;
}
.onChange((idx: number) => {
  this.currentTab = idx;
  // 切换 Tab 时强制各页重算统计,作为响应式监听之外的兜底刷新通道
  this.refreshTick++;
})

这个参数是「兜底刷新通道」—— 子页用 @Param refreshTick 接,再用 @Monitor('refreshTick') 监听。为什么需要兜底,是后面刷新通道那篇的主题;在这里只需要知道,它是架构里预留的第二条路。

barBackgroundBlurStyle(BlurStyle.COMPONENT_THICK) 配 barBackgroundColor('#CCFFF6E0') 做出「玻璃底栏」,选中项换成渐变胶囊加投影:

.linearGradient(this.currentTab === idx
  ? { angle: 135, colors: [[AppColors.GRADIENT_START, 0], [AppColors.PRIMARY, 1]] }
  : undefined)

linearGradient 传 undefined 表示不加渐变,这是 ArkUI 里条件样式的常规写法 —— 比拆成两个 if 分支包住整个 Row 省一半代码。

架构这一层的形状到这里就清楚了:EntryAbility 挂首屏,启动页串三个初始化并接线卡片刷新,common 放全部可复用能力,features 放三个不占路由的 Tab,pages 只有五个真正会被 pushUrl 打开的页面。状态只有一个入口 —— UserState,页面通过 @Local 持它、@Monitor 听它,写它的人只有 StorageService。

Logo

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

更多推荐