组件冻结与按需加载:HarmonyOS 6.0 性能优化的核心手段

组件冻结与按需加载:HarmonyOS 6.0 性能优化的核心手段

做鸿蒙应用做到中后期,性能问题基本都会绕回一个主题:你到底创建了多少组件,又有多少是真正需要渲染的?

这个问题看起来简单,但实际项目里的情况往往是这样:一个首页四五个Tab,每个Tab里塞了列表、图表、视频,用户只看了一个Tab,其他Tab的组件全挂在树上;一个长列表上千条数据,ForEach一口气全渲染了,内存直接飙到几百兆;一个弹窗频繁开关,每次if重建整棵子树,帧率掉到三十多。

这些问题的根源都是同一个:不该创建的组件创建了,不该刷新的组件刷新了。解决思路也只有一个:按需加载,按需刷新,该冻结的冻结。

这篇文章就把HarmonyOS 6.0里围绕"按需"这条线的所有核心机制串一遍,从Tabs懒加载到LazyForEach,从cachedCount调优到@Reusable复用,从三种隐藏方式对比到组件冻结实践,全部用代码说清楚。


一、Tabs懒加载:非活跃TabContent默认不渲染

先说最常见的场景:底部导航栏 + 多Tab页面。

很多开发者以为Tabs会一次性把所有TabContent都创建出来,其实不是。ArkUI的Tabs组件自带懒加载机制------只有当前Tab和相邻的Tab会被预创建,其他Tab在用户切换过去之前根本不会走build流程。

看个基本用法:

typescript 复制代码
@Entry
@Component
struct MainTabsPage {
  @State currentIndex: number = 0
  private tabsController: TabsController = new TabsController()

  build() {
    Column() {
      Tabs({ index: this.currentIndex, controller: this.tabsController }) {
        TabContent() {
          HomePage()
        }
        .tabBar('首页')

        TabContent() {
          DiscoverPage()
        }
        .tabBar('发现')

        TabContent() {
          ProfilePage()
        }
        .tabBar('我的')
      }
      .onChange((index: number) => {
        this.currentIndex = index
      })
    }
  }
}

这段代码里,如果用户在"首页",只有"首页"和"发现"两个TabContent会被创建,"我的"这个TabContent在用户点过去之前不会执行build。这就是默认的懒加载行为。

但这里有个细节要注意:如果你在TabContent里用了条件判断来控制是否渲染子组件,比如:

typescript 复制代码
TabContent() {
  if (this.currentIndex === 1) {
    DiscoverPage()
  }
}

这种方式是有问题的。因为TabContent本身已经会被创建,里面的if判断只是控制子组件的创建,而且这个判断会导致每次切换Tab时整棵子树重建。正确做法是让TabContent直接包含子组件,依赖Tabs自身的懒加载机制。

如果你想要更精细的控制,比如预加载特定Tab,可以用preloadItems:

typescript 复制代码
Tabs({ controller: this.tabsController }) {
  // ... TabContent列表
}
.onAppear(() => {
  try {
    this.tabsController.preloadItems([1])
  } catch (e) {
    hilog.error(0x0000, 'MainTabs', 'preload failed')
  }
})

注意,preloadItems必须在Tabs和TabsController绑定之后调用。在aboutToAppear里调用是不行的,因为那时候Tabs还没完成渲染。onAppear或onDidBuild才是正确的时机。

还有一个容易忽视的参数:cachedCount。Tabs也有cachedCount,它控制的是当前Tab左右两侧额外缓存的Tab数量:

typescript 复制代码
Tabs() {
  // ... TabContent列表
}
.cachedCount(2)

设置cachedCount(2)意味着当前Tab + 左右各2个Tab都会被缓存。如果总Tab数不多(比如5个以内),可以设置一个较大的cachedCount让所有Tab都常驻内存,这样切换时完全零延迟。


二、LazyForEach可视区渲染:只创建可见区域的组件

ForEach和LazyForEach的区别,一句话就能说清:ForEach一次性把所有子组件都创建出来,LazyForEach只创建可视区域内的子组件。

