在这里插入图片描述

📖 引言

打开「民族图鉴」,你最先看到的是什么?是列表。

点进「民族百科」,看到的是 56 个民族的网格列表。
点进「收藏」,看到的是收藏的民族列表。
点进「音乐」,看到的是民族音乐列表。
点进「历史」,看到的是浏览历史列表。

列表,是移动应用中最常见的界面形态,也是最容易出性能问题的地方。

为什么列表这么容易卡?因为:

  • 数据量大:几十上百条,甚至上千条
  • 滚动频繁:用户一直在划
  • 每个 item 都可能复杂:图片、文字、按钮、标签……
  • 内存压力大:每个 item 都占内存,滚多了内存暴涨

你可能会问:

  • ForEach 和 LazyForEach 到底有什么区别?性能差多少?
  • LazyForEach 的工作原理是什么?它是怎么做到"懒加载"的?
  • 列表项怎么优化才能更流畅?
  • key 为什么这么重要?为什么不能用 index 当 key?
  • 列表的复用机制是怎么回事?组件池又是什么?
  • 长列表怎么优化?虚拟列表是什么?
  • 列表里的图片怎么优化才不会卡?
  • 为什么滚动的时候内存会一直涨?怎么解决?

这些问题非常关键。列表性能好不好,直接决定了应用的"流畅度"——用户觉得一个 App 卡不卡,很大程度上取决于列表卡不卡。

本文将带你深入理解列表性能优化的方方面面。我们会从"为什么列表是性能重灾区"讲起,深入对比 ForEach 和 LazyForEach,解析 LazyForEach 的工作原理,然后逐一讲解列表优化的各种技巧,最后结合「民族图鉴」的 56 个民族列表进行实战优化。


🎯 学习目标

完成本文后,你将能够:

  • ✅ 理解为什么列表是性能重灾区
  • ✅ 掌握 ForEach 与 LazyForEach 的原理区别与性能差异
  • ✅ 深入理解 LazyForEach 的工作原理:视口回收、组件复用、懒加载
  • ✅ 掌握列表项优化的各种技巧:轻量化、减少层级、固定高度
  • ✅ 理解复用机制、组件池与 key 的重要性
  • ✅ 学会列表性能优化的高级手段:预加载、缓存、虚拟化
  • ✅ 掌握长列表优化策略:数据分页、增量加载、虚拟列表
  • ✅ 掌握列表中的图片优化策略
  • ✅ 能够独立优化「民族图鉴」的列表性能
  • ✅ 解决滚动卡顿、白屏闪烁、item 错位、内存增长等常见问题

💡 需求分析

为什么列表是性能重灾区?

在讲优化之前,我们先搞清楚一个问题:为什么列表这么容易出性能问题?

原因主要有四个:数据量大、滚动频繁、item 复杂、内存压力大


1. 数据量大

列表的数据量通常很大:

  • 「民族图鉴」的民族列表:56 项
  • 音乐列表:可能几百首
  • 搜索结果:可能上千条
  • 资讯/社交类应用:可能无限加载

如果一次性把所有数据都渲染出来:

  • 首屏要等所有 item 渲染完才能显示 → 首屏慢
  • 内存里有所有 item 的组件 → 内存占用高
  • 每次数据变化都要 diff 所有 item → 更新慢

数据量越大,性能问题越严重。


2. 滚动频繁

用户用列表的时候,一直在滚动:

  • 往上划、往下划
  • 快速划、慢慢划
  • 划到底部加载更多

滚动的时候,新的 item 不断进入可视区域,旧的 item 不断滚出去。如果每个新出现的 item 都要从头创建和渲染,那滚动的时候肯定卡。

而且滚动是 60fps 的——每 16ms 就要渲染一帧。如果每帧要做的事情太多,超过 16ms,就掉帧了。


3. 每个 item 都可能很复杂

一个列表项可能包含:

  • 图片(封面图、头像、图标……)
  • 文字(标题、副标题、描述……)
  • 按钮(收藏、分享、点赞……)
  • 标签(分类、状态……)
  • 嵌套的布局结构

item 越复杂,渲染越慢,内存占用越高。如果每个 item 都有好几张图、好几层嵌套,那滚动起来肯定卡。


4. 内存压力大

每个列表项都要占用内存:

  • 组件实例的内存
  • 图片的内存
  • 布局数据的内存

如果一次性渲染几百上千项,内存占用会非常高。低端机可能直接 OOM(内存溢出)崩溃。

而且滚动的时候,如果滚出去的 item 不回收,内存会越滚越大,最后应用被杀掉或者崩溃。


列表性能问题的表现

列表性能不好,用户会有什么感受?

问题 用户感受
首屏加载慢 进去后白屏半天,列表才出来
滚动卡顿 划的时候一顿一顿的,不跟手
白屏闪烁 快速滚动的时候,有些地方白一下才出来
内存暴涨 划着划着应用就卡了,或者被杀了
item 错位 滚动回来发现 item 的位置/内容不对了

这些问题,我们都会在本文中找到解决方案。


「民族图鉴」的列表场景

让我们看看「民族图鉴」有哪些列表场景:

场景 数据量 item 复杂度 技术方案
民族列表(网格) 56 项 中等(图标+文字) Grid + LazyForEach
民族列表(列表) 56 项 简单(图标+两行文字) List + LazyForEach
收藏列表 不定 中等 List + ForEach/LazyForEach
浏览历史 不定 简单 List + ForEach/LazyForEach
音乐列表 不定(可能几百) 中等 List + LazyForEach
首页精选网格 8 项 简单 Grid + ForEach

56 个民族,数量不算特别大,但用 LazyForEach 还是有好处的——特别是网格视图,一屏显示 20 个左右,用 LazyForEach 可以只渲染可见的 + 缓冲区,内存更省。

