【共创稿事节】 HarmonyOS7 元服务【拼词小达人】的分层架构与启动链路
这个项目把「谁是数据的唯一写入方」这件事提前定死了:StorageService 是唯一允许改学习数据的类,页面只读不写,写完再由它统一向 AppStorage 广播。25 个 .ets 文件按五个目录切开,service 与 datasource 之间没有任何一行交叉 import,依赖方向是一条直线。启动链路上只有三个 await,它们的先后顺序决定了首帧页面上到底有没有数据可显示。
目录切分与依赖方向
entry/src/main/ets 下有 25 个文件、3603 行。目录不是按「页面 / 组件」这种 UI 视角切的,而是按「谁依赖谁」切的:
| 目录 | 文件数 | 行数 | 承担的事 |
|---|---|---|---|
pages/ | 9 | 1565 | 9 个页面,其中 3 个是内嵌 Tab 子页 |
components/ | 5 | 416 | 可复用 UI 单元,全部无状态或只持 @Prop |
service/ | 2 | 584 | StorageService 管数据写入,TtsService 管语音 |
datasource/ | 1 | 201 | WordRepository 只管只读词库 |
models/ | 2 | 111 | 纯 interface,零逻辑 |
common/ | 2 | 217 | 常量表与格式化函数 |
entryability/ | 1 | 60 | 启动编排 |
formability/ | 1 | 159 | 卡片生命周期 |
widget/pages/ | 2 | 290 | 两张卡片的 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:
| 调用点 | 目标 | 方式 | 原因 |
|---|---|---|---|
SplashPage | MainPage / GradeSelectPage | replaceUrl | 启动页不该留在栈里 |
GradeSelectPage | MainPage | replaceUrl | 从「我的」页进来时避免双重返回 |
MinePage(清空数据) | GradeSelectPage | replaceUrl | 数据已清空,主页面无意义 |
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 这一条单向通道连接。后面几篇会分别拆开这张图里的每一块 —— 词库怎么建索引、状态怎么广播、卡片怎么拿数据、语音怎么排他。
更多推荐
所有评论(0)