对于短列表,ForEach没问题。但数据量一大,ForEach就是性能灾难。我见过一个项目用ForEach渲染2000条新闻列表,首屏直接卡了3秒,内存吃了400多兆。换成LazyForEach后,首屏时间降到500毫秒以内,内存减少60%以上。

LazyForEach的基本用法需要一个实现了IDataSource的数据源:

typescript 复制代码
class NewsDataSource implements IDataSource {
  private newsList: NewsItem[] = []
  private listeners: DataChangeListener[] = []

  totalCount(): number {
    return this.newsList.length
  }

  getData(index: number): NewsItem {
    return this.newsList[index]
  }

  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) {
      this.listeners.push(listener)
    }
  }

  unregisterDataChangeListener(listener: DataChangeListener): void {
    const pos = this.listeners.indexOf(listener)
    if (pos >= 0) {
      this.listeners.splice(pos, 1)
    }
  }

  pushData(item: NewsItem): void {
    this.newsList.push(item)
    this.listeners.forEach((listener: DataChangeListener) => {
      listener.onDataAdd(this.newsList.length - 1)
    })
  }

  reloadData(): void {
    this.listeners.forEach((listener: DataChangeListener) => {
      listener.onDataReloaded()
    })
  }
}

然后在List里使用:

typescript 复制代码
List({ space: 12 }) {
  LazyForEach(this.newsDataSource, (item: NewsItem) => {
    ListItem() {
      NewsCard({ item: item })
    }
  }, (item: NewsItem) => item.id)
}

这里有几个关键点:

LazyForEach必须放在支持懒加载的容器里。目前只有List、Grid、Swiper、WaterFlow这四个组件支持。如果你把LazyForEach放在Column或Row里,它还是会一次性加载所有数据,等于白用。

keyGenerator必须返回稳定且唯一的值。上面代码里用item.id做key,这没问题。如果你用index做key,数据增删时key会变,导致组件重建,性能反而更差。

数据变化时不能靠重新赋值dataSource来刷新。必须通过DataChangeListener的通知方法来更新UI。这是一个特别常见的坑------很多人以为把数据源重新赋值给@State变量就能触发刷新,实际上LazyForEach不支持这种方式。

LazyForEach在每次迭代中只能创建一个子组件。也就是说,生成函数里只能有一个根组件,不能有并列的多个组件。


三、cachedCount调优:预渲染上下N个条目,平衡流畅度和内存

LazyForEach配合的cachedCount是一个需要认真调优的参数。它的作用是在可视区域之外额外预渲染N个条目,这样用户滚动时预渲染的条目已经准备好了,不会出现白块。

typescript 复制代码
List({ space: 8 }) {
  LazyForEach(this.dataSource, (item: ItemData) => {
    ListItem() {
      ComplexCard({ data: item })
    }
  }, (item: ItemData) => item.key)
}
.cachedCount(3)

cachedCount设多大合适?这取决于你的列表项复杂度和用户滚动速度:

  • 如果列表项比较简单(纯文本),cachedCount(1)或cachedCount(2)就足够了
  • 如果列表项里有图片、阴影等较重的内容,建议cachedCount(3)到cachedCount(5)
  • 如果用户会快速滚动(比如聊天记录),可以适当增大

但cachedCount不是越大越好。每多缓存一个条目,就意味着多占用一份组件实例的内存。如果你的列表项包含大图,cachedCount设10可能额外吃掉几十兆内存。

实际项目里的调优策略是这样的:先用cachedCount(1)起步,用Profiler看滚动时的帧率。如果有掉帧,逐步增大到2、3、5,直到帧率稳定在60fps。如果内存压力变大,就往回调。这是一个需要实测的过程,没有万能公式。

还需要注意一个细节:Grid的cachedCount和List的cachedCount行为略有不同。Grid是行列结构,cachedCount影响的是行数。如果你的Grid是4列布局,cachedCount(1)意味着额外预渲染1行(4个条目),而不是1个条目。