接下来,让我们深入学习列表优化的知识,然后把「民族图鉴」的列表优化到极致。


🛠️ 核心实现

步骤1:ForEach vs LazyForEach

提到列表性能优化,第一个想到的就是 LazyForEach。那它和 ForEach 到底有什么区别?


1.1 ForEach:全量渲染

ForEach 是最基础的列表渲染方式。它的工作方式很简单:一次性渲染所有项

List() {
  ForEach(this.items, (item: Item) => {
    ListItem() {
      // ... item 内容
    }
  }, (item: Item) => item.id)
}

ForEach 的特点

特点 说明
全量渲染 所有数据一次性全部渲染成组件
内存占用高 所有 item 都在内存里
首屏慢 要等所有 item 渲染完
实现简单 用法简单,不需要 IDataSource
适合小数据量 几十条以内没问题

ForEach 的适用场景

  • 数据量小(< 20~30 条)
  • item 很简单
  • 不会频繁滚动
  • 所有数据都需要同时显示(比如短的设置列表)

1.2 LazyForEach:懒加载渲染

LazyForEach 是高性能列表渲染方式。它的工作方式是:按需渲染,只渲染可见区域的内容

List() {
  LazyForEach(this.dataSource, (item: Item) => {
    ListItem() {
      // ... item 内容
    }
  }, (item: Item) => item.id)
}
.cachedCount(5)  // 缓存数量

LazyForEach 的特点

特点 说明
懒加载 只渲染可见区域 + 缓冲区的 item
内存占用低 内存里只有二三十项(可见+缓冲)
首屏快 只渲染首屏可见的几项
滚动流畅 滚出去的回收,新进来的复用
需要 IDataSource 用法稍复杂,要实现数据源接口
适合大数据量 几百上千条都没问题

LazyForEach 的适用场景

  • 数据量大(> 50~100 条)
  • item 比较复杂
  • 频繁滚动
  • 长列表、无限列表

1.3 性能对比

ForEach 和 LazyForEach 的性能差距有多大?

我们用一个简单的测试来说明(假设数据量 500 条,item 中等复杂度):

指标 ForEach LazyForEach 提升倍数
首屏渲染时间 1500ms 100ms 15x
内存占用 200MB 30MB ~7x
滚动 FPS ~20 ~55 ~2.7x
数据更新速度 慢(diff 500 项) 快(只 diff 可见的) 很多

可以看到,大数据量下,LazyForEach 的性能是碾压级的。

但如果数据量很小(比如只有 10 条),两者差别不大——甚至 ForEach 可能还快一点,因为 LazyForEach 有一些额外的管理开销。


1.4 怎么选?

选择决策树

数据量多少?
  │
  ├─ < 20 条
  │   └─ ForEach(简单够用)
  │
  ├─ 20~100 条
  │   ├─ item 很简单(纯文字,1~2 层) → ForEach 也可以
  │   └─ item 较复杂(多图,多层级) → 推荐 LazyForEach
  │
  └─ > 100 条
      └─ 必须用 LazyForEach

「民族图鉴」的选择

  • 56 个民族,item 中等复杂度
  • 网格视图一屏能显示 20 个左右
  • 推荐用 LazyForEach(实际上项目里已经在用了)

虽然 56 条不算特别多,但用 LazyForEach 有几个好处:

  1. 内存占用更低
  2. 首屏渲染更快
  3. 以后数据量增长了也不怕
  4. 养成好习惯

步骤2:LazyForEach 的工作原理

LazyForEach 为什么这么快?它的"懒加载"是怎么实现的?

核心原理是三个机制:视口回收、组件复用、懒加载


2.1 视口(Viewport)与缓冲区

什么是视口?
视口就是用户当前能看到的区域。屏幕就这么大,只能显示有限的列表项。

比如:

  • 列表项高度 60px
  • 屏幕高度 600px
  • 一屏能显示 10 个 item

那视口内就只有 10 个 item。其他的 item,用户根本看不到,为什么要渲染呢?

什么是缓冲区?
如果只渲染视口内的 item,那用户滚动的时候,新出现的 item 要临时创建,可能来不及,导致白屏。

所以 LazyForEach 会在视口的上下各加一个缓冲区——提前渲染几个即将进入视口的 item。

  ┌─────────────┐
  │   缓冲区上   │  ← 提前渲染,马上要滚进来的
  ├─────────────┤
  │             │
  │    视口     │  ← 用户当前看到的
  │             │
  ├─────────────┤
  │   缓冲区下   │  ← 提前渲染,马上要滚进来的
  └─────────────┘

缓冲区大小

  • cachedCount 属性控制(大致)
  • 数值越大,越不容易白屏,但内存占用越高
  • 一般设 3~10 就够了
  • 默认值由框架内部决定(一般上下各缓冲几屏)

为什么这样做?

  • 只渲染可见的 + 缓冲区 → 内存占用低
  • 有缓冲区 → 滚动的时候不会白屏
  • 滚出去的 item 可以回收 → 内存不会一直涨

2.2 组件复用(Recycling)

光有视口回收还不够。如果每个新出现的 item 都要从头创建组件,那滚动的时候还是会卡——创建组件是有开销的。

所以 LazyForEach 还有一个重要机制:组件复用

什么是组件复用?
滚出去的 item,组件不要销毁,而是放到一个"组件池"里。新进来的 item,从组件池里拿一个现成的组件,更新一下数据就用。

滚出去的 item → 放入组件池 → 新进来的 item 从池里拿 → 更新数据 → 显示

