# HarmonyOS 组件冻结怎么用才稳:多页签长列表、统计刷新和唤醒边界怎么拆

ArkUI 页面越做越复杂以后,最容易被忽略的不是某个 for 循环慢,而是页面上很多组件明明没有被用户看到,却还在跟着状态一起刷新。列表、图表、统计面板、二级页签堆在一起时,一个小状态变化可能把一大片 UI 都带着重算,滑动时就会出现掉帧、白块或者按钮响应慢。

自定义组件冻结解决的就是这类问题:组件暂时不需要参与界面刷新时,把它冻住;等用户真正切回来,或者数据到了必须刷新时,再把它唤醒。这个能力适合放在性能优化文章里讲,因为它不是一个装饰接口,而是用来减少无效刷新、控制状态传播范围的。

这篇按排查思路拆:问题怎么发生,怎么复现,哪些地方不能直接冻结,怎么选择冻结边界,最后怎么封装成可以复用的写法。

![ArkUI 组件冻结排查示意](https://i-blog.csdnimg.cn/direct/82916ea6d4bd4cf39f3f9cb623adf2a0.png)

## 先看问题:页面没显示,为什么还在刷新

先做一个很常见的页面:上面是 Tabs,下面每个页签里都是长列表。第一个页签显示菜谱列表,第二个页签显示收藏,第三个页签显示统计。用户停在第一个页签时,后两个页签其实看不见。

如果把状态都放在父页面里,再把同一个统计对象传给每个子组件,就会出现一个问题:只要收藏数量、筛选条件、加载状态发生变化,几个页签里的组件都会收到状态变动。即使当前只显示第一个页签,隐藏页签也可能继续参与构建、计算和刷新。

小页面看不出来,大页面就明显了:

- 切换页签时有一瞬间卡顿;
- 长列表滚动时偶尔掉帧;
- 统计面板没打开,里面的计算却一直在跑;
- 某个状态只影响当前页,却把兄弟页签也带着刷新。

这个时候不要先急着怀疑列表组件。更应该先问一句:当前状态变化到底需要刷新哪些组件?哪些组件只是被顺手带上了?

## 复现场景一:隐藏页签被无意义刷新

下面这个例子故意写得比较直接。父页面维护 selectedIndex 和 stats,三个页签都能拿到 stats。每次列表里点击收藏,stats.favoriteCount 增加。问题是,统计页签虽然没展示,也会因为 stats 变化被带着刷新。

代码结构可以先这么看:

    @Observed
    class PageStats {
      favoriteCount: number = 0;
      viewedCount: number = 0;
      updatedAt: number = Date.now();
    }

    @Component
    struct RecipeTabPage {
      @State selectedIndex: number = 0;
      @State stats: PageStats = new PageStats();

      build() {
        Column() {
          Tabs({ index: this.selectedIndex }) {
            TabContent() { RecipeList({ stats: this.stats }) }.tabBar("菜谱")
            TabContent() { FavoriteList({ stats: this.stats }) }.tabBar("收藏")
            TabContent() { StatsPanel({ stats: this.stats }) }.tabBar("统计")
          }
          .onChange((index: number) => { this.selectedIndex = index; })
        }
      }
    }

这段代码功能没错,但性能边界不清楚。RecipeList 改了收藏数,StatsPanel 立刻响应;FavoriteList 可能也跟着响应。用户没有打开这些页签时,这些响应大部分就是无效刷新。

组件冻结适合放在这里:不是冻结整页,也不是冻结数据,而是冻结“当前不可见、暂时不需要响应 UI 刷新”的子组件。

可以先包一层 FreezeTabContent:

    @Component
    struct FreezeTabContent {
      @Prop active: boolean;
      @BuilderParam content: () => void;

      build() {
        Column() {
          this.content()
        }
        .freeze(!this.active)
      }
    }

使用时把每个页签包进去:

    TabContent() {
      FreezeTabContent({ active: this.selectedIndex === 0 }) {
        RecipeList({ stats: this.stats })
      }
    }.tabBar("菜谱")

    TabContent() {
      FreezeTabContent({ active: this.selectedIndex === 1 }) {
        FavoriteList({ stats: this.stats })
      }
    }.tabBar("收藏")

这里的重点不是把代码变花,而是把刷新边界说清楚:当前页签 active,就允许刷新;非当前页签 inactive,就先冻结,别让它跟着每次状态变化一起跑。

## 复现场景二:统计面板计算太重,切回来又要保持结果正确

第二个问题更容易踩坑:统计面板不是普通文本,它可能要做分组、排序、TopN、空状态判断,还要显示最近更新时间。如果冻结之后直接不管,用户切回统计页时看到旧数据,就变成另一个问题。

所以冻结不是“永远不刷新”,而是“不可见时少刷新,可见时补一次正确刷新”。这个边界要写在代码里。

    @Observed
    class StatsSnapshot {
      total: number = 0;
      favorite: number = 0;
      topCategory: string = "";
      version: number = 0;
    }

    @Component
    struct StatsPanel {
      @ObjectLink snapshot: StatsSnapshot;
      @Prop active: boolean;
      @State localVersion: number = -1;
      @State displayText: string = "";

      aboutToAppear() {
        this.syncIfNeeded();
      }

      private syncIfNeeded() {
        if (this.localVersion === this.snapshot.version) {
          return;
        }
        this.localVersion = this.snapshot.version;
        this.displayText = "共 " + this.snapshot.total + " 条,收藏 " + this.snapshot.favorite + " 条,最多分类:" + this.snapshot.topCategory;
      }

      build() {
        Column({ space: 8 }) {
          Text(this.displayText)
          Text("版本:" + this.localVersion)
        }
        .freeze(!this.active)
        .onVisibleAreaChange([0.0, 1.0], (_: boolean, ratio: number) => {
          if (ratio > 0) {
            this.syncIfNeeded();
          }
        })
      }
    }

这个例子里有三个保护点:用 active 控制冻结,不让隐藏组件一直刷新;用 version 判断统计快照是否真的变化;组件重新可见时补一次 sync,避免切回来看到旧结果。

## 几种方案怎么选

第一种是不做冻结,只做拆组件。这个方案最简单,适合页面很小、状态很少的场景。缺点也明显:当页面继续长大,状态传播范围还是会越来越乱。

第二种是把所有状态拆到各个子组件里。它能减少父组件刷新,但会带来同步问题。比如统计页需要知道列表和收藏页的变化,状态拆得太碎以后,反而要写很多事件和回调。

第三种是保留清晰的数据源,但把不可见组件冻结起来。这个方案更适合多页签、多列表、多统计面板的页面。数据仍然从一个地方来,UI 刷新边界单独控制。

我的选择一般是:数据边界先理清,冻结只管 UI 刷新边界。不要把冻结当成状态管理方案,它是性能优化手段,不是数据一致性方案。

## 可以封装成什么

如果项目里有很多 Tabs、Swiper、Navigation 子页面,可以封装一个统一的 FreezeBoundary。

    @Component
    export struct FreezeBoundary {
      @Prop active: boolean;
      @Prop name: string = "";
      @BuilderParam content: () => void;

      build() {
        Column() {
          this.content()
        }
        .freeze(!this.active)
      }
    }

使用时只关心当前页面是否活跃。这样以后排查性能时也更清楚:先看状态是不是拆对,再看 FreezeBoundary 的 active 是否准确,最后看组件重新可见时有没有补同步。

## 验证时不要只看能不能跑

组件冻结看起来像一个简单开关,但验证不能只看页面有没有报错。我会至少看四件事:当前页签操作时隐藏页签不要频繁刷新;切回隐藏页签时展示的数据必须是最新的;快速切换页签时不要出现旧状态闪一下再更新;长列表滑动时冻结前后的掉帧和白块情况要对比。

如果项目里接了性能分析工具,还可以对比状态变化时的组件刷新次数。没有工具时,也可以先用日志记录组件 build 次数,确认隐藏组件没有被无意义唤醒。

## 最后总结一下

组件冻结适合解决“不可见组件被状态变化拖着刷新”的问题。它真正有价值的地方,不是少写几行代码,而是让页面刷新边界变得可控。

我会按这个顺序处理:先拆清楚数据源,再找出当前不可见但还在刷新的组件,然后用 active 做冻结边界,最后在组件重新可见时补一次同步。这样写出来的页面,不只是跑得动,也更容易解释为什么快、为什么不会丢状态。

Logo

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