四、@Reusable组件回收复用:aboutToRecycle/aboutToReuse生命周期

LazyForEach解决了"不该创建的组件不创建"的问题,但还有一个问题:当列表项滑出可视区域后,组件会被销毁;用户再滑回来时,又要重新创建。如果列表项比较复杂,这个创建销毁的开销也不小。

@Reusable就是为了解决这个问题。它让组件在被移除时不销毁,而是进入复用缓存池。下次需要创建同类组件时,直接从缓存池取出来复用,省掉了组件创建的开销。

typescript 复制代码
@Reusable
@Component
struct NewsCard {
  @State item: NewsItem = new NewsItem()

  aboutToRecycle(): void {
    hilog.info(0x0000, 'NewsCard', 'aboutToRecycle called')
  }

  aboutToReuse(params: Record<string, Object>): void {
    const itemParam = params['item']
    if (itemParam instanceof NewsItem) {
      this.item = itemParam
    }
    hilog.info(0x0000, 'NewsCard', 'aboutToReuse called')
  }

  build() {
    Column() {
      Text(this.item.title)
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
      Text(this.item.summary)
        .fontSize(14)
        .fontColor('#666666')
        .maxLines(2)
    }
    .padding(12)
  }
}

aboutToRecycle在组件从组件树上移除并进入缓存池时触发,aboutToReuse在组件从缓存池取出重新挂到组件树时触发。你可以在aboutToRecycle里做一些资源释放的工作(比如取消定时器、释放图片资源),在aboutToReuse里重新初始化。

这里有一个重要细节:aboutToReuse接收的参数类型是Record<string, Object>,而不是你传入的原始类型。所以需要从params里取出来后做类型判断再赋值,不能直接用。

还有一个常见的坑:@Reusable装饰的组件内部如果有@State变量,aboutToReuse里给@State变量赋值后,UI会自动刷新。但如果变量不是@State装饰的,赋值后不会触发刷新。所以确保需要响应式更新的变量都用了@State。

另外,@Reusable不支持嵌套使用。如果你在@Reusable组件A里又嵌套了一个@Reusable组件B,它们会各自维护独立的缓存池,B的aboutToRecycle和aboutToReuse可能无法成对调用,生命周期管理会出问题。


五、reuseId分组回收池:同类型组件分池管理,避免类型不匹配

当一个列表里有多种不同布局的卡片时,如果只加@Reusable不加reuseId,所有类型的卡片会共用同一个缓存池。这会导致一个问题:从池子里取出来的组件可能和你需要的布局不匹配。

比如一个新闻列表里有单图卡、三图卡、视频卡三种类型,它们都标记了@Reusable。当用户滚动时,一个单图卡被回收进了池子,下一个需要渲染的是视频卡,框架从池子里取出了那个单图卡------布局结构对不上,框架只能销毁它再重新创建,复用完全失效。

reuseId就是来解决这个问题的。它给不同类型的组件分配不同的缓存池:

typescript 复制代码
List({ space: 12 }) {
  LazyForEach(this.dataSource, (item: NewsItem) => {
    ListItem() {
      NewsCard({ item: item }).reuseId(item.type)
    }
  }, (item: NewsItem) => item.id)
}

这里的reuseId用的是item.type(比如'single_image'、'three_image'、'video'),这样框架会为每种type维护独立的缓存池,单图卡只和单图卡复用,视频卡只和视频卡复用。

如果不用if/else区分布局,而是把不同类型的卡片做成不同的组件,那每个组件天然就是独立的类型,reuseId可以直接用组件名:

typescript 复制代码
@Reusable
@Component
struct SingleImageCard {
  @State item: NewsItem = new NewsItem()
  // ...
}

@Reusable
@Component
struct ThreeImageCard {
  @State item: NewsItem = new NewsItem()
  // ...
}

@Reusable
@Component
struct VideoCard {
  @State item: NewsItem = new NewsItem()
  // ...
}

然后在List里根据type条件渲染不同组件,每种组件自动有独立的缓存池。

