HarmonyOS7 元服务「诗境」的三条刷新通道与 @Monitor 取舍
文章目录
前言
同一个「收藏数变了」的事件,在这个项目里有三条路能通知到界面:服务里的回调总线、@Monitor('userState.statsVersion')、@Monitor('refreshTick')。三条并存不是为了保险起见,而是各自覆盖一种另外两条盖不住的场景 —— 搞清楚哪条在什么时候生效,比记住「用了 @Monitor」有用得多。

回调总线:唯一能穿透组件层级的一条
StorageService 里维护一个函数数组:
/** 数据变更回调总线:跨页面实时同步的可靠通道(不依赖状态管理观察机制) */
private changeListeners: Array<() => void> = [];
addChangeListener(listener: () => void): void {
this.changeListeners.push(listener);
}
removeChangeListener(listener: () => void): void {
const idx: number = this.changeListeners.indexOf(listener);
if (idx >= 0) {
this.changeListeners.splice(idx, 1);
}
}
private notifyChange(): void {
hilog.info(DOMAIN, TAG, 'notifyChange listeners=%{public}d', this.changeListeners.length);
for (let i = 0; i < this.changeListeners.length; i++) {
this.changeListeners[i]();
}
}
removeChangeListener 靠 indexOf 找位置,这要求「注册的引用」和「退订的引用」是同一个函数对象。所以每个组件都不写成方法,而写成类字段:
/** 回调总线监听器(稳定引用,便于退订) */
private onStoreChange: () => void = () => {
this.refresh();
};
箭头函数在字段初始化时创建一次,之后 this.onStoreChange 永远指向同一个对象。如果写成普通方法 private onStoreChange(): void {...} 再传 this.onStoreChange,每次取属性拿到的都是新绑定的函数(取决于编译目标),indexOf 会返回 -1,退订静默失败,组件销毁后监听器还留在数组里,下次数据变更时对已销毁的组件调 refresh()。
六个组件用同一套写法注册与退订:
| 组件 | 场景 | 注册回调做的事 |
|---|---|---|
HomeTabPage | 首页 | 重算当前年级的册列表 |
FavoriteTabPage | 收藏 Tab | 重取收藏诗列表 |
MineTabPage | 我的 Tab | 重取已学会诗列表 |
PoemCardLearnPage | 刷卡页 | 同步当前卡的收藏/已学会标记 |
PoemDetailPage | 详情页 | 同步收藏/已学会标记 |
PoemRowCard | 列表行 | 同步两个徽标状态 |
四处都挂在 aboutToAppear / aboutToDisappear 上,成对出现:
aboutToAppear(): void {
this.refresh();
StorageService.getInstance().addChangeListener(this.onStoreChange);
}
aboutToDisappear(): void {
StorageService.getInstance().removeChangeListener(this.onStoreChange);
}
aboutToAppear 里第一件事是 this.refresh() 而不是先注册 —— 注册之后立刻收到的第一次通知是多余的,先算一遍再挂监听,保证首帧就有正确数据,之后靠通知。
PoemRowCard 是唯一一个非页面级别的订阅者。它在列表里可能有十几个实例,每个都注册一个监听器:
aboutToAppear(): void {
this.syncFlags();
StorageService.getInstance().addChangeListener(this.onStoreChange);
}
aboutToDisappear(): void {
StorageService.getInstance().removeChangeListener(this.onStoreChange);
}
private syncFlags(): void {
// 直接从单例现取数据,避免包装/拷贝导致读到旧数据
const u: UserState = StorageService.getInstance().userState;
this.masteredFlag = u.isMastered(this.poemId);
this.collectedFlag = u.isCollected(this.poemId);
}
收藏页有 12 首收藏时,changeListeners 数组里会同时有 12 个行组件的监听器加 1 个页面自己的。任何一次数据变更,notifyChange 会依次调 13 个回调,每个行组件重算两个 indexOf。这个量级没问题,但如果列表涨到几百行,就该考虑「页面算一次、结果通过 @Param 下发」而不是「每行自己听」。
@Monitor 的两种写法
@Monitor 监听的是 @Local 或 @Param 属性路径。项目里出现了两种形态。
监听单个标量:
/** 数据版本号变化(StorageService 每次数据变更后集中递增)→ 重算派生数据 */
@Monitor('userState.statsVersion')
onUserStateChanged(): void {
this.refresh();
}
路径是 userState.statsVersion,跨了一层对象。userState 是 @Local 持有的 UserState 实例,statsVersion 是它上面带 @Trace 的字段。V2 允许直接写这种嵌套路径,命中条件是「被监听的那个 @Trace 字段发生变化」—— userState 引用本身变不变无所谓。
监听多个字段:
/** 实时监听收藏/已学会列表变化,同步当前卡片状态(不重置卡片翻转状态) */
@Monitor('userState.collectedIds', 'userState.masteredIds')
onUserStateChanged(): void {
this.syncCardFlags();
}
两个路径写在同一个装饰器里,任一变化都触发。刷卡页和详情页用这种写法,因为它们只关心这两个列表;首页和三个 Tab 用 statsVersion,因为它们关心的是「任何数据变化」。
@Monitor 回调不带参数(也可以带 IMonitor 参数拿到旧值新值,项目里没用)。回调体只做一件事 —— 调 refresh() 或 syncCardFlags()。
刷卡页那处注释里有一句关键信息:「不重置卡片翻转状态」。因为 @Monitor 回调走的是 syncCardFlags(),而 refreshCardState() 才是会重置 showMeaning 的那个:
private syncCardFlags(): void {
if (this.currentIndex < 0 || this.currentIndex >= this.poems.length) {
return;
}
const poem: Poem = this.poems[this.currentIndex];
// 直接从单例现取数据,避免 @Local 包装/拷贝导致读到旧数据
const u: UserState = StorageService.getInstance().userState;
this.isCollected = u.isCollected(poem.id);
this.isMastered = u.isMastered(poem.id);
}
private refreshCardState(): void {
this.syncCardFlags();
this.showMeaning = false;
}
翻页时调 refreshCardState(顺带把卡片翻回正面),数据变化时只调 syncCardFlags(不动翻转状态)。用户在看译文的时候点了收藏,不应该被翻回原文 —— 这个区别靠两个方法分开实现。
一个容易忽略的细节:@Monitor 的回调在数据变化的同一个渲染周期内执行,如果回调里又改了被监听的字段,会形成循环。项目里所有回调都只读不写 userState,没有这个风险。