为什么复用性能好?

  • 创建组件是有成本的(实例化、初始化……)
  • 复用的话,只需要更新数据,不需要重新创建
  • 滚动的时候,组件数量保持稳定(可见 + 缓冲 + 池)
  • 内存也不会一直涨

类比一下
就像餐厅的盘子。客人走了,盘子不要扔,洗一下,下一个客人接着用。这样不需要准备几百个盘子,几十个就够了,而且上菜也快。


2.3 懒加载(Lazy Loading)

懒加载有两层含义:

第一层:组件懒加载

  • 不是一次性创建所有组件
  • 滚到哪,创建(或复用)到哪

第二层:数据懒加载

  • 数据也不一定一次性全加载
  • 可以分页加载——滚到底部了再加载下一页

第二层是业务层面的,需要我们自己实现。LazyForEach 本身提供的是第一层——组件的懒加载。


2.4 整体工作流程

把三个机制合起来,LazyForEach 的整体工作流程是这样的:

初始状态:
  1. 计算首屏可见的 item 数量
  2. 创建这些 item 的组件
  3. 加上缓冲区,多创建几个
  4. 显示首屏

用户向下滚动:
  1. 上面的 item 滚出视口
  2. 这些 item 的组件回收到组件池
  3. 下面缓冲区的 item 进入视口
  4. 缓冲区下面再创建(复用)新的 item
  5. 保持缓冲区大小

用户向上滚动:
  类似,方向反过来

结果就是

  • 内存里的组件数量保持稳定(可见 + 缓冲 × 2 + 池)
  • 不管数据量是 100 还是 10000,内存占用都差不多
  • 滚动的时候,组件复用,性能流畅

💡 理解了这些原理,你就明白为什么 LazyForEach 性能好了。它的核心思想就是:不做无用功——用户看不到的东西,就不渲染;用过的东西,尽量重复用。


步骤3:列表项优化

LazyForEach 解决了"数量"的问题,但每个 item 本身的渲染速度也很重要。

如果每个 item 渲染要 5ms,一屏 10 个就是 50ms,远超 16ms 的预算,肯定卡。

所以我们还要优化列表项本身


3.1 列表项轻量化

原则:列表项越简单越好。

列表项只是"预览",不是"详情"。只放最核心的信息,详细内容点进去再看。

怎么轻量化?

  1. 减少内容

    • 只放标题、副标题、缩略图
    • 详细描述、大量标签,都放到详情页
    • 列表项的作用是"让用户快速浏览,找到感兴趣的"
  2. 减少图片

    • 一个列表项最好不要超过 1~2 张图
    • 小图标尽量用 iconfont 或者 SVG
    • 大图列表(比如相册)要特别优化
  3. 减少组件数量

    • 能合并的组件就合并
    • 能用 Span 的不要用多个 Text
    • 能不用容器的就不用

3.2 减少层级

列表项的布局层级越深,渲染越慢。因为布局是递归计算的,层级越深,计算量越大。

目标:列表项的层级控制在 3~4 层以内。

怎么减少层级?

这个我们上一篇讲过,这里再结合列表项复习一下:

  1. 避免不必要的嵌套

    // ❌ 不好:没必要的嵌套
    ListItem() {
      Column() {
        Row() {
          Column() {
            Text('标题')
            Text('副标题')
          }
          .margin({ left: 10 })
        }
        .padding(10)
      }
    }
    
    // ✅ 好:更扁平的结构
    ListItem() {
      Row() {
        Image(...)
        Column({ space: 4 }) {
          Text('标题')
          Text('副标题')
        }
        .layoutWeight(1)
        .margin({ left: 10 })
      }
      .padding(10)
    }
    
  2. 用 Span 减少 Text 组件
    同一行的多样式文字,用 Span 代替多个 Text + Row。

  3. 属性直接加在组件上
    不要为了加个背景色、padding 就套一层容器。


3.3 固定高度

如果列表项的高度是固定的,LazyForEach 的性能会更好。

为什么?

  • 高度固定的话,框架不用测量每个 item 的高度
  • 布局计算更快
  • 滚动位置计算更准确
  • 复用也更高效

怎么固定高度?
直接给 ListItem 设置高度:

ListItem() {
  // ... 内容
}
.height(60)  // 固定高度

或者用 .aspectRatio() 固定宽高比。

什么时候可以固定高度?

  • item 内容是固定的,不会变化
  • item 内容的行数是固定的(比如标题最多 1 行,副标题最多 2 行)

什么时候不能固定高度?

  • item 内容高度不固定(有的长有的短)
  • 动态内容,可能变化

💡 建议:能固定高度就固定,性能更好。实在不能固定也没关系,LazyForEach 也支持动态高度,只是稍微慢一点。


3.4 避免动态计算

列表项的 build 函数里,不要做复杂的计算。

为什么?
build 函数会被频繁调用(每次状态变化、每次复用更新数据),如果里面有复杂计算,会拖慢渲染。

不好的做法

// ❌ 不好:每次 build 都计算
ForEach(this.list, (item: Item) => {
  ListItem() {
    Text(this.formatDate(item.timestamp))  // 每次都格式化
    Text(this.calculateSomething(item))    // 每次都计算
  }
})

好的做法

// ✅ 好:提前算好,存到数据里
// 拿到数据的时候就格式化好
const processedList = rawList.map(item => ({
  ...item,
  formattedDate: formatDate(item.timestamp),
  calculatedValue: calculateSomething(item)
}));

// 渲染的时候直接用
ForEach(processedList, (item: Item) => {
  ListItem() {
    Text(item.formattedDate)  // 直接用,不用计算
    Text(item.calculatedValue)  // 直接用,不用计算
  }
})

原则:build 函数里只做渲染,不做计算。计算逻辑提前做好,存到数据里。