但这里也有一个度的问题:reuseId分类不能过细。如果你的列表有10种type,每种type的缓存池都会占用内存,但大部分type的卡片可能很少出现,池子里囤了一堆用不上的实例。合理的做法是把布局结构相似的type合并,只在布局结构真正不同时才用不同的reuseId。


六、三种隐藏方式对比:if条件渲染 vs Visibility.Hidden vs Visibility.None

控制组件显示隐藏是日常开发最常见的操作之一,HarmonyOS 6.0提供了三种方式,它们的底层行为完全不同,用错了会直接影响性能。

if条件渲染

typescript 复制代码
if (this.showPanel) {
  PanelView()
}

if控制的是组件的创建和销毁。条件为false时,整棵子树不会创建------不创建、不布局、不绘制,也不占位。条件变为true时,整棵子树从头创建。

这意味着每次条件切换,都是一次完整的组件树销毁+重建。如果子树很复杂(比如包含大量Image、List),切换时的Measure和Layout开销会很大。

但反过来看,条件为false时内存占用为零,因为组件根本不存在。所以if适合那些不频繁切换的场景,比如详情页的展开区域、设置项的高级选项------用户点一次展开,可能很久都不会收起。

Visibility.Hidden

typescript 复制代码
Column() {
  Text('标题')
  PanelView()
    .visibility(this.showPanel ? Visibility.Visible : Visibility.Hidden)
}

Visibility.Hidden的效果是:组件不绘制,但保留布局占位。也就是说,组件在组件树上依然存在,Measure和Layout都正常执行,只是不画出来。

Hidden的典型场景是需要占位但暂时不显示的元素,比如表格里某个单元格的内容暂时隐藏,但布局不能塌掉。实际开发中用Hidden的情况不多,因为大多数场景要么需要占位但可见(用Visible),要么不需要占位(用None或if)。

Visibility.None

typescript 复制代码
Column() {
  Text('标题')
  PanelView()
    .visibility(this.showPanel ? Visibility.Visible : Visibility.None)
}

Visibility.None的效果是:组件不显示也不占位。和Hidden的区别在于,None不参与Layout计算,不会在页面上占据空间。

None和if看起来效果很像,但底层机制不同:

  • if:控制组件是否创建。条件为false时组件不存在于组件树上。
  • None:组件始终存在于组件树上,只是不参与布局和绘制。

这意味着None切换时不需要重新创建组件,切换速度比if快得多。但组件始终占据内存,因为实例一直存在。

三种方式的性能对比

为了更直观地理解差异,我整理了一个对比表:

维度 if条件渲染 Visibility.Hidden Visibility.None
条件为false时是否创建组件
条件为false时是否占位
条件为false时是否参与Layout
切换时是否重建组件树
切换时的Measure/Layout开销 大(重建) 极小(只改绘制) 小(跳过Layout)
条件为false时内存占用 正常 正常
状态是否保留 否(重建后重置)

从实测数据看(100个Image组件的Column容器),切换显示状态时:

  • if方式:Measure约3ms,Layout约1.6ms,组件创建约13ms
  • visibility方式:Measure约0.1-0.2ms,Layout约0.07-0.3ms,无组件创建

差距非常明显。频繁切换时,if的组件重建开销会成为瓶颈。

选择策略

总结成一句话:不频繁切换用if省内存,频繁切换用visibility省性能。

具体来说:

  • if:适合不频繁切换的场景(如详情展开、表单的更多选项),或者组件创建开销大且只在特定条件下出现的场景
  • Visibility.None:适合频繁切换的场景(如Tab切换、筛选条件显示隐藏),组件始终在树上但按需显示
  • Visibility.Hidden:适合需要占位但不绘制的场景(如表格单元格内容隐藏但布局不能变)

七、if整棵子树销毁重建的深入分析

前面说了if的宏观行为,这里再深入一点讲讲if为什么在某些场景下会成为性能杀手。

