组件冻结与按需加载: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需要:
- 创建整棵子树的所有组件实例
- 执行所有组件的aboutToAppear生命周期
- 对整棵子树做Measure计算每个组件的尺寸
- 对整棵子树做Layout计算每个组件的位置
- 执行绘制
当条件从true变为false时,ArkUI需要:
- 执行所有组件的aboutToDisappear生命周期
- 销毁整棵子树的所有组件实例
- 释放所有@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
})
}
}
}
这个案例综合运用了以下优化手段:
- Tabs懒加载:非活跃Tab默认不渲染
- Tabs cachedCount(1):额外缓存1个相邻Tab,切换更流畅
- freezeWhenInactive:三个子页面都加了冻结,切换Tab时不刷新后台页
- LazyForEach:推荐页长列表按需渲染
- cachedCount(3):List预渲染3个额外条目,滚动更顺滑
- @Reusable:ProductCard可复用,避免重复创建
- 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应用性能优化的所有核心手段。
这些手段不需要全部用上,但你需要知道每个手段解决什么问题、适合什么场景,在遇到具体性能问题时能选对方案。性能优化从来不是"加上某个配置就好了",而是理解原理后对症下药。