步骤4:复用机制与 key 的重要性

列表的复用机制很重要,但复用的正确性,依赖于key


4.1 key 是用来干什么的?

当列表数据变化的时候(比如增加、删除、排序),框架需要知道:

  • 哪些项是新增的?
  • 哪些项是删除的?
  • 哪些项只是移动了位置?
  • 哪些项的数据更新了?

有了 key,框架就能精准地 diff,只更新变化的部分,复用没变化的组件。

没有 key,或者 key 不正确,框架就只能"全部重建"——性能就差了。


4.2 好的 key 长什么样?

好的 key 有三个特点

  1. 唯一:同一个列表里,每个项的 key 不能重复
  2. 稳定:同一个项的 key 不会变(数据更新了,key 还是同一个)
  3. 简单:字符串或数字就行,不要太复杂

最好的 key:数据的唯一 ID

// ✅ 好:用数据本身的唯一 ID
ForEach(this.ethnicList, (ethnic: EthnicGroup) => {
  ListItem() { ... }
}, (ethnic: EthnicGroup) => ethnic.id)  // 用民族 ID 当 key

每个民族都有一个唯一的、不变的 ID,用它当 key 最合适。


4.3 为什么不能用 index 当 key?

很多人图省事,用数组的 index 当 key:

// ❌ 不好:用 index 当 key
ForEach(this.list, (item: Item, index: number) => {
  ListItem() { ... }
}, (item: Item, index: number) => index.toString())

为什么不好?

因为 index 是不稳定的。如果列表有增删排序,index 就变了,但其实数据还是那些数据。

举个例子

  • 初始列表:[A, B, C],index 分别是 0, 1, 2
  • 删除了 A,列表变成:[B, C],index 变成 0, 1
  • 这时候,B 的 index 从 1 变成了 0,C 的 index 从 2 变成了 1
  • 框架一看:key 全变了!以为所有项都是新的,全部重建

结果就是:明明只删除了第一项,但所有项都重建了一遍,性能很差。

用 ID 的情况

  • 初始列表:[A(id=1), B(id=2), C(id=3)]
  • 删除了 A,列表变成:[B(id=2), C(id=3)]
  • 框架一看:key=1 没了(删除),key=2 和 key=3 还在(复用)
  • 结果就是:只删除第一项,其他两项复用

性能天差地别。

🎯 记住:只要能用数据唯一 ID 当 key,就不要用 index。这是列表性能优化的基础。


4.4 组件池与复用的关系

有了正确的 key,组件复用才能正常工作。

复用的流程

  1. 一个新的 item 要显示了
  2. 框架根据 key 去组件池里找:有没有相同 key 的组件?
  3. 如果有:拿出来,更新数据,继续用
  4. 如果没有:创建一个新的

如果 key 不正确(比如用 index),框架就找不到可以复用的组件,只能每次都创建新的——复用机制就失效了。

所以说:key 是复用机制的基础。key 不正确,复用就无从谈起。


步骤5:列表性能优化手段

讲完了基础,我们来看看列表性能优化的一些高级手段。


5.1 预加载(Preloading)

什么是预加载?
提前加载用户"可能会看"的内容,等用户真正看到的时候,已经加载好了。

列表中的预加载

  • 数据预加载:滚到接近底部的时候,提前加载下一页数据
  • 图片预加载:缓冲区里的 item,提前加载图片
  • 页面预加载:预判用户会点哪个 item,提前加载详情页数据

数据预加载(上拉加载更多)

List() {
  LazyForEach(this.dataSource, (item: Item) => {
    ListItem() { ... }
  })
  
  // 底部加载提示
  if (this.isLoadingMore) {
    ListItem() {
      Text('加载中...')
        .width('100%')
        .textAlign(TextAlign.Center)
        .padding(20)
    }
  }
}
.onReachEnd(() => {
  // 滚到底部了,加载更多
  if (!this.isLoadingMore && this.hasMore) {
    this.loadMore();
  }
})

优化技巧

  • 不要等滚到底了再加载,提前一点(比如还差 10 项的时候就开始加载)
  • 这样用户感觉不到加载过程,以为是无限的
  • 但也不要太早,避免浪费流量和性能

5.2 缓存(Caching)

列表中的缓存

  • 图片缓存:加载过的图片缓存起来,不用重新下载/解码
  • 数据缓存:加载过的数据缓存起来,不用重新请求
  • 组件缓存:LazyForEach 的组件池也是一种缓存

图片缓存尤其重要——图片是列表性能的大头。我们会在后面专门讲。


5.3 虚拟化(Virtualization)

虚拟化是 LazyForEach 背后的核心技术。简单说就是:

  • 只有可见的元素是"真实"的
  • 不可见的元素只是"数据",不占组件,不占内存

LazyForEach 本身就是虚拟化列表。大多数情况下,用 LazyForEach 就够了,不需要自己实现虚拟化。


5.4 瀑布流优化

瀑布流(多列不等高)是比较特殊的列表,性能优化也更有挑战。

瀑布流的性能问题

  • 每个 item 高度不一样,布局计算更复杂
  • 高度不固定,复用效率稍低
  • 快速滚动的时候,可能出现位置计算错误

优化建议

  1. 尽量给 item 一个预估高度
    即使不能完全固定,给个预估高度也能帮助框架更快计算。

  2. 减少 item 内部的布局变化
    瀑布流本身布局计算就重,如果 item 内部还频繁变化,会更卡。

  3. 图片要固定宽高比
    图片加载完成后不要改变 item 的高度,不然会导致"重排瀑布流"——所有项的位置都要重新算,非常慢。


步骤6:长列表优化

如果列表特别长(几百上千条,甚至无限加载),还需要一些额外的优化手段。


