这个项目把「谁是数据的唯一写入方」这件事提前定死了:StorageService 是唯一允许改学习数据的类,页面只读不写,写完再由它统一向 AppStorage 广播。25 个 .ets 文件按五个目录切开,service 与 datasource 之间没有任何一行交叉 import,依赖方向是一条直线。启动链路上只有三个 await,它们的先后顺序决定了首帧页面上到底有没有数据可显示。

目录切分与依赖方向

entry/src/main/ets 下有 25 个文件、3603 行。目录不是按「页面 / 组件」这种 UI 视角切的,而是按「谁依赖谁」切的:

目录文件数行数承担的事
pages/915659 个页面,其中 3 个是内嵌 Tab 子页
components/5416可复用 UI 单元,全部无状态或只持 @Prop
service/2584StorageService 管数据写入,TtsService 管语音
datasource/1201WordRepository 只管只读词库
models/2111纯 interface,零逻辑
common/2217常量表与格式化函数
entryability/160启动编排
formability/1159卡片生命周期
widget/pages/2290两张卡片的 ArkUI 声明

依赖方向可以用一句话概括:pages 与 formability 依赖 service 和 datasource,service 和 datasource 依赖 models 和 common,反向没有边。这一点可以直接用 import 语句验证 —— StorageService 的 import 只有四个:

import { preferences } from '@kit.ArkData';
import { PrefKeys, StoreKeys } from '../common/Constants';
import { UserWordStat } from '../models/UserWordStat';
import { HistoryEntry } from '../models/Word';

WordRepository 的 import 同样干净,只有 util、GradeUtils 和四个模型接口:

import { util } from '@kit.ArkTS';
import { GradeUtils } from '../common/GradeUtils';
import {
  GradeInfo,
  RawWordsJson,
  RawWordJson,
  UnitInfo,
  VersionInfo,
  WordInfo
} from '../models/Word';

两边谁都没有 import 对方。这个约束的价值在「改词库解析」和「改存储格式」这两件事上体现出来:前者只动 WordRepository,后者只动 StorageService,改一边不会牵出另一边的编译错误。

models/ 目录里只有 interface,没有一个 class、没有一行可执行代码。Word.ets 104 行定义了 10 个接口,UserWordStat.ets 7 行定义 1 个:

export interface UserWordStat {
  wordId: string;
  readCount: number;
  lastReadTime: number;
  mastered: boolean;
  collected: boolean;
}

把模型做成纯类型而不是带方法的类,换来的是它可以被随意序列化 —— StorageService 里 JSON.stringify(arr) 直接写盘、JSON.parse(json) as UserWordStat[] 直接读回,中间不需要任何 toJSON / fromJSON 转换层。

启动链路上的三个 await

EntryAbility.onWindowStageCreate 是整个应用的唯一编排点,60 行里真正做事的只有十来行:

async onWindowStageCreate(windowStage: window.WindowStage): Promise<void> {
  hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate');

  try {
    await StorageService.getInstance().init(this.context);
    await WordRepository.getInstance().load(this.context);
  } catch (e) {
    hilog.error(DOMAIN, 'testTag', 'Init error: %{public}s', JSON.stringify(e));
  }

  TtsService.getInstance().init().catch((e: BusinessError) => {
    hilog.error(DOMAIN, 'testTag', 'TTS init failed: %{public}s', JSON.stringify(e));
  });

  windowStage.loadContent('pages/SplashPage', (err) => {
    if (err.code) {
      hilog.error(DOMAIN, 'testTag', 'Failed to load content. Cause: %{public}s', JSON.stringify(err));
      return;
    }
    hilog.info(DOMAIN, 'testTag', 'Succeeded in loading content.');
  });
}

三个初始化调用的等待策略各不相同,这是有意的:

  • StorageService.init() 必须 await。它要把 preferences 里的年级选择、首次使用时间、三个统计计数全读出来,再写进 AppStorage。页面一挂载就会用 @StorageLink 去读这些键,没读完就是空值。
  • WordRepository.load() 必须 await。它要解一次 950762 字节的 JSON 并建三张索引表,SplashPage 的判断条件里直接有 repo.isLoaded(),没加载完就会把用户送回年级选择页。
  • TtsService.init() 不 await。语音引擎创建失败不影响任何页面渲染,把它挂到关键路径上只会让首帧白等。失败路径用 .catch 接住并打日志,speak() 里另有 isReady 判断兜底。

把三个 await 串起来的代价是首帧延后。为了不让语音引擎的初始化失败传染给整个启动流程,两个数据类的初始化被裹在同一个 try 里 —— 这样即使 preferences 打不开,loadContent 仍然会被调用,页面至少能渲染出来,只是拿到的是空数据。这是一个明确的取舍:宁可显示空页面,也不要停在启动窗口。

语音引擎的生命周期跟 Ability 不完全同步,所以 onForeground 里补了一次机会:

onForeground(): void {
  hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground');
  if (!TtsService.getInstance().getIsReady()) {
    TtsService.getInstance().init().catch((e: BusinessError) => {
      hilog.error(DOMAIN, 'testTag', 'TTS reinit failed: %{public}s', JSON.stringify(e));
    });
  }
}