if的本质是条件分支渲染。当条件从false变为true时,ArkUI需要:

  1. 创建整棵子树的所有组件实例
  2. 执行所有组件的aboutToAppear生命周期
  3. 对整棵子树做Measure计算每个组件的尺寸
  4. 对整棵子树做Layout计算每个组件的位置
  5. 执行绘制

当条件从true变为false时,ArkUI需要:

  1. 执行所有组件的aboutToDisappear生命周期
  2. 销毁整棵子树的所有组件实例
  3. 释放所有@State变量的监听

如果你的if包裹了一个包含50个子组件的面板,每次切换就是50个组件的创建+50个Measure+50个Layout+50个绘制,或者50个销毁。这在60fps的要求下(每帧16.6ms)是很重的负担。

更隐蔽的问题是状态丢失。if销毁子树时,子树内的所有@State变量都会重置。用户在面板里输入了一堆内容,把面板收起来再展开,之前输入的东西全没了。很多人第一反应是用@Link或AppStorage来保存状态,但这只是绕过了问题,没有解决根本的开销。

所以如果你的场景是:用户可能频繁开关某个面板,或者面板里有表单输入需要保留状态,那if就不是合适的选择。


八、Hidden保留布局占位但不绘制的适用场景

Visibility.Hidden的实际应用场景比较有限,但在某些特定布局里确实不可替代。

最常见的场景是:你需要一个元素暂时不可见,但它必须在布局中占位,否则其他元素的位置会跳动。

比如一个水平排列的按钮组:

typescript 复制代码
Row() {
  Button('确认')
    .visibility(this.canConfirm ? Visibility.Visible : Visibility.Hidden)
  Button('取消')
}

如果用if控制"确认"按钮,当this.canConfirm为false时,按钮不存在,"取消"按钮会跳到左边。如果用Visibility.None,同样不占位,也会跳动。只有Visibility.Hidden能让"确认"按钮看不见但占着位置,"取消"按钮始终在右边。

但这种场景其实不多。大多数情况下,UI设计的意图要么是"有就显示没有就消失"(用if或None),要么是"始终在但颜色/透明度变化"(用透明度控制)。Hidden更多是一种布局层面的需求而非业务层面的需求。


九、None不显示不占位但保留状态

Visibility.None是if的一个很好的替代方案,特别是在需要频繁切换的场景下。

typescript 复制代码
@Component
struct FilterPanel {
  @State showFilter: boolean = false
  @State keyword: string = ''
  @State selectedCategory: number = 0

  build() {
    Column() {
      Button('筛选')
        .onClick(() => {
          this.showFilter = !this.showFilter
        })

      Column() {
        TextInput({ placeholder: '输入关键词', text: this.keyword })
          .onChange((value: string) => {
            this.keyword = value
          })
        // ... 更多筛选项
      }
      .visibility(this.showFilter ? Visibility.Visible : Visibility.None)
      .padding(16)
    }
  }
}

这个例子中,筛选面板用Visibility.None控制显隐。用户输入了关键词然后关闭面板,再打开时关键词还在------因为组件实例一直在树上,@State变量没有被重置。

如果换成if,每次关闭面板组件都会被销毁,keyword会被重置为空字符串,用户体验会很差。

但None也不是没有代价。组件实例始终存在,意味着它始终占用内存。如果这个面板里有大量图片或复杂子组件,即使不可见也会吃内存。所以如果面板很重且很少使用,还是if更合适。

这又回到了那个核心权衡:内存和性能之间的取舍。if省内存但切换慢且丢状态,None占内存但切换快且保状态。根据具体场景选择。


十、组件冻结实践:列表页冻结、Tab页冻结、后台页面冻结

前面讲的按需加载和按需显隐都是在"减少组件数量"这个维度上做优化。组件冻结则是在另一个维度上做优化:减少不必要的组件刷新

组件冻结的原理是通过设置freezeWhenInactive为true,让处于非激活状态的自定义组件不响应状态变量引起的UI刷新。注意,冻结不等于不更新,而是延迟更新------当组件重新激活时,会批量处理之前积压的更新。

列表页冻结