6.1 数据分页

不要一次性加载所有数据,一页一页加载。

为什么?

  • 初始加载快(只加载第一页)
  • 节省流量(用户可能看不了那么多)
  • 服务端压力也小

分页策略

  • 每页 20~50 条(根据 item 复杂度调整)
  • 滚到底部加载下一页
  • 可以提前几屏开始加载(更流畅)

6.2 增量加载

数据更新的时候,不要全部替换,只更新变化的部分。

不好的做法

// ❌ 不好:直接替换整个数组,全部重绘
this.list = newList;

好的做法

// ✅ 好:增量更新,只加新的
this.list.push(...newItems);  // 追加,而不是替换

对于 LazyForEach + IDataSource,可以用增量更新的方法:

// 追加数据
appendData(data: Item[]): void {
  const startIndex = this.dataList.length;
  this.dataList = this.dataList.concat(data);
  // 通知框架:新增了这些项
  for (let i = 0; i < data.length; i++) {
    this.notifyDataAdd(startIndex + i);
  }
}

增量更新比全量更新性能好很多——框架知道哪些是新增的,只处理新增的部分。


6.3 虚拟列表的极限优化

如果列表特别特别长(几万条),普通的 LazyForEach 可能也不够用了。这时候需要更极端的优化:

  1. 简化 item 到极致
    每个 item 越简单越好,尽量只有文字,少用图片。

  2. 固定高度
    必须固定高度,这样布局计算最快。

  3. 更大的复用池
    适当增加 cachedCount,减少创建新组件的概率。

  4. 回收机制优化
    确保滚出去的组件及时回收,内存不泄漏。

但对于大多数应用来说,LazyForEach 已经足够了。真的有几万条数据的场景不多。


步骤7:图片优化

列表性能问题,图片占了一半以上的功劳

图片为什么这么影响性能?

  • 图片解码耗时(特别是大图)
  • 图片内存占用大
  • 图片绘制也耗时
  • 网络加载图片更慢

所以列表优化,图片优化是重中之重。


7.1 固定宽高

列表里的图片,一定要固定宽高。

为什么?

  • 不固定的话,图片加载完会把 item 撑开
  • 导致列表布局重新计算(重排)
  • 甚至可能导致整个列表的位置都变了
  • 用户会看到"跳一下"的感觉,体验很差

怎么固定?

Image(src)
  .width(80)
  .height(80)
  .objectFit(ImageFit.Cover)

或者用宽高比:

Image(src)
  .width('100%')
  .aspectRatio(1)  // 1:1 正方形
  .objectFit(ImageFit.Cover)

🎯 记住:列表里的图片必须有确定的尺寸。这是铁则。


7.2 占位图

图片加载需要时间,加载过程中显示什么?

不好的做法

  • 空白(白屏闪烁)
  • 直接显示原图(慢慢从上到下出现)

好的做法:显示占位图

占位图的几种形式:

  1. 纯色占位
    最简单,用个纯色方块占位置。

  2. 模糊小图
    先用一张很小的缩略图(几 KB),模糊显示,等大图加载完再替换。

  3. 骨架屏
    用灰色的占位块,模拟内容的大致形状。

占位图的好处

  • 避免白屏闪烁
  • 布局稳定(因为尺寸固定了)
  • 用户知道"这里有内容,正在加载"

7.3 滑动暂停加载

快速滚动的时候,新的 item 不断出现,如果每个都立刻加载图片,滚动会很卡——因为图片解码和绘制都要时间,会抢占主线程。

所以有一个优化技巧:滚动的时候不加载图片,滚动停下来再加载。

为什么这样好?

  • 滚动的时候,用户快速划过,图片加载了用户也看不清
  • 不如把资源留给滚动,保证滚动流畅
  • 停下来了,用户要看了,再加载图片

怎么实现?
监听列表的滚动状态:

  • 滚动开始 → 暂停加载新图片
  • 滚动结束 → 开始加载可见区域的图片

这个技巧在快速滚动的时候效果特别明显——滚动会流畅很多。


7.4 图片尺寸优化

用合适尺寸的图片,不要大图小用。

比如:

  • 列表里的缩略图,只需要 100×100
  • 就不要用 1000×1000 的大图,然后缩小显示

为什么不好?

  • 大图文件大,加载慢
  • 大图解码慢,耗 CPU
  • 大图占内存多(1000×1000 的图占 4MB,100×100 只占 40KB,差 100 倍!)

怎么做?

  • 服务端提供多尺寸的图片(缩略图、中图、大图)
  • 列表用缩略图,详情页用大图
  • 本地图片也要准备合适尺寸的

图片内存的计算:

图片内存 = 宽度 × 高度 × 4 字节(RGBA)

比如:

  • 100×100:100 × 100 × 4 = 40,000 字节 ≈ 40KB
  • 500×500:500 × 500 × 4 = 1,000,000 字节 ≈ 1MB
  • 1000×1000:1000 × 1000 × 4 = 4,000,000 字节 ≈ 4MB

差距非常大。所以图片尺寸优化,收益非常高。


7.5 图片格式优化

不同的图片格式,大小和质量也不一样:

格式 透明度 动画 压缩率 适用场景
JPG 照片、复杂图片
PNG 图标、简单图形、透明图
WebP 很高 大部分场景都适用
SVG 高(矢量) 图标、简单图形

WebP 是个好选择——同样质量,比 JPG 小 25%~35%,比 PNG 小更多。而且支持透明度和动画。

图标优先用 SVG 或 iconfont——矢量图,缩放不失真,文件也小。


7.6 图片缓存

加载过的图片,缓存起来,下次用的时候直接从缓存里拿,不用重新下载/解码。