getIsReady() 返回的是 isReady 这个布尔量,它只在 createEngine 成功后被 markReady() 置为 true。回到前台时如果还是 false,说明上次创建没成功,再试一次。onDestroy 里对应的收尾只有一行:

onDestroy(): void {
  hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onDestroy');
  TtsService.getInstance().release();
}

首帧分流与页面栈

SplashPage 是两个页面之间的岔路口,它自己不画任何有信息量的东西,只显示图标和应用名,然后在 aboutToAppear 里做判断:

年级选择页面

图:首次启动时的年级选择页面。

async aboutToAppear(): Promise<void> {
  const storage = StorageService.getInstance();
  const repo = WordRepository.getInstance();

  try {
    if (storage.hasSelectedGrade() && repo.isLoaded()) {
      storage.ensureFirstUseDate();
      await router.replaceUrl({ url: 'pages/MainPage' });
    } else {
      await router.replaceUrl({ url: 'pages/GradeSelectPage' });
    }
  } catch (e) {
    const err: Error = e as Error;
    hilog.error(DOMAIN, 'SplashPage', 'Navigation failed: %{public}s', err.message);
  }
}

判断条件是「选过年级」且「词库加载完成」,两个都成立才进主页面。hasSelectedGrade() 的实现读的是 AppStorage 而不是 preferences:

hasSelectedGrade(): boolean {
  const versionCode: string = AppStorage.get<string>(StoreKeys.VERSION_CODE) ?? '';
  const gradeKey: string = AppStorage.get<string>(StoreKeys.GRADE_KEY) ?? '';
  return versionCode.length > 0 && gradeKey.length > 0;
}

因为 init() 已经把 preferences 里的值搬进了 AppStorage,这里读内存比再读一次盘快,也不必再写 await。两个字段都用 ?? '' 兜住 undefined —— AppStorage.get 在键不存在时返回 undefined,不兜底就会在 .length 上抛。

三个跳转点都用了 replaceUrl 而不是 pushUrl:

调用点目标方式原因
SplashPageMainPage / GradeSelectPagereplaceUrl启动页不该留在栈里
GradeSelectPageMainPagereplaceUrl从「我的」页进来时避免双重返回
MinePage(清空数据)GradeSelectPagereplaceUrl数据已清空,主页面无意义

GradeSelectPage 的 startLearning 里还把原因写在了注释中:

private async startLearning(): Promise<void> {
  if (this.selectedVersionCode.length === 0 || this.selectedGradeKey.length === 0) {
    return;
  }
  this.isStarting = true;
  try {
    const gradeInfo: GradeInfo | undefined = this.grades.find((g: GradeInfo) => g.gradeKey === this.selectedGradeKey);
    const gradeName: string = gradeInfo ? gradeInfo.grade : GradeUtils.gradeKeyToDisplay(this.selectedGradeKey);
    const storage = StorageService.getInstance();
    await storage.saveSelectedGrade(this.selectedVersionCode, this.selectedGradeKey, gradeName);
    await storage.ensureFirstUseDate();

    // 始终使用 replaceUrl 进入 MainPage
    await router.replaceUrl({ url: 'pages/MainPage' });
  } catch (e) {
    const err: Error = e as Error;
    hilog.error(DOMAIN, 'GradeSelectPage', 'startLearning error: %{public}s', err.message);
    this.isStarting = false;
  }
}

gradeName 的取值有两级:先在已加载的 grades 数组里找 gradeKey 匹配的那条拿原始年级名(例如「三年级上册」),找不到才用 gradeKeyToDisplay 从键反推。原始名来自词库 JSON,比反推结果更可靠。

isStarting 在成功路径上不会被复位 —— 因为紧接着就 replaceUrl 走了,页面会被销毁,复位没有意义。只有异常分支才需要把它置回 false,让用户能再点一次。这种「只在失败时复位」的写法比 finally 里复位更贴合实际,因为 finally 会在跳转真正生效前就执行,用户会看到按钮闪一下从「加载中…」变回「开始学习」。

页面注册在 main_pages.json 里只有 6 条:

{
  "src": [
    "pages/SplashPage",
    "pages/GradeSelectPage",
    "pages/MainPage",
    "pages/CardLearnPage",
    "pages/HistoryPage",
    "pages/StatsDetailPage"
  ]
}

而 pages/ 目录下有 9 个文件。差额的三个来源是:UnitListPage、FavoritePage、MinePage 都是 @Component export struct,被 MainPage 的 TabContent 直接内嵌,不是独立路由。只有带 @Entry 的 struct 才需要进 main_pages.json,也才能被 router.pushUrl 作为目标。

MainPage 用 Tabs 把三个子页拼起来,barPosition 放在底部:

Tabs({ barPosition: BarPosition.End }) {
  TabContent() {
    UnitListPage()
  }
  .tabBar(this.tabBuilder(this.tabs[0], 0))

  TabContent() {
    FavoritePage()
  }
  .tabBar(this.tabBuilder(this.tabs[1], 1))

  TabContent() {
    MinePage()
  }
  .tabBar(this.tabBuilder(this.tabs[2], 2))
}
.layoutWeight(1)
.barMode(BarMode.Fixed)
.barHeight(80)
.onChange((index: number) => {
  this.currentIndex = index;
})
.scrollable(false)

scrollable(false) 关掉了左右滑动切页 —— 因为 CardLearnPage 的卡片横滑手势是全局的,如果主页面也响应横滑,两个手势会在边缘互相抢。onChange 只是把索引同步到 @State currentIndex,自定义 tabBar 里的选中态(背景胶囊、加粗文字、小圆点)全部读这个值。

元服务身份与卡片注册

module.json5 里跟普通应用不同的只有两行:

"deliveryWithInstall": true,
"installationFree": true,

installationFree: true 声明这是免安装的元服务,deliveryWithInstall: true 表示它随宿主应用一起分发。两个字段同时为 true 是元服务的标准组合:用户不需要装包就能打开,但能力仍然挂在一个已安装的应用上。deviceTypes 只有 ["phone"],没有为平板或 PC 做适配,卡片尺寸也只有 2*2 和 4*4 两档竖屏版式。

卡片能力通过 extensionAbilities 注册:

"extensionAbilities": [
  {
    "name": "FormAbility",
    "srcEntry": "./ets/formability/FormAbility.ets",
    "type": "form",
    "description": "$string:formability_desc",
    "icon": "$media:icon",
    "label": "$string:formability_label",
    "exported": true,
    "metadata": [
      {
        "name": "ohos.extension.form",
        "resource": "$profile:form_config"
      }
    ]
  }
]

type: "form" 把它标成卡片扩展,metadata 里的 ohos.extension.form 指向 form_config.json,两张卡片的尺寸、刷新策略、入口文件都写在那份 profile 里。exported: true 是必须的 —— 桌面要能拉起它。

单例与无依赖注入

三个类用了同一套单例写法,private constructor 加一个静态字段:

export class WordRepository {
  private static instance: WordRepository | null = null;
  private versions: VersionInfo[] = [];
  private allGrades: Map<string, GradeInfo> = new Map<string, GradeInfo>();
  private allUnits: Map<string, UnitInfo> = new Map<string, UnitInfo>();
  private allWords: Map<string, WordInfo> = new Map<string, WordInfo>();
  private rawData: RawWordsJson | null = null;
  private loaded: boolean = false;

  private constructor() {
  }

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

StorageService 与 TtsService 的结构完全一样。三处都用 === null 判断而不是 if (!instance),因为字段类型是 WordRepository | null,用 ! 会让类型收窄失效。

没有引入任何容器或注解式注入。原因是调用点太集中:StorageService.getInstance() 在 10 个文件里出现,WordRepository.getInstance() 在 9 个文件里出现,写全称虽然啰嗦,但每个页面都能一眼看出自己依赖了谁。代价是初始化顺序由人保证 —— EntryAbility 里那两个 await 就是这条隐性契约的唯一执行点。卡片进程走的是另一条入口(FormAbility.initData),它用 isInitialized() / isLoaded() 两个幂等判断自己补初始化。

FormAbility 里这段是全项目唯一一处「按需初始化」:

private async initData(): Promise<void> {
  if (!StorageService.getInstance().isInitialized()) {
    await StorageService.getInstance().init(this.context);
  }
  if (!WordRepository.getInstance().isLoaded()) {
    await WordRepository.getInstance().load(this.context);
  }
}

StorageService.init() 内部没有幂等判断,全靠调用方这一层 isInitialized() 挡住。WordRepository.load() 则在方法内部自己挡了一道:

async load(context: Context): Promise<void> {
  if (this.loaded) {
    return;
  }
  const buffer: Uint8Array = await context.resourceManager.getRawFileContent('words.json');
  const decoder = util.TextDecoder.create('utf-8');
  const text: string = decoder.decodeToString(buffer);
  const raw = JSON.parse(text) as RawWordsJson;
  this.rawData = raw;
  this.parseData(raw);
  this.loaded = true;
}

两处 isLoaded() / loaded 检查并存不是冗余:EntryAbility 与 FormAbility 可能在不同进程里各自跑一遍,方法内的检查保证同一个实例不会被解析两次,调用方的检查只是省掉一次空调用。

架构上还有一个没被用到的东西值得记一笔:TtsService 里写了一个 reinit() 方法,把 release() 和 init() 串在一起,但全项目没有调用点。真正需要重建时走的是 EntryAbility.onForeground 里那段直接调 init() 的代码。这类方法留着不影响运行,但会让「谁负责重建引擎」这个问题出现两个看似都对的地方,读代码的人需要多确认一次。

骨架到这里就清楚了:数据写入收在一个类里,词库只读且自带幂等,启动编排只有一处,页面与数据层之间靠 AppStorage 这一条单向通道连接。后面几篇会分别拆开这张图里的每一块 —— 词库怎么建索引、状态怎么广播、卡片怎么拿数据、语音怎么排他。

Logo

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

更多推荐