当LazyForEach配合cachedCount使用时,cachedCount范围内的组件即使不在可视区也会被保留。如果这些组件监听了全局状态变量(比如选择模式、筛选条件),每次状态变化时它们也会刷新,这是完全无意义的。

给这些组件加上freezeWhenInactive就能避免这种情况:

typescript 复制代码
@Component({ freezeWhenInactive: true })
struct GalleryItem {
  @State imageItem: ImageInfo = new ImageInfo()
  @Prop isSelectedMode: boolean = false

  build() {
    Stack() {
      Image(this.imageItem.icon)
        .width('100%')
        .height('100%')
        .objectFit(ImageFit.Cover)
      if (this.isSelectedMode) {
        Checkbox()
          .select(this.imageItem.isSelected)
      }
    }
  }
}

当GalleryItem在cachedCount范围内但不在可视区时,isSelectedMode的变化不会触发它的刷新。只有当它重新进入可视区时,才会一次性应用所有积压的状态变化。

官方测试数据显示,在2000张图片的Gallery场景下,切换选择模式时:

  • 不使用组件冻结:全部cachedCount范围内的组件都刷新,耗时约180ms
  • 使用组件冻结:只有可视区内的组件刷新,耗时约35ms

差距超过5倍。

Tab页冻结

对于Tabs中的非活跃TabContent,同样可以冻结:

typescript 复制代码
@Component({ freezeWhenInactive: true })
struct DiscoverPage {
  @State dataList: DiscoverItem[] = []
  @State filterKeyword: string = ''

  aboutToAppear(): void {
    this.loadData()
  }

  loadData(): void {
    // 加载数据
  }

  build() {
    Column() {
      TextInput({ placeholder: '搜索', text: this.filterKeyword })
        .onChange((value: string) => {
          this.filterKeyword = value
        })
      List() {
        // 列表内容
      }
    }
  }
}

当用户在"首页"Tab时,"发现"Tab里的DiscoverPage处于非激活状态。如果此时filterKeyword在外部被修改(比如全局搜索同步),DiscoverPage不会刷新。等用户切回"发现"Tab时,才会执行刷新。

后台页面冻结

在Navigation路由场景下,当页面A通过router.pushUrl跳转到页面B时,页面A变成不可见的后台页面。如果此时页面A的状态变量发生了变化,它依然会刷新------虽然用户看不到。

typescript 复制代码
@Component({ freezeWhenInactive: true })
struct OrderListPage {
  @State orderList: OrderItem[] = []
  @Watch('onFilterChange') filterStatus: number = 0

  onFilterChange(): void {
    this.loadOrders()
  }

  loadOrders(): void {
    // 重新加载订单列表
  }

  build() {
    // ...
  }
}

加上freezeWhenInactive后,当OrderListPage在Navigation栈中处于非顶部时,filterStatus的变化不会触发onFilterChange回调,也不会刷新UI。当用户pop回来时,积压的更新才会执行。

这里有一个需要注意的点:冻结是延迟更新而非丢弃更新。pop回来时,所有积压的状态变化会一次性触发,如果积压的更新量很大,pop操作本身可能会变慢。所以冻结适合"状态变化不频繁"的后台页面,如果后台页面的状态每秒都在变,解冻时的批量更新可能比持续更新更卡。

另外,freezeWhenInactive的值只支持常量,不支持变量。你不能写freezeWhenInactive: this.shouldFreeze这种动态控制,它必须在编译期确定。

还有一个容易混淆的概念:组件的激活状态和可见状态不是一回事。比如Stack里堆叠的两个组件,上面那个遮挡了下面那个,下面的组件不可见但仍然是激活状态。freezeWhenInactive冻结的是"非激活"状态的组件,不是"不可见"的组件。Stack里被遮挡的组件不会被冻结。


实战:一个完整的性能优化案例

把上面的知识点串起来,看一个完整的优化案例。假设我们要做一个电商首页,结构是:顶部Tab(推荐/关注/直播),推荐Tab里是长列表,每个列表项可能是单图卡、双图卡或视频卡。

