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

九组目录与依赖方向
entry/src/main/ets 下的文件不是按传统的「页面 / 组件 / 服务」三分,而是先把 common 抽出来当底座,再按功能域切 features:
| 目录 | 文件数 | 行数 | 承担的事 |
|---|---|---|---|
common/services | 4 | 911 | 词库、存储、语音、卡片刷新 |
common/components | 4 | 535 | 卡片、列表行、等级弹窗、安全留白 |
common/models | 2 | 133 | Poem 与 UserState |
common/constants | 3 | 163 | 配色、尺寸、等级表、卡片主题 |
pages | 5 | 1043 | 5 个 @Entry 路由页 |
features | 3 | 750 | 三个 Tab 子页,非路由 |
widget/pages | 2 | 376 | 两张卡片 |
formability | 1 | 89 | 卡片生命周期 |
entryability | 1 | 35 | 只负责挂载首屏 |
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 串行,顺序不能换:
| 顺序 | 调用 | 为什么必须在这个位置 |
|---|---|---|
| 1 | PoemRepository.load | StorageService 的统计要做词库校验,词库没加载完校验会把所有 id 判成无效 |
| 2 | TtsService.init | 与数据无关,但必须在首屏可交互前完成,否则第一次点朗读会静默失败 |
| 3 | StorageService.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 更细,项目里用到的有七个:
| 装饰器 | 用在哪 | 作用 |
|---|---|---|
@ObservedV2 | Poem、UserPoemStat、UserState | 标记可被深度观察的类 |
@Trace | 上面三个类里的字段 | 标记具体哪个字段变化要通知 |
@ComponentV2 | 全部 struct | V2 组件 |
@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。
更多推荐


所有评论(0)