refreshTick:给「Tab 页不会重建」准备的
MainPage 里有个 @Local refreshTick,两个地方各加一:
onPageShow(): void {
this.refreshTick++;
}
.onChange((idx: number) => {
this.currentTab = idx;
// 切换 Tab 时强制各页重算统计,作为响应式监听之外的兜底刷新通道
this.refreshTick++;
})
三个子页通过 @Param 接它:
@ComponentV2
export struct FavoriteTabPage {
@Param refreshTick: number = 0;
/** 主页面重新显示时(从详情页/刷卡页返回)强制刷新 */
@Monitor('refreshTick')
onPageRefresh(): void {
this.refresh();
}
这条通道解决的是 Tabs 的生命周期问题。MainPage 被 pushUrl 打开后,三个 TabContent 里的子组件会一直存活:切 Tab 不销毁,被别的页面盖住也不销毁。用户从收藏页点进详情页、在详情页取消收藏、再返回,FavoriteTabPage 的 aboutToAppear 不会重新执行 —— 它的生命周期只跟随 MainPage 走。
那为什么还需要 refreshTick?如果 @Monitor('userState.statsVersion') 已经能在取消收藏时触发,返回时列表自然是新的。refreshTick 覆盖的是「响应式监听没覆盖到的派生数据」:FavoriteTabPage.refresh() 里除了读 id 列表,还要把 id 换成 Poem 对象、重新组数组,这一步依赖 PoemRepository 的状态。如果诗集被换过(比如以后支持切换诗集版本),statsVersion 不会变,列表就得靠 onPageShow 兜一次。
onPageShow 是页面级事件,MainPage 每次重新可见都会触发。所以「从详情页返回」这条路径上,refreshTick 一定会加一,三个 Tab 全部重算一遍。
这条通道的代价是重复计算:一次收藏操作会依次触发「syncStats → 回调总线 → 三个 Tab 的 refresh」加「statsVersion 变化 → 三个 Tab 的 refresh」加「返回主页面 → refreshTick → 三个 Tab 的 refresh」。同一次数据变更,refresh 可能被执行三到六次。
没有做去重,因为每次 refresh 都是一次「从单例读数据 + 组数组」,111 首诗的规模下耗时可忽略,而去重需要引入「脏标记 + 帧末合并」,代码量比省下的几次数组拷贝多得多。这是一个明确的取舍:宁可多算几次,也不引入调度器。
@Builder 按值传参收不到更新
项目里三处写了同一条注释,说的是同一件事:
// 统计卡(直接绑定 StorageService 集中维护的派生统计,任何页面变更数据后实时刷新;
// 注意不能用 @Builder 按值传参渲染——@Builder 值参数内部 UI 不随状态变化刷新,会导致统计数字滞留旧值)
// 统计行(直接绑定 userState 集中统计字段;不用 @Builder 按值传参——其内部 UI 不随状态刷新)
// 注意:必须内联直绑 @LocalStorageProp,不能用 @Builder 按值传参
// (@Builder 值参数内部 UI 不随卡片数据推送刷新,会导致数字永远停留在初始 0)
三处说的是同一件事:@Builder 接收的是值参数时,函数体里用这个值渲染的 UI 不会随状态变化刷新。
HomeTabPage 里的统计三卡就是按这条规则写成了三段几乎重复的内联代码:
Row({ space: 10 }) {
Column({ space: 4 }) {
Text('' + this.userState.learnedCount)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(AppColors.PRIMARY)
Text('已学')
.fontSize(12)
.fontColor(AppColors.TEXT_SUB)
}
.layoutWeight(1)
.padding({ top: 12, bottom: 12 })
.backgroundColor(AppColors.CARD_BG)
.borderRadius(AppSizes.CARD_RADIUS)
.shadow({ radius: 4, color: '#0D000000', offsetX: 0, offsetY: 2 })
Column({ space: 4 }) {
Text('' + this.userState.masteredCount)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(AppColors.PRIMARY)
Text('已学会')
.fontSize(12)
.fontColor(AppColors.TEXT_SUB)
}
.layoutWeight(1)
// ...
三个 Column 除了绑的字段和标签文案,样式一字不差。抽成 @Builder statCard(label: string, value: number) 能省七成代码,但那样 value 是值参数,数字会停在首次渲染时的值。
要既复用又实时,没有第三条路:
| 做法 | 能否实时刷新 | 代码量 |
|---|---|---|
| 内联三段直绑 | 能 | 三份重复样式 |
@Builder 按值传参 | 不能 | 一份样式 |
@Builder 里读 this.userState.xxx | 能 | 一份样式,但字段名写死在 Builder 里 |
抽成子组件 + @Param | 能 | 一份样式 + 一个新文件 |
第四种是正解,代价是多一个组件。当前选了第一种,因为三张卡就在同一个 Row 里,重复的只有六行样式。
MineTabPage 的等级卡是另一种情况:它抽成了 @Builder buildLevelCard(),但方法不带参数:
@Builder
buildLevelCard() {
Row({ space: 12 }) {
Column() {
Image(LevelSystem.getLevel(this.userState.masteredCount).badgeIcon)
.width(40)
.height(40)
}
// ...
方法内部直接读 this.userState.masteredCount,没有参数,所以不存在「值参数」的问题,能正常实时刷新。这套 @Builder 的定义就是「一段可以抽出来的 UI 片段」,只要闭包里有 this,就能跟着状态走。
本地快照与单例现取
六个组件里都出现了同一句注释:
// 直接从单例现取数据,避免 @Local 包装/拷贝导致读到旧数据
refresh() 里不是用 this.userState.collectedIds,而是重新 StorageService.getInstance().userState:
refresh(): void {
// 直接从单例现取数据,统一使用 StorageService 的词库校验口径,保证与其他页面/卡片绝对一致
const ids: number[] = StorageService.getInstance().getCollectedPoemIds();
const list: Poem[] = [];
for (let i = 0; i < ids.length; i++) {
const p: Poem | null = PoemRepository.getInstance().getPoemById(ids[i]);
if (p !== null) {
list.push(p);
}
}
this.collected = list;
}
@Local userState 本身就是一个指向单例的引用,理论上 this.userState.collectedIds 和 StorageService.getInstance().userState.collectedIds 是同一个数组。写全称的好处是「不依赖那个 @Local 是否被框架包了一层代理」—— V2 对 @Local 的包装行为在不同版本上有差异,直接拿单例能绕开这类不确定性。
代价是每处都要多写 40 个字符。收益是 refresh() 的行为只取决于单例状态,与组件的 @Local 有没有被初始化、有没有被替换无关。
PoemRowCard 走得更远 —— 它把两个状态复制到自己的 @Local 里:
@Local masteredFlag: boolean = false;
@Local collectedFlag: boolean = false;
这是必要的。父组件 FavoriteTabPage 传下来的是 poemId、title、meta、preview 四个只读字段,没有收藏/已学会状态 —— 如果从 userState 直接读(像 VolumeRowItem 那样用 @Local userState 加方法调用),build 里每次都要 indexOf,而且 ForEach 的 key 不变时子组件不一定重建。
复制到 @Local 之后,syncFlags 在 aboutToAppear 与每次总线通知时更新这两个字段,@Local 变化触发本组件重渲染,与父组件无关。这就是「每个行自己听」的实现代价:多两个字段、多一次注册。
三条通道的适用边界
把三条通道放在一起看,各自的边界很清楚:
| 通道 | 触发者 | 覆盖场景 | 不覆盖 |
|---|---|---|---|
| 回调总线 | notifyDataChange | 任何写操作后立刻同步,包括列表行组件 | 组件未挂载时收不到 |
@Monitor(statsVersion) | syncStats 自增 | 需要「任意变化都重算」的派生数据 | 只监听 userState 内的字段 |
@Monitor(refreshTick) | MainPage 的 onPageShow / onChange | 页面重新可见、切 Tab | 需要父组件持有并传递 |
@Monitor(collectedIds, masteredIds) | 两个数组被整体替换 | 只关心这两个列表的页面 | 其他字段变化时不触发 |
四条(双字段那条算一种写法)组合起来,结果是「几乎没有刷新不到的地方」。代价是同一次变更可能触发多轮重算,以及每个组件都要维护自己的注册/退订配对。
真正要担心的不是性能,而是退订漏了。六个注册点、六个退订点,一一对应,aboutToDisappear 与 aboutToAppear 严格成对。如果哪天新增一个组件只在 aboutToAppear 里注册、忘了退订,表现是「退出页面后内存里的监听器越来越多,每次数据变更都调一次已销毁组件的 refresh」,V2 下通常不报错、只是白跑,很难在测试里发现。
这套机制的最后一块拼图在服务侧:notifyChange 只打一行日志,把监听器数量打出来。这行日志是排查「监听器有没有泄漏」的唯一线索 —— 进出页面几次之后,如果数量只增不减,就说明有一处漏了退订。
更多推荐
所有评论(0)