缓存的层级

  1. 内存缓存:最快,但容量有限,应用被杀就没了
  2. 磁盘缓存:较慢,但容量大,应用重启还在
  3. 网络:最慢,最"贵"(耗流量)

缓存策略

  • 最近常用的图片放内存缓存
  • 更多的图片放磁盘缓存
  • 内存缓存要有大小限制,满了就淘汰最久没用的(LRU)

「民族图鉴」现在用的是本地 rawfile 图片,没有网络加载的问题。但内存缓存还是需要的——图片解码也需要时间,缓存解码后的图片,复用的时候更快。


步骤8:实战——「民族图鉴」56个民族列表性能优化对比

讲了这么多理论,让我们来实战一下——优化「民族图鉴」的民族列表。


8.1 优化前的状态

先看看优化前的情况:

列表实现

  • 网格模式:Grid + LazyForEach
  • 列表模式:List + LazyForEach
  • 数据源:EthnicListDataSource(继承 BasicDataSource)
  • 已经用了 LazyForEach,基础不错

item 结构(网格模式)

GridItem
  └─ Column
      ├─ Text(首字图标)
      ├─ Text(名称)
      └─ Text(人口)

item 结构(列表模式)

ListItem
  └─ Row
      ├─ Text(首字图标)
      ├─ Column
      │   ├─ Text(名称)
      │   └─ Text(地区·人口)
      └─ Text(箭头)

目前的问题

  1. 网格模式用了 Scroll + Grid + LazyForEach,Scroll 可能和 Grid 的滚动有冲突
  2. item 里有 pressedIndex 状态,点击缩放动画每次都触发重绘
  3. 用的是文字首字图标,这个挺好,没有图片加载问题
  4. 层级还可以,不算特别深

8.2 可以优化的点

让我们分析一下可以优化的地方:

优化项 优化手段 预期收益 优先级
Grid 滚动优化 直接用 Grid 滚动,不要外层 Scroll P0
点击状态优化 用 transform 做缩放,减少重绘 P1
列表项层级优化 再看看能不能减少层级 P1
key 优化 现在的 key 是 ${id}_${index},index 多余,直接用 id P2
固定 item 高度 给 GridItem/ListItem 固定高度 P1
renderGroup 给 item 开 renderGroup 待验证 P2

8.3 实施优化

优化一:Grid 滚动优化

现在的实现是:

Scroll() {
  Grid() {
    LazyForEach(...)
  }
}

外面套了一层 Scroll。其实 Grid 本身就支持滚动,不需要外层 Scroll。外层 Scroll 反而可能和 Grid 的滚动冲突,影响性能。

优化后:

Grid() {
  LazyForEach(...)
}
.scrollBar(BarState.Off)
.layoutWeight(1)

收益

  • 去掉一层 Scroll,减少层级
  • 滚动更原生,性能更好
  • 避免滚动冲突

优化二:点击缩放用 transform

现在的点击缩放是通过 .scale() 实现的,这个其实已经是 transform 了,没问题。

但 pressedIndex 是一个整体状态,每次点击都会改变,导致所有 item 都要检查要不要重绘。虽然影响不大,但可以优化。

优化思路:把按下状态下放到每个 item 内部,而不是用一个全局的 pressedIndex。

但这个改造成本有点高,收益不大,可以先不做。


优化三:key 简化

现在的 key 是 ${ethnic.id}_${index}

LazyForEach(this.lazyDataSource, (ethnic: EthnicGroup, index: number) => {
  // ...
}, (ethnic: EthnicGroup, index: number) => `${ethnic.id}_${index}`)

其实用 ethnic.id 就够了,index 是多余的。因为 id 已经是唯一的了。

优化后:

LazyForEach(this.lazyDataSource, (ethnic: EthnicGroup) => {
  // ...
}, (ethnic: EthnicGroup) => ethnic.id)

收益

  • key 更简洁
  • 避免 index 变化导致 key 变化(比如排序后)
  • 复用更准确

优化四:固定 item 高度

网格模式的 item 高度可以固定一下:

GridItem() {
  this.buildGridCard(ethnic, index)
}
.height(100)  // 固定高度

列表模式的 item 已经有高度了(.height(56)),这个不用改。

收益

  • Grid 布局计算更快
  • 减少测量时间
  • 复用更高效

8.4 优化效果对比

优化完了,对比一下效果:

指标 优化前 优化后 提升
首屏渲染时间 ~200ms ~150ms 25%
滚动 FPS ~50 ~55 10%
内存占用 ~50MB ~45MB 10%
快速滚动白屏 偶尔 几乎没有 明显改善

(注:以上数据是估算的,实际数据需要在设备上测量。)

可以看到,虽然已经用了 LazyForEach,但通过细节优化,还能再提升一截。


步骤9:常见问题与解决方案

最后,我们来聊聊列表优化中常见的问题和解决方案。


9.1 问题一:滚动卡顿

现象
列表滑动的时候掉帧,不流畅,特别是快速滚动的时候。

原因及解决方案

原因 概率 解决方案
用了 ForEach 而不是 LazyForEach 30% 改用 LazyForEach
列表项太复杂/层级深 25% 简化 item,减少层级
图片太大太多 20% 图片压缩,合适尺寸,懒加载
key 不正确/没有 key 15% 用数据唯一 ID 当 key
滚动回调里有耗时操作 10% 防抖,减少操作

排查步骤

第1步:确认用的是 List + LazyForEach 吗?
    → 不是 → 改用 List + LazyForEach
    ↓ 是
第2步:key 是数据唯一 ID 吗?
    → 不是/用 index → 改成唯一 ID
    ↓ 是
第3步:item 复杂吗?层级深吗?
    → 是 → 简化布局,减少层级
    ↓ 还好
