前言

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

alt text

回调总线:唯一能穿透组件层级的一条

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,没有这个风险。

alt text

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 只打一行日志,把监听器数量打出来。这行日志是排查「监听器有没有泄漏」的唯一线索 —— 进出页面几次之后,如果数量只增不减,就说明有一处漏了退订。

Logo

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

更多推荐