typescript 复制代码
// 数据源
class ProductDataSource implements IDataSource {
  private products: ProductItem[] = []
  private listeners: DataChangeListener[] = []

  totalCount(): number {
    return this.products.length
  }

  getData(index: number): ProductItem {
    return this.products[index]
  }

  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) {
      this.listeners.push(listener)
    }
  }

  unregisterDataChangeListener(listener: DataChangeListener): void {
    const pos = this.listeners.indexOf(listener)
    if (pos >= 0) {
      this.listeners.splice(pos, 1)
    }
  }

  pushData(item: ProductItem): void {
    this.products.push(item)
    this.listeners.forEach((listener: DataChangeListener) => {
      listener.onDataAdd(this.products.length - 1)
    })
  }
}

// 冻结的推荐页
@Component({ freezeWhenInactive: true })
struct RecommendPage {
  private dataSource: ProductDataSource = new ProductDataSource()
  private scroller: Scroller = new Scroller()

  aboutToAppear(): void {
    this.loadProducts()
  }

  loadProducts(): void {
    // 加载商品数据
  }

  build() {
    List({ space: 12, scroller: this.scroller }) {
      LazyForEach(this.dataSource, (item: ProductItem) => {
        ListItem() {
          ProductCard({ item: item }).reuseId(item.cardType)
        }
      }, (item: ProductItem) => item.id)
    }
    .cachedCount(3)
  }
}

// 冻结的关注页
@Component({ freezeWhenInactive: true })
struct FollowPage {
  @State followList: FollowItem[] = []

  build() {
    // ...
  }
}

// 冻结的直播页
@Component({ freezeWhenInactive: true })
struct LivePage {
  @State liveList: LiveItem[] = []

  build() {
    // ...
  }
}

// 可复用的商品卡片
@Reusable
@Component
struct ProductCard {
  @State item: ProductItem = new ProductItem()

  aboutToRecycle(): void {
    // 释放图片资源等
  }

  aboutToReuse(params: Record<string, Object>): void {
    const itemParam = params['item']
    if (itemParam instanceof ProductItem) {
      this.item = itemParam
    }
  }

  build() {
    Column() {
      if (this.item.cardType === 'single') {
        SingleImageView({ item: this.item })
      } else if (this.item.cardType === 'double') {
        DoubleImageView({ item: this.item })
      } else {
        VideoView({ item: this.item })
      }
      Text(this.item.title)
        .fontSize(14)
        .maxLines(2)
      Text(this.item.price)
        .fontSize(16)
        .fontColor('#FF4444')
    }
    .padding(8)
  }
}

// 主页
@Entry
@Component
struct MallHomePage {
  @State currentTab: number = 0
  private tabsController: TabsController = new TabsController()

  build() {
    Column() {
      Tabs({ index: this.currentTab, controller: this.tabsController }) {
        TabContent() {
          RecommendPage()
        }
        .tabBar('推荐')

        TabContent() {
          FollowPage()
        }
        .tabBar('关注')

        TabContent() {
          LivePage()
        }
        .tabBar('直播')
      }
      .cachedCount(1)
      .onChange((index: number) => {
        this.currentTab = index
      })
    }
  }
}

这个案例综合运用了以下优化手段:

  1. Tabs懒加载:非活跃Tab默认不渲染
  2. Tabs cachedCount(1):额外缓存1个相邻Tab,切换更流畅
  3. freezeWhenInactive:三个子页面都加了冻结,切换Tab时不刷新后台页
  4. LazyForEach:推荐页长列表按需渲染
  5. cachedCount(3):List预渲染3个额外条目,滚动更顺滑
  6. @Reusable:ProductCard可复用,避免重复创建
  7. reuseId(item.cardType):不同卡片类型分池管理,复用更精准

这套组合拳打下来,首页的内存占用和帧率表现会有质的提升。


常见陷阱与最佳实践