第4步:图片多吗?大吗?
    → 是 → 优化图片(尺寸、格式、懒加载)
    ↓ 还好
第5步:onScroll/onTouch 里有什么?
    → 复杂逻辑 → 防抖/节流,或移出去

9.2 问题二:白屏闪烁

现象
快速滚动的时候,新出现的 item 白一下才显示出来。

原因

  • 缓冲区太小,来不及渲染
  • 图片加载慢,没加载完就是白的
  • item 渲染太慢

解决方案

  1. 增大 cachedCount

    List() { ... }
      .cachedCount(10)  // 适当增大缓存
    

    但也不要太大,内存会增加。

  2. 图片用占位图
    图片加载过程中显示占位图,不是白的。

  3. 滑动暂停加载图片
    滚动的时候不加载,滚完再加载。但这个要配合占位图,不然还是白的。

  4. 简化 item
    item 越简单,渲染越快,越不容易白屏。


9.3 问题三:item 错位

现象
滚动回来之后,发现 item 的内容/位置不对了——比如显示的是别的 item 的数据。

原因

  • 组件复用的时候,数据没更新对
  • key 不正确,导致复用到了错误的组件
  • 组件内部有状态,复用的时候状态没重置

解决方案

  1. 检查 key
    确保 key 是唯一且稳定的。不要用 index。

  2. 确保组件是"纯"的
    列表项组件最好是纯展示组件——给什么数据就显示什么,不要有自己的内部状态(或者内部状态要正确重置)。

  3. 用 @State 的时候小心
    如果列表项内部有 @State,复用时要确保状态正确更新。最好把状态提到数据层面,不要让组件自己存状态。


9.4 问题四:内存增长

现象
列表滚动的时候,内存一直涨,滚得越久,内存越高。

原因

  • 图片内存:滚过的图片都在内存里
  • 组件泄漏:滚出去的组件没有被回收
  • 没有复用:每次都创建新组件,旧的没释放

解决方案

  1. 确认用了 LazyForEach
    ForEach 的话,滚多少就创建多少,内存一直涨。LazyForEach 会回收。

  2. 图片内存优化

    • 图片缓存有大小限制,超过了就释放旧的
    • 用合适尺寸的图片(不要大图小用)
    • 离屏的图片可以考虑释放(但要平衡加载速度)
  3. 检查内存泄漏

    • 列表项里有没有注册监听、定时器,没移除?
    • 有没有闭包引用,导致组件回收不了?
    • 用 Profiler 看一下内存快照,找泄漏点
  4. 优化复用

    • 确保 key 正确
    • 适当增加 cachedCount,提高复用率

9.5 问题五:数据更新后列表不刷新/刷新慢

现象

  • 数据更新了,但列表没变化
  • 或者更新很慢,要等很久才刷新

原因

  • 没有正确通知数据变化
  • 用了全量更新,每次都全部重绘
  • key 不正确,diff 效率低

解决方案

  1. 正确使用 IDataSource 的 notify 方法

    • 全量变了用 notifyDataReload
    • 新增用 notifyDataAdd
    • 修改用 notifyDataChange
    • 删除用 notifyDataDelete
    • 不要每次都用 notifyDataReload(性能差)
  2. 尽量用增量更新
    能追加就追加,能改几条就改几条,不要每次都全量刷新。

  3. 确保 key 正确
    key 正确,diff 才快。


⚠️ 常见问题与解答

Q1:多少条数据需要用 LazyForEach?

A:没有绝对的标准,要看 item 的复杂度。

粗略参考

数据量 item 简单(纯文字) item 中等(有图) item 复杂(多图多组件)
< 20 ForEach ForEach 考虑 LazyForEach
20~50 ForEach 考虑 LazyForEach 推荐 LazyForEach
50~100 考虑 LazyForEach 推荐 LazyForEach 必须 LazyForEach
> 100 推荐 LazyForEach 必须 LazyForEach 必须 LazyForEach

简单判断

  • 一屏能显示完的列表 → ForEach
  • 超过 2~3 屏的 → 考虑 LazyForEach
  • 超过 5 屏的 → 推荐 LazyForEach

💡 建议:拿不准的时候,先用 ForEach,跑起来看看。如果卡顿再换成 LazyForEach。不要过早优化,但也要心里有数。


Q2:cachedCount 设多少合适?

A:一般 3~10 就够了。

cachedCount 的影响

cachedCount 白屏概率 内存占用 推荐场景
0~2 高(快速滚动可能白屏) 最低 低端机/内存紧张
3~5 中(正常滚动没问题) 中等 大多数情况
5~10 低(快速滚动也很少白屏) 较高 高性能设备/追求流畅
> 10 很低 不推荐,性价比低

建议

  • 先设 5,试试看
  • 如果快速滚动经常白屏,就加一点
  • 如果内存紧张,就减一点

不是越大越好——太大的话内存占用高,反而可能因为内存不足导致卡顿。


Q3:列表里的图片,用本地的好还是网络的好?

A:这个问题的答案是——看情况。但从性能角度说:

本地图片的优点

  • 加载快,没有网络延迟
  • 不耗流量
  • 离线也能用

本地图片的缺点

  • 增加包体积
  • 图片数量有限(不能太多)
  • 不能动态更新

网络图片的优点

  • 不占包体积
  • 可以动态更新
  • 数量不受限制

网络图片的缺点

  • 加载慢(受网络影响)
  • 耗流量
  • 离线用不了

「民族图鉴」的情况

  • 56 个民族的封面图,数量不多
  • 都是固定内容,不需要动态更新
  • 用本地 rawfile 图片就挺好

如果以后图片多了(比如几百首音乐的封面),可以考虑换成网络图片 + 缓存。


Q4:列表项的点击效果会影响性能吗?

A:如果做得不好,会有一点影响,但一般不大。

不好的做法

  • 点击的时候改变背景色 → 触发重绘
  • 点击的时候改变大小(用 width/height) → 触发重排

好的做法

  • 点击缩放用 transform.scale → 只触发合成,性能好
  • 透明度变化用 opacity → 只触发合成,性能好

「民族图鉴」的列表点击用的是 .scale() + .opacity() + .animation(),这个是对的,性能不错。

但要注意
如果用一个全局的 pressedIndex 来控制所有 item 的状态,每次点击所有 item 都要检查一遍。虽然影响不大,但如果 item 特别多,可以考虑把状态下放到每个 item 内部。


Q5:瀑布流比普通列表性能差很多吗?

A:是的,瀑布流性能更差一些,但也不是不能用。

瀑布流为什么慢?

  • 高度不固定,每个都要测量
  • 布局计算更复杂(要算放在哪一列)
  • 复用效率稍低(因为高度不一样)

瀑布流优化建议

  1. item 尽量简单
  2. 图片一定要固定宽高比(不要加载完改变高度)
  3. 数据量不要太大(几百项以内比较好)
  4. 适当增加缓冲,减少白屏
  5. 尽量用固定宽度,高度按比例算(这样布局计算快一些)

📝 本章小结

核心知识点

本文系统讲解了列表性能优化:

1. 为什么列表是性能重灾区

  • 数据量大
  • 滚动频繁
  • item 可能复杂
  • 内存压力大

2. ForEach vs LazyForEach

  • ForEach:全量渲染,适合小数据量
  • LazyForEach:懒加载,适合大数据量
  • 大数据量下性能差距很大(几倍到几十倍)

3. LazyForEach 工作原理

  • 视口回收:只渲染可见的 + 缓冲区
  • 组件复用:滚出去的放组件池,新进来的复用
  • 懒加载:滚到哪渲染到哪

4. 列表项优化

  • 轻量化:减少内容,减少图片,减少组件
  • 减少层级:3~4 层以内最好
  • 固定高度:能固定就固定,性能更好
  • 避免动态计算:build 里只渲染,不计算

5. 复用与 key

  • key 是复用的基础
  • 好的 key:唯一、稳定、简单
  • 不要用 index 当 key(不稳定)
  • 推荐用数据的唯一 ID

6. 列表优化手段

  • 预加载:提前加载数据/图片
  • 缓存:图片缓存、数据缓存
  • 虚拟化:LazyForEach 就是虚拟化列表
  • 瀑布流优化:固定宽高比,简化 item

7. 长列表优化

  • 数据分页:一页一页加载
  • 增量加载:只更新变化的部分
  • 极限优化:简化 item、固定高度、增大复用池

8. 图片优化

  • 固定宽高:必须!防止布局抖动
  • 占位图:避免白屏闪烁
  • 滑动暂停加载:滚动更流畅
  • 尺寸优化:用合适尺寸的图,不要大图小用
  • 格式优化:WebP > JPG > PNG
  • 图片缓存:内存缓存 + 磁盘缓存

最佳实践总结

数据量大一定要用 LazyForEach

// ✅ 好:大数据量用 LazyForEach
List() {
  LazyForEach(this.dataSource, (item: Item) => {
    ListItem() { ... }
  }, (item: Item) => item.id)
}

key 一定要用数据唯一 ID

// ✅ 好:用 ID,唯一稳定
(item: Item) => item.id

// ❌ 不好:用 index,不稳定
(item: Item, index: number) => index.toString()

列表项图片必须固定宽高

// ✅ 好:固定尺寸,防止布局抖动
Image(src)
  .width(80)
  .height(80)
  .objectFit(ImageFit.Cover)

列表项尽量简单,层级尽量少

目标:3~4 层以内
能合并的合并,能不用的容器就不用
能用 Span 的不要用多个 Text

build 函数里只做渲染,不做计算

// ✅ 好:提前算好,渲染直接用
Text(item.formattedDate)  // 不用每次都格式化

// ❌ 不好:每次 build 都计算
Text(this.formatDate(item.date))

图片优化要重视

合适的尺寸 → 节省内存,加载更快
合适的格式 → 减小体积
占位图 → 避免白屏
缓存 → 更快加载

下一步预告

在下一篇文章(第65篇)中,我们将:

  • 🧠 理解为什么要关注内存:OOM 崩溃、卡顿、后台被杀
  • 💾 学习鸿蒙应用的内存模型:JS 堆、Native 堆、图形内存
  • 🕳️ 了解内存泄漏的常见原因:未移除的监听、定时器、闭包、缓存过大
  • 🔍 掌握内存泄漏的检测方法:Profiler、手动打点、代码审查
  • 🖼️ 学习图片内存优化:压缩、采样、复用、及时释放
  • 📦 理解缓存策略:LRU 缓存、大小限制、自动清理
  • 📋 掌握列表的内存优化:滚动回收、大图处理、分页加载
  • ♻️ 理解对象池与复用模式
  • 🎯 实战排查「民族图鉴」的内存泄漏
  • 💡 解决列表滚动内存暴涨、页面返回内存不下降、OOM 崩溃等常见问题

内存是移动端的稀缺资源。做好内存优化,让你的应用更稳定、更流畅、更"长寿"。


🔗 相关链接


💡 提示:列表是应用中最常见的界面,也是性能优化的重点。做好列表优化,应用的流畅度会提升一个档次。记住几个关键点:用 LazyForEach、正确的 key、简单的 item、优化好图片。把这几点做好,大多数列表的性能就都没问题了。

Logo

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

更多推荐