陷阱 问题描述 正确做法
LazyForEach放在Column/Row中 不在支持懒加载的容器中,LazyForEach仍会一次性加载全部 必须放在List/Grid/Swiper/WaterFlow中
用index做key 数据增删后key不稳定,导致组件重建 用数据中的唯一标识做key
重新赋值dataSource刷新LazyForEach 重新赋值不会触发UI更新 使用DataChangeListener的onDataReloaded等方法
@Reusable组件嵌套@Reusable组件 各自独立缓存池,生命周期可能不成对调用 只在最外层用@Reusable,内部子组件不要加@Reusable
reuseId分类过细 每种type一个缓存池,内存浪费且复用率低 合并布局相似的type,只在结构不同时才分池
频繁切换用if 每次切换都重建子树,帧率下降 频繁切换用Visibility.None
不频繁切换用None 组件始终在树上占内存 不频繁显示的重UI用if,省内存
在aboutToAppear中调用preloadItems 此时Tabs未完成渲染,预加载失效 在onAppear或onDidBuild中调用
冻结=不更新 误以为冻结后状态变化会被丢弃 冻结是延迟更新,解冻时会批量刷新
freezeWhenInactive用变量赋值 只支持常量,写变量编译报错 直接写true或false常量
Hidden当None用 Hidden会占位,导致布局出现空白 不需要占位用None,需要占位才用Hidden
cachedCount设置过大 预渲染太多条目,内存压力增大 根据列表项复杂度实测调优,一般1-5
if控制TabContent内子组件 在TabContent里用if判断当前Tab才渲染 直接放子组件,依赖Tabs自身懒加载
aboutToReuse直接取值不做类型判断 params类型是Record<string, Object> 取出后用instanceof判断类型再赋值
后台页面状态频繁变化还加冻结 解冻时批量更新可能比持续更新更卡 状态变化不频繁的后台页面才适合冻结

总结

组件冻结与按需加载不是一个独立的API,而是一套组合拳。它的核心思想就两条:不该创建的别创建,不该刷新的别刷新。

  • 不该创建的别创建:Tabs懒加载控制Tab粒度,LazyForEach控制列表项粒度,if控制组件块粒度
  • 不该刷新的别刷新:freezeWhenInactive冻结非激活组件,cachedCount合理预渲染减少滚动时的创建

再加上@Reusable和reuseId解决组件复用问题,三种visibility控制显隐策略,基本上覆盖了HarmonyOS 6.0应用性能优化的所有核心手段。

这些手段不需要全部用上,但你需要知道每个手段解决什么问题、适合什么场景,在遇到具体性能问题时能选对方案。性能优化从来不是"加上某个配置就好了",而是理解原理后对症下药。

相关推荐
程序员黑豆2 小时前
鸿蒙应用开发实战:从零学会自定义组件
前端·华为·harmonyos
qizayaoshuap5 小时前
# [特殊字符] 名言警句 — 鸿蒙ArkTS数据管理与随机展示系统
华为·harmonyos
FrameNotWork5 小时前
HarmonyOS 6.0 LocalStorage页面级存储
华为·交互·harmonyos
FF2501_940228585 小时前
Grid 构建月历网格:7 列模板 + 日期占位算法
后端·华为·harmonyos·鸿蒙系统
FrameNotWork5 小时前
HarmonyOS 6.0 自定义指令与手势组合:从单指到多指的交互进阶
华为·交互·harmonyos
胡琦博客6 小时前
HarmonyOS 智能工具箱(二):OCR 文字识别工具
华为·ocr·harmonyos
FrameNotWork6 小时前
HarmonyOS 6.0 数据可视化图表
华为·信息可视化·harmonyos
程序员黑豆6 小时前
鸿蒙应用开发:6种图片加载方式详解
前端·华为·harmonyos
qizayaoshuap6 小时前
# ❌ 井字棋 — 鸿蒙ArkTS Minimax AI算法与博弈系统设计
人工智能·算法·华为·harmonyos
●VON7 小时前
鸿蒙 PC Markdown 编辑器界面语言切换:跟随系统、简体中文与 English 的完整状态闭环
华为·编辑器·harmonyos·鸿蒙