在这里插入图片描述

📖 引言

前几篇我们学习了基础组件、状态管理和路由导航,已经能够构建简单的页面了。但真实的应用中,最常见的界面是什么?是列表

民族列表、收藏列表、历史记录、搜索结果……几乎每个应用都有大量的列表页面。列表也是最容易出现性能问题的地方——几百个条目一次性渲染出来,卡得动不了;滚动的时候掉帧,用户体验极差。

你可能会问:

  • ForEach 和 LazyForEach 有什么区别?什么时候用哪个?
  • List 组件为什么比 Scroll + Column 性能好?
  • 为什么要给每个列表项加 key?不加会怎样?
  • Grid 网格布局怎么用?和 List 有什么区别?
  • 列表性能优化有哪些技巧?

这些问题非常关键。列表用好了,应用流畅丝滑;用不好,卡成 PPT。「民族图鉴」项目中有 56 个民族的列表,虽然数量不算特别多,但列表的实现方式决定了用户体验的上限。

本文将以「民族图鉴」的民族列表为载体,从基础用法到性能优化,从 ForEach 到 LazyForEach,从 List 到 Grid,带你彻底搞懂鸿蒙的列表组件。


🎯 学习目标

完成本文后,你将能够:

  • ✅ 熟练使用 ForEach 渲染列表,理解 key 的作用
  • ✅ 掌握 List 组件的使用,理解 List 为什么性能好
  • ✅ 深入理解 LazyForEach 的懒加载原理与实现
  • ✅ 掌握 Grid 网格布局的使用场景
  • ✅ 学会列表性能优化的各种技巧
  • ✅ 能够构建高性能的民族列表页面
  • ✅ 避开列表开发的常见坑

💡 需求分析

为什么列表这么重要?

列表是移动应用中最常见的界面形态。为什么?

  1. 信息密度高:有限的屏幕空间展示大量内容
  2. 交互自然:上下滑动是用户最熟悉的手势
  3. 扩展性强:加一项少一项,界面都能适应
  4. 实现简单:数据驱动,有多少数据就有多少项

但列表也是性能重灾区。几百条数据一次性渲染:

  • 内存占用高
  • 首屏加载慢
  • 滚动掉帧
  • 低端机直接卡死

所以,列表的性能优化是每个开发者的必修课。

「民族图鉴」的列表场景

「民族图鉴」项目中有多种列表场景:

场景 数据量 特点 技术方案
民族列表页 56 项 中等数量,卡片式 List + ForEach
收藏列表 不定 可能很少也可能很多 List + ForEach
浏览历史 不定 时间倒序 List + ForEach
首页精选网格 8 项 少量,两列网格 Grid + ForEach
测验题目列表 10 题 少量,每题一屏 Swiper 或 List

56 个民族,数量不算特别大,用 ForEach 就够了。但我们还是会讲 LazyForEach,因为当数据量变大的时候(比如几百首民族音乐),必须用懒加载。


🛠️ 核心实现

步骤1:ForEach——最基础的列表渲染

1.1 基本用法

ForEach 是 ArkUI 中最基础的列表渲染组件,用来遍历数组生成多个子组件。

@Entry
@Component
struct SimpleList {
  @State names: string[] = ['汉族', '壮族', '满族', '回族', '苗族'];

  build() {
    Column({ space: 10 }) {
      // 遍历 names 数组,为每个元素生成一个 Text
      ForEach(this.names, (name: string) => {
        Text(name)
          .fontSize(16)
          .padding(16)
          .width('100%')
          .backgroundColor('#FFFFFF')
          .borderRadius(8)
      }, (name: string) => name)  // key 生成函数
    }
    .padding(16)
    .width('100%')
  }
}

ForEach 的三个参数

参数 类型 说明
第一个 数组 要遍历的数据源
第二个 函数 每个数组项生成什么 UI
第三个 函数 生成唯一 key 的函数(可选但推荐)
1.2 key 的作用

第三个参数(key 生成函数)非常重要,但很多人不理解为什么需要它。

为什么需要 key?

当数组变化的时候(比如增加、删除、排序),框架需要知道哪些项没变、哪些是新增的、哪些是移动了。没有 key 的话,框架只能全部重建,性能差;有了 key,框架可以精准地 diff,只更新变化的部分。

key 的要求

  1. 唯一:同一个列表中,每个项的 key 不能重复
  2. 稳定:同一个项的 key 不应该变(不要用 index 当 key)
  3. 简单:字符串或数字最好

为什么不要用 index 当 key?

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

如果数组排序了,index 变了,但其实数据还是那些数据。这时候框架会以为所有项都变了,全部重建,性能很差。

正确做法:用数据的唯一 ID

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

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

1.3 ForEach 的限制

ForEach 虽然简单,但有一些限制:

  1. 不能单独使用:必须放在容器组件(Column、Row、List、Grid 等)里面
  2. 完整渲染:所有项一次性全部渲染,不管看不看得见
  3. 数据量不宜太大:建议几十条以内,太多了性能差

如果有几百上千条数据,ForEach 就不够用了,这时候需要 List + LazyForEach。


步骤2:List——高性能列表容器

2.1 为什么要用 List?

你可能会想:我用 Scroll + Column + ForEach 也能做出滚动列表啊,为什么要用 List?

答案是:性能差距很大

特性 Scroll + Column + ForEach List + LazyForEach
渲染方式 所有项一次性渲染 按需渲染,滑到哪渲染到哪
内存占用 高(所有项都在内存里) 低(只有可见的 + 缓冲区)
首屏速度 慢(要渲染所有项) 快(只渲染首屏可见的)
复用机制 没有,每项都是独立的 有,滚动时回收复用
适用数据量 几十条以内 几百上千条都行

List 组件内置了虚拟化(Virtualization)组件复用(Recycling) 机制,这是它性能好的根本原因。

2.2 List 的基本用法
@Entry
@Component
struct EthnicListPage {
  @State ethnicList: EthnicGroup[] = ETHNIC_GROUPS;

  build() {
    List({ space: 12 }) {
      ForEach(this.ethnicList, (ethnic: EthnicGroup) => {
        ListItem() {
          EthnicCard({ ethnic: ethnic })
        }
      }, (ethnic: EthnicGroup) => ethnic.id)
    }
    .width('100%')
    .height('100%')
    .padding({ left: 16, right: 16, top: 16, bottom: 24 })
    .scrollBar(BarState.Off)
    .edgeEffect(EdgeEffect.Spring)
  }
}

注意:List 的子组件必须是 ListItem,不能直接放自定义组件。

2.3 List 的常用属性
属性 类型 说明
space number 列表项之间的间距
initialIndex number 初始滚动到第几项
scrollBar BarState 滚动条显示状态
edgeEffect EdgeEffect 边缘效果(弹簧/淡出/无)
listDirection Axis 滚动方向(垂直/水平)
divider object 分割线样式
lanes number | Length 列数(多列布局时用)

EdgeEffect 对比

效果 说明 适用场景
EdgeEffect.Spring 弹簧效果,拉到底会弹回来 列表类(最常用,体验好)
EdgeEffect.Fade 淡出效果,拉到底有渐变阴影 页面内容
EdgeEffect.None 没有效果,拉到底就不动了 很少用
2.4 ListItemGroup:列表分组与粘性标题

很多列表都需要分组显示,比如联系人按字母分组、民族按地区分组。每组有一个标题,滚动的时候标题会"粘"在顶部,直到下一组的标题把它顶上去。这就是 sticky header(粘性标题)

ArkUI 提供了 ListItemGroup 组件来实现列表分组。

基本用法

@Entry
@Component
struct GroupedListPage {
  @State ethnicGroups: { category: string; list: EthnicGroup[] }[] = [
    {
      category: '华北地区',
      list: ETHNIC_GROUPS.filter(e => e.region === '华北')
    },
    {
      category: '华东地区',
      list: ETHNIC_GROUPS.filter(e => e.region === '华东')
    },
    {
      category: '西南地区',
      list: ETHNIC_GROUPS.filter(e => e.region === '西南')
    },
  ];

  build() {
    List({ space: 8 }) {
      ForEach(this.ethnicGroups, (group: { category: string; list: EthnicGroup[] }) => {
        ListItemGroup({ header: this.buildHeader(group.category) }) {
          ForEach(group.list, (ethnic: EthnicGroup) => {
            ListItem() {
              EthnicCard({ ethnic: ethnic })
            }
          }, (ethnic: EthnicGroup) => ethnic.id)
        }
      }, (group: { category: string; list: EthnicGroup[] }) => group.category)
    }
    .width('100%')
    .height('100%')
    .sticky(StickyStyle.Header)  // 粘性标题
  }

  @Builder
  buildHeader(title: string) {
    Text(title)
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .width('100%')
      .padding({ left: 16, right: 16, top: 12, bottom: 8 })
      .backgroundColor('#F5F5F5')
  }
}

关键属性

属性 类型 说明
ListItemGroup 组件 列表分组容器
header CustomBuilder 分组标题组件
footer CustomBuilder 分组底部组件(可选)
sticky StickyStyle 粘性效果(Header/Footer/Both/None)

StickyStyle 枚举

说明
StickyStyle.None 不粘性(默认)
StickyStyle.Header 标题粘性,滚动时停在顶部
StickyStyle.Footer 底部粘性
StickyStyle.Both 标题和底部都粘性

「民族图鉴」的分组场景

「民族图鉴」可以按多种方式分组:

分组方式 说明 适用场景
按地区分组 华北、华东、西南、西北等 地理分布浏览
按拼音首字母 A、B、C、D… 字母索引快速查找
按人口数量 百万以上、十万以上等 人口规模浏览
按语系 汉藏语系、阿尔泰语系等 语言文化研究
2.5 List 的高级属性详解

让我们深入了解几个重要但容易被忽略的 List 属性:

1. listDirection:滚动方向

// 垂直滚动(默认)
List({ space: 12 }) { ... }
  .listDirection(Axis.Vertical)

// 水平滚动
List({ space: 12 }) { ... }
  .listDirection(Axis.Horizontal)

水平列表的适用场景

  • 横向滚动的 Banner
  • 横向卡片列表
  • 标签栏滚动

2. scrollBar:滚动条

// 不显示滚动条
.scrollBar(BarState.Off)

// 自动显示(滚动时显示,默认)
.scrollBar(BarState.Auto)

// 一直显示
.scrollBar(BarState.On)

3. edgeEffect:边缘效果

// 弹簧效果(推荐,体验好)
.edgeEffect(EdgeEffect.Spring)

// 淡出效果
.edgeEffect(EdgeEffect.Fade)

// 无效果
.edgeEffect(EdgeEffect.None)

4. divider:分割线

.divider({
  strokeWidth: 1,        // 线宽
  color: '#EEEEEE',      // 颜色
  startMargin: 16,       // 左边距
  endMargin: 16          // 右边距
})

5. lanes:多列列表

// 两列列表
List({ space: 12 }) { ... }
  .lanes(2)

// 自适应列数(根据宽度自动调整)
List({ space: 12 }) { ... }
  .lanes('auto')

步骤3:LazyForEach——大数据量的懒加载

3.1 为什么需要懒加载?

假设你有 1000 条数据,如果用 ForEach:

  • 1000 个组件一次性创建
  • 1000 个组件都在内存里
  • 首屏要等 1000 个组件渲染完才能显示

用户看到的是什么?白屏很久,然后突然出来一长串。滚动的时候也会卡,因为有太多组件在内存里。

懒加载(Lazy Loading) 的思路是:

  • 只渲染当前可见的项
  • 滚动到哪,渲染到哪
  • 滚出去的项,回收复用

这样:

  • 首屏只渲染十几项,速度飞快
  • 内存里只有二三十项(可见 + 缓冲区),占用小
  • 滚动时回收复用,不会越滚越卡
3.2 IDataSource 接口深度解析

LazyForEach 需要一个特殊的数据源——实现了 IDataSource 接口的对象。这个接口是懒加载的核心,让我们深入理解它。

IDataSource 接口定义

interface IDataSource {
  // 数据总数量
  totalCount(): number;

  // 获取指定位置的数据
  getData(index: number): any;

  // 注册数据变化监听器(框架调用)
  registerDataChangeListener(listener: DataChangeListener): void;

  // 注销数据变化监听器(框架调用)
  unregisterDataChangeListener(listener: DataChangeListener): void;
}

interface DataChangeListener {
  // 数据全部重新加载
  onDataReloaded(): void;

  // 某一项数据改变
  onDataChanged(index: number): void;

  // 新增一项
  onDataAdd(index: number): void;

  // 删除一项
  onDataDelete(index: number): void;

  // 移动一项
  onDataMoved(from: number, to: number): void;
}

为什么设计成这样?

IDataSource 接口的设计体现了数据源与 UI 分离的思想:

  1. UI 不问数据从哪来:LazyForEach 只关心"第 index 项是什么",不关心数据是本地的、网络的还是数据库的
  2. 数据变化主动通知:数据变了,数据源主动通知 UI 更新,而不是 UI 轮询
  3. 按需加载:UI 只在需要的时候才调用 getData() 取数据

监听器机制的工作原理

框架(List/LazyForEach)              数据源(IDataSource)
      │                                      │
      │  registerDataChangeListener(listener) │
      │─────────────────────────────────────>│
      │                                      │
      │           数据变化了                   │
      │                                      │
      │  notifyDataChange(index)             │
      │<─────────────────────────────────────│
      │                                      │
      │  调用 getData(index) 重新获取数据       │
      │─────────────────────────────────────>│
      │                                      │
      │  更新 UI                              │
      │                                      │

完整的 IDataSource 实现模板

/**
 * 通用数据源基类
 * 封装 IDataSource 的通用逻辑,业务数据源继承这个类即可
 */
abstract class BaseDataSource<T> implements IDataSource {
  protected dataList: T[] = [];
  private listeners: DataChangeListener[] = [];

  // ========== IDataSource 接口实现 ==========

  totalCount(): number {
    return this.dataList.length;
  }

  getData(index: number): T {
    return this.dataList[index];
  }

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

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

  // ========== 通知方法(子类调用) ==========

  protected notifyReload(): void {
    this.listeners.forEach(listener => listener.onDataReloaded());
  }

  protected notifyChange(index: number): void {
    this.listeners.forEach(listener => listener.onDataChanged(index));
  }

  protected notifyAdd(index: number): void {
    this.listeners.forEach(listener => listener.onDataAdd(index));
  }

  protected notifyDelete(index: number): void {
    this.listeners.forEach(listener => listener.onDataDelete(index));
  }

  protected notifyMove(from: number, to: number): void {
    this.listeners.forEach(listener => listener.onDataMoved(from, to));
  }

  // ========== 业务方法(子类可扩展) ==========

  setData(data: T[]): void {
    this.dataList = data;
    this.notifyReload();
  }

  add(index: number, item: T): void {
    this.dataList.splice(index, 0, item);
    this.notifyAdd(index);
  }

  update(index: number, item: T): void {
    this.dataList[index] = item;
    this.notifyChange(index);
  }

  remove(index: number): void {
    this.dataList.splice(index, 1);
    this.notifyDelete(index);
  }

  move(from: number, to: number): void {
    const item = this.dataList.splice(from, 1)[0];
    this.dataList.splice(to, 0, item);
    this.notifyMove(from, to);
  }

  getItem(index: number): T | undefined {
    return this.dataList[index];
  }

  getAll(): T[] {
    return [...this.dataList];
  }

  clear(): void {
    this.dataList = [];
    this.notifyReload();
  }
}

/**
 * 民族数据源(业务类,继承 BaseDataSource)
 */
class EthnicDataSource extends BaseDataSource<EthnicGroup> {
  // 可以在这里添加业务特有的方法
  // 比如分页加载、搜索过滤等

  async loadPage(page: number, pageSize: number): Promise<void> {
    // 模拟网络请求
    const result = await fetchEthnicList(page, pageSize);
    if (page === 1) {
      this.setData(result.list);
    } else {
      const startIndex = this.totalCount();
      result.list.forEach((item, i) => {
        this.dataList.push(item);
        this.notifyAdd(startIndex + i);
      });
    }
  }
}

使用方式

@Entry
@Component
struct EthnicListPage {
  private dataSource: EthnicDataSource = new EthnicDataSource();

  aboutToAppear(): void {
    this.dataSource.loadPage(1, 20);
  }

  build() {
    List() {
      LazyForEach(this.dataSource, (ethnic: EthnicGroup) => {
        ListItem() {
          EthnicCard({ ethnic: ethnic })
        }
      }, (ethnic: EthnicGroup) => ethnic.id)
    }
    .cachedCount(5)
  }
}

继承 BaseDataSource 的好处

  1. 不用每个数据源都写一遍重复的监听器逻辑
  2. 业务代码更简洁,专注于业务逻辑
  3. 统一的接口,方便维护
3.3 ForEach vs LazyForEach:原理性差异

很多人知道 LazyForEach 性能好,但好在哪里?底层原理是什么?让我们深入对比一下。

1. 渲染时机不同

ForEach:一次性全部渲染

  数据:[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ... 1000]
              │
              ▼
  一次性创建 1000 个组件
              │
              ▼
  全部渲染完才显示首屏


LazyForEach:按需渲染

  数据:[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ... 1000]
              │
              ▼
  只创建可见的 + 缓存的(比如 15 个)
              │
              ▼
  首屏很快显示
              │
              ▼
  滚动时,创建新的,回收旧的

2. 内存占用不同

维度 ForEach LazyForEach
组件数量 = 数据总量 = 可见数量 + 缓存数量
内存占用 高(线性增长) 低(基本恒定)
1000 条数据 1000 个组件在内存 约 20-30 个在内存

3. 首屏加载速度不同

  • ForEach:首屏时间 ∝ 数据总量 × 单项渲染时间
  • LazyForEach:首屏时间 ∝ 可见数量 × 单项渲染时间

比如 1000 条数据,每条渲染 1ms,一屏显示 15 条:

  • ForEach:首屏需要 1000ms(1秒)
  • LazyForEach:首屏需要 15ms(几乎瞬间)

4. 组件复用机制

LazyForEach 配合 List 使用时,有组件复用(Recycling) 机制:

滚动时的复用机制:

  ┌─────────────┐        ┌─────────────┐
  │  可见区域    │        │  可见区域    │
  │ ┌─────────┐ │        │ ┌─────────┐ │
  │ │ Item 1  │ │        │ │ Item 2  │ │
  │ ├─────────┤ │   向上  │ ├─────────┤ │
  │ │ Item 2  │ │  滚动  │ │ Item 3  │ │
  │ ├─────────┤ │   →    │ ├─────────┤ │
  │ │ Item 3  │ │        │ │ Item 4  │ │
  │ └─────────┘ │        │ └─────────┘ │
  └─────────────┘        └─────────────┘

  Item 1 滚出去了 → 回收这个组件 ↘
                                    → 重新填充数据 → Item 4
  需要显示 Item 4 了 → 拿回收的组件 ↗

复用的好处:

  • 不用频繁创建/销毁组件(创建组件开销大)
  • 减少 GC(垃圾回收)压力
  • 滚动更流畅

5. 适用场景对比

特性 ForEach LazyForEach
数据量 小(建议 < 50) 大(建议 > 50)
首屏速度 慢(全渲染) 快(按需渲染)
内存占用
滚动性能 差(多了会卡) 好(复用机制)
实现复杂度 简单 稍复杂(需要 IDataSource)
动态数据 简单(直接改数组) 需要调用 notify
「民族图鉴」场景 56个民族列表 音乐列表、搜索结果

6. 决策树:什么时候用哪个?

需要渲染列表吗?
  │
  ├─ 数据量 < 20 条
  │   └─ ForEach(简单够用)
  │
  ├─ 数据量 20-100 条
  │   ├─ 卡片简单(纯文字) → ForEach 也行
  │   └─ 卡片复杂(多图) → 推荐 LazyForEach
  │
  └─ 数据量 > 100 条
      └─ 必须 LazyForEach

💡 经验之谈:不要过早优化,但也不要事后救火。56 个民族用 ForEach 完全没问题,但如果以后要加民族音乐(几百首),就得用 LazyForEach 了。心里有这个意识,写代码的时候留好扩展空间。


@Entry
@Component
struct BigListPage {
  private dataSource: EthnicDataSource = new EthnicDataSource();

  aboutToAppear(): void {
    // 加载数据
    this.dataSource.setData(BIG_ETHNIC_LIST);
  }

  build() {
    List({ space: 12 }) {
      // 用 LazyForEach,而不是 ForEach
      LazyForEach(this.dataSource, (ethnic: EthnicGroup) => {
        ListItem() {
          EthnicCard({ ethnic: ethnic })
        }
      }, (ethnic: EthnicGroup) => ethnic.id)
    }
    .width('100%')
    .height('100%')
    .cachedCount(5)  // 缓存 5 项(上下各缓冲几项)
  }
}

cachedCount 属性

  • 设置列表项的缓存数量
  • 数值越大,越不容易出现白屏,但内存占用越高
  • 一般设 3-10 就够了
  • 默认值是 0(但框架内部也有自己的缓冲逻辑)
3.4 「民族图鉴」中的 LazyForEach 场景

「民族图鉴」目前只有 56 个民族,用 ForEach 就够了。但有些场景可以考虑用 LazyForEach:

场景 数据量 用不用 LazyForEach
民族列表 56 不用(ForEach 足够)
音乐列表 可能几百首 建议用
搜索结果 不确定 建议用
冷知识列表 几十条 不用

什么时候该用 LazyForEach?

  • 数据量 > 50 条,考虑一下
  • 数据量 > 100 条,推荐用
  • 数据量 > 500 条,必须用
  • 图片多、卡片复杂的,阈值可以低一点

步骤4:Grid——网格布局

4.1 什么时候用 Grid?

列表是一行一个项,但有时候需要多列布局,比如:

  • 首页的民族精选(2列)
  • 民族图鉴页(3列瀑布流?)
  • 图片相册(多列网格)

这时候就用 Grid 组件——网格布局的列表。

4.2 Grid 的基本用法
@Entry
@Component
struct GridPage {
  @State ethnicList: EthnicGroup[] = ETHNIC_GROUPS.slice(0, 8);

  build() {
    Grid() {
      ForEach(this.ethnicList, (ethnic: EthnicGroup) => {
        GridItem() {
          // 网格项内容
          Column({ space: 8 }) {
            Image($rawfile(`coverImage/${ethnic.coverImage}`))
              .width('100%')
              .aspectRatio(1)
              .objectFit(ImageFit.Cover)
              .borderRadius(8)

            Text(ethnic.name)
              .fontSize(14)
              .fontWeight(FontWeight.Medium)
              .maxLines(1)
          }
          .width('100%')
        }
      }, (ethnic: EthnicGroup) => ethnic.id)
    }
    .columnsTemplate('1fr 1fr')  // 两列,等宽
    .rowsGap(12)                // 行距
    .columnsGap(12)             // 列距
    .padding(16)
    .width('100%')
    .height('100%')
  }
}

注意:Grid 的子组件必须是 GridItem

4.3 关键属性详解

columnsTemplate / rowsTemplate

定义列和行的模板。

// 两列等宽
.columnsTemplate('1fr 1fr')

// 三列,中间宽两边窄
.columnsTemplate('1fr 2fr 1fr')

// 四列等宽
.columnsTemplate('1fr 1fr 1fr 1fr')

1fr 表示一份(fraction),几份就是几列。

rowsGap / columnsGap

行距和列距,和 List 的 space 类似,但 Grid 两个方向都有。

layoutDirection

布局方向,默认是按行排列(一行一行从左到右,从上到下)。

4.4 Grid 的懒加载

Grid 也支持 LazyForEach,数据量大的时候用:

Grid() {
  LazyForEach(this.dataSource, (item: Item) => {
    GridItem() {
      // ...
    }
  })
}

步骤5:列表性能优化实战

学了这么多组件,让我们系统总结一下列表性能优化的各种技巧。从基础到进阶,一共 10 个优化技巧。

5.1 技巧1:选择合适的渲染方式

这是最基础也是最重要的优化——选对方案。

数据量 推荐方案 原因
< 20 条 Column/Row + ForEach 简单,够用
20-100 条 List + ForEach 有复用,性能不错
> 100 条 List + LazyForEach 懒加载,内存占用低
多列网格 Grid + (ForEach/LazyForEach) 网格布局

「民族图鉴」实践:56 个民族用 List + ForEach,简单够用;如果以后加民族音乐(几百首),就换成 LazyForEach。

5.2 技巧2:用唯一稳定的 key

key 是 diff 优化的基础,没有好的 key,其他优化都是白搭。

// ❌ 不要用 index
ForEach(items, (item, index) => { ... }, (item, index) => index)

// ✅ 用数据唯一 ID
ForEach(items, (item) => { ... }, (item) => item.id)

为什么不能用 index?

  • 数组排序后,index 变了但数据没变
  • 框架会误以为所有项都变了,全部重建
  • 性能差,还可能有状态错乱

key 的三个原则

  1. 唯一:同一列表中不能重复
  2. 稳定:同一项的 key 不应该变
  3. 简单:字符串或数字最好
5.3 技巧3:简化列表项结构

列表项越简单,渲染越快,滚动越流畅。

优化方向

  1. 减少嵌套层级:尽量控制在 3-5 层以内
  2. 抽取小组件:复杂卡片拆成子组件
  3. 避免不必要的组件:能不用就不用
  4. 减少条件渲染:频繁切换的条件渲染会影响性能
// ❌ 不好:嵌套太深
Column() {
  Row() {
    Column() {
      Row() {
        Text(...)
      }
    }
  }
}

// ✅ 好:结构扁平,用堆叠布局
Stack() {
  Image(...)
  Column() {
    Text(...)
  }
}
5.4 技巧4:提前计算,避免 build 时耗时

不要在 build 方法里做耗时计算,每次渲染都算一遍,性能差。

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

// ✅ 好:提前算好,存到数据里
ForEach(this.list, (item: Item) => {
  Text(item.calculatedValue)
})

常见的耗时操作

  • 复杂的格式化(日期、金额)
  • 字符串拼接/处理
  • 数组过滤/排序
  • 图片路径处理

最佳实践:在拿到数据的时候就处理好,存到数据模型里,build 的时候直接用。

5.5 技巧5:优化图片加载

列表里的图片,往往是性能瓶颈。

优化要点

  1. 用合适的尺寸:不要用 2000px 的大图显示在 100px 的位置
  2. 用合适的格式:WebP 优先,比 JPG/PNG 小
  3. objectFit 用 Cover:保持比例,填满容器
  4. 懒加载:LazyForEach 本身就是懒加载,图片也会按需加载
  5. 占位图:加载过程中显示占位图,避免布局跳动
Image(item.coverUrl)
  .width(100)
  .height(100)
  .objectFit(ImageFit.Cover)
  .borderRadius(8)
5.6 技巧6:减少滚动回调中的耗时操作

onScroll、onScrollIndex 等回调会在滚动时频繁触发(每帧都可能触发),不要在里面做复杂的事情。

// ❌ 不好:每次滚动都做很多事
List() { ... }
  .onScroll(() => {
    this.doSomethingHeavy();  // 耗时操作,会卡
  })

// ✅ 好:防抖节流,或者只在需要的时候做
List() { ... }
  .onScroll((scrollOffset) => {
    // 简单的逻辑可以,复杂的要防抖
    this.updateHeaderOpacity(scrollOffset);
  })

常见场景优化

  • 顶部导航栏透明度变化 → 可以直接做,计算简单
  • 滚动到底部加载更多 → 用 onReachEnd,不要自己判断
  • 滚动时隐藏浮动按钮 → 加防抖,不要每帧都改
5.7 技巧7:合理设置 cachedCount

LazyForEach 的 cachedCount 控制缓存数量,不是越大越好,也不是越小越好。

cachedCount 内存占用 白屏概率 滚动流畅度
小(1-2) 高(快速滚动可能白屏) 一般
中(3-10)
大(>20) 很低 一般(内存压力大了反而卡)

推荐值

  • 简单列表项(纯文字):3-5
  • 复杂列表项(多图):5-10
  • 根据实际测试调整
5.8 技巧8:使用 List 而不是 Scroll + Column

List 内置了虚拟化和组件复用,Scroll + Column + ForEach 没有。

// ❌ 性能差:Scroll + Column + ForEach
Scroll() {
  Column() {
    ForEach(this.list, (item) => { ... })
  }
}

// ✅ 性能好:List + ForEach / LazyForEach
List() {
  ForEach(this.list, (item) => {
    ListItem() { ... }
  })
}

List 的优势

  • 组件复用机制
  • 滚动性能优化
  • 内置分割线、分组等功能
  • 支持 sticky header
5.9 技巧9:避免频繁的数据更新

数据每次更新,框架都要 diff 和重新渲染,太频繁了会卡。

优化方法

  1. 批量更新:多次数据修改合并成一次
  2. 减少不必要的更新:只有真的变了才更新
  3. 用局部更新:只更新变化的那一项,不要全量刷新
// ❌ 不好:循环里一次次更新
for (let i = 0; i < list.length; i++) {
  this.dataSource.updateItem(i, list[i]);
}

// ✅ 好:批量更新
this.dataSource.setData(newList);
5.10 技巧10:合理使用组件复用与缓存

除了框架自带的复用,我们还可以自己做一些缓存优化。

常见做法

  1. 列表项组件用 @Observed + @ObjectLink:精细控制刷新
  2. 图片缓存:用框架的图片缓存机制
  3. 数据缓存:列表数据缓存,不要每次进入页面都重新加载
  4. 避免在 onPageShow 里全量刷新:只刷新变化的部分

「民族图鉴」实践:收藏列表用 onPageShow 时,只从 StorageService 取最新的收藏状态,不重新加载整个列表数据。


步骤6:实战——实现「民族图鉴」字母索引列表

理论讲了这么多,让我们动手实现一个实用的功能:字母索引列表。就像手机通讯录那样,右边有 A-Z 的字母索引,点哪个字母就滚到哪个分组。

这是「民族图鉴」百科页的一个实用功能——56 个民族按拼音首字母分组,右边字母索引快速定位。

6.1 需求分析

功能需求

  1. 民族按拼音首字母分组(A、B、C…)
  2. 每组有 sticky header(粘性标题)
  3. 右侧有字母索引栏(A-Z)
  4. 点击字母,滚动到对应分组
  5. 滚动时,高亮当前所在的字母

技术方案

  • List + ListItemGroup 实现分组和 sticky header
  • Scroller 控制滚动位置
  • 右侧索引栏用自定义组件
  • onScrollIndex 监听当前位置,更新高亮
6.2 数据准备

首先,我们需要把民族列表按拼音首字母分组:

// 分组后的数据结构
interface EthnicGroupItem {
  letter: string;        // 首字母(A、B、C...)
  list: EthnicGroup[];   // 该字母下的民族列表
}

// 分组函数
function groupByFirstLetter(list: EthnicGroup[]): EthnicGroupItem[] {
  const groupMap = new Map<string, EthnicGroup[]>();

  for (const ethnic of list) {
    const firstLetter = ethnic.pinyin[0].toUpperCase();
    if (!groupMap.has(firstLetter)) {
      groupMap.set(firstLetter, []);
    }
    groupMap.get(firstLetter)!.push(ethnic);
  }

  // 按字母排序
  const result: EthnicGroupItem[] = [];
  const letters = Array.from(groupMap.keys()).sort();
  for (const letter of letters) {
    result.push({
      letter: letter,
      list: groupMap.get(letter)!
    });
  }

  return result;
}
6.3 完整实现
@Entry
@Component
struct AlphabetIndexList {
  private scroller: Scroller = new Scroller();
  @State groupedList: EthnicGroupItem[] = [];
  @State currentLetter: string = 'A';
  private allLetters: string[] = [];

  aboutToAppear(): void {
    this.groupedList = groupByFirstLetter(ETHNIC_GROUPS);
    this.allLetters = this.groupedList.map(g => g.letter);
  }

  build() {
    Stack() {
      // 主列表
      List({ scroller: this.scroller, space: 4 }) {
        ForEach(this.groupedList, (group: EthnicGroupItem, groupIndex: number) => {
          ListItemGroup({
            header: this.buildGroupHeader(group.letter)
          }) {
            ForEach(group.list, (ethnic: EthnicGroup) => {
              ListItem() {
                this.buildEthnicItem(ethnic)
              }
            }, (ethnic: EthnicGroup) => ethnic.id)
          }
        }, (group: EthnicGroupItem) => group.letter)
      }
      .width('100%')
      .height('100%')
      .sticky(StickyStyle.Header)
      .scrollBar(BarState.Off)
      .edgeEffect(EdgeEffect.Spring)
      .onScrollIndex((first: number) => {
        // 滚动时更新当前字母
        this.updateCurrentLetter(first);
      })

      // 右侧字母索引栏
      this.buildAlphabetBar()
    }
    .width('100%')
    .height('100%')
  }

  // 分组标题
  @Builder
  buildGroupHeader(letter: string) {
    Text(letter)
      .fontSize(14)
      .fontWeight(FontWeight.Bold)
      .width('100%')
      .padding({ left: 16, right: 16, top: 8, bottom: 6 })
      .backgroundColor('#F5F5F5')
      .foregroundColor('#666666')
  }

  // 民族列表项
  @Builder
  buildEthnicItem(ethnic: EthnicGroup) {
    Row({ space: 12 }) {
      Image($rawfile(`coverImage/${ethnic.coverImage}`))
        .width(48)
        .height(48)
        .borderRadius(24)
        .objectFit(ImageFit.Cover)

      Column({ space: 4 }) {
        Text(ethnic.name)
          .fontSize(16)
          .fontWeight(FontWeight.Medium)

        Text(ethnic.pinyin)
          .fontSize(12)
          .fontColor('#999999')
      }
      .alignItems(HorizontalAlign.Start)

      Blank()

      Text(ethnic.region)
        .fontSize(12)
        .fontColor('#999999')
    }
    .width('100%')
    .padding(12)
    .backgroundColor('#FFFFFF')
    .borderRadius(8)
    .margin({ left: 12, right: 12 })
    .onClick(() => {
      this.navigateToDetail(ethnic.id);
    })
  }

  // 字母索引栏
  @Builder
  buildAlphabetBar() {
    Column({ space: 2 }) {
      ForEach(this.allLetters, (letter: string) => {
        Text(letter)
          .fontSize(12)
          .fontWeight(this.currentLetter === letter ? FontWeight.Bold : FontWeight.Normal)
          .fontColor(this.currentLetter === letter ? '#409EFF' : '#999999')
          .width(24)
          .height(20)
          .textAlign(TextAlign.Center)
          .borderRadius(10)
          .backgroundColor(this.currentLetter === letter ? 'rgba(64, 158, 255, 0.1)' : 'transparent')
          .onClick(() => {
            this.scrollToLetter(letter);
          })
      })
    }
    .width(32)
    .padding({ top: 8, bottom: 8 })
    .backgroundColor('rgba(255, 255, 255, 0.9)')
    .borderRadius(16)
    .margin({ right: 4 })
    .position({ x: '100%', y: '50%' })
    .translate({ x: -36, y: '-50%' })
  }

  // 滚动到指定字母
  scrollToLetter(letter: string): void {
    // 找到这个字母分组的第一项在整个列表中的索引
    let totalIndex = 0;
    for (const group of this.groupedList) {
      if (group.letter === letter) {
        break;
      }
      totalIndex += group.list.length + 1; // +1 是 header
    }
    this.scroller.scrollToIndex(totalIndex);
    this.currentLetter = letter;
  }

  // 更新当前字母
  updateCurrentLetter(firstVisibleIndex: number): void {
    // 计算第一个可见项属于哪个分组
    let totalIndex = 0;
    for (const group of this.groupedList) {
      totalIndex += group.list.length + 1; // +1 是 header
      if (firstVisibleIndex < totalIndex) {
        if (this.currentLetter !== group.letter) {
          this.currentLetter = group.letter;
        }
        break;
      }
    }
  }

  navigateToDetail(ethnicId: string): void {
    // 跳转到详情页
  }
}
6.4 实现要点解析

1. 分组数据结构

  • 先把数据按字母分好组
  • 每组有一个 letter 和对应的 list
  • 提前计算好,不要滚动时才分组

2. Sticky Header

  • 用 ListItemGroup 的 header 配合 List.sticky(StickyStyle.Header)
  • 滚动时标题自动粘在顶部

3. Scroller 控制滚动

  • 创建 Scroller 对象,传给 List
  • 点击字母时调用 scrollToIndex 滚动到指定位置

4. onScrollIndex 监听位置

  • 监听第一个可见项的索引
  • 计算当前在哪个字母分组
  • 更新右侧索引栏的高亮状态

5. 性能优化点

  • 分组数据提前计算好
  • 用 ForEach 就够了(56 项不多)
  • 字母索引栏用定位,不影响主列表布局
  • 点击字母时直接滚动到索引,体验流畅

「民族图鉴」中的应用
这个字母索引列表可以用在百科页,用户可以快速定位到某个拼音首字母的民族,比一点点滚动快多了。特别是 56 个民族,翻起来也要一会儿,有了字母索引,用户体验提升明显。


⚠️ 常见问题与解决方案

问题1:列表滚动卡顿,怎么办?

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

排查方向

原因 概率 解决方法
一次性渲染太多项 30% 改用 LazyForEach
列表项太复杂/嵌套深 25% 简化布局,减少嵌套
图片太大太多 20% 图片压缩,用合适尺寸
没有设置 key 15% 加唯一 key
onScroll 里有耗时操作 10% 防抖,减少操作

优化步骤

第1步:确认用的是 List 还是 Scroll + Column?
    → Scroll + Column → 改用 List
    ↓ 已经是 List
第2步:数据量多大?
    → >100 条 → 改用 LazyForEach
    ↓ 数量不多
第3步:列表项复杂吗?
    → 嵌套很深,图片很多 → 简化布局,优化图片
    ↓ 还好
第4步:key 设置了吗?是唯一稳定的吗?
    → 没有/用了 index → 改成数据唯一 ID
    ↓ 设置了
第5步:onScroll 里有什么?
    → 复杂逻辑 → 防抖/节流,或移出去

问题2:ForEach 和 LazyForEach 怎么选?

快速决策

数据量多少?
  │
  ├─ < 20 条 → ForEach(简单,够用)
  │
  ├─ 20-100 条
  │   ├─ 卡片简单(文字为主) → ForEach 也行
  │   └─ 卡片复杂(多图/多组件) → 推荐 LazyForEach
  │
  └─ > 100 条 → 必须用 LazyForEach

另外考虑因素

  • 卡片越复杂,阈值越低(越应该用 LazyForEach)
  • 低端机多,阈值低一些
  • 快速滚动多,阈值低一些

💡 经验法则:拿不准的时候,先用 ForEach,跑起来看看。如果卡顿再换成 LazyForEach。不要过早优化,但也不要等到用户反馈卡了才优化。


问题3:列表项高度不固定怎么办?

现象
每个列表项的高度不一样(比如有的简介长有的短),LazyForEach 还能用吗?

答案:可以用,没问题。

List + LazyForEach 支持动态高度,框架会自动测量每个项的高度。

List() {
  LazyForEach(this.dataSource, (item: Item) => {
    ListItem() {
      // 内容多高就多高,不用固定高度
      Column() {
        Text(item.title)
        Text(item.content)
          .fontSize(14)
          .lineHeight(22)
      }
      .width('100%')
    }
  })
}

注意:动态高度比固定高度性能稍差一点(因为要测量),但大多数场景感知不到。除非特别卡,否则不用刻意追求固定高度。


问题4:列表怎么跳转到指定位置?

现象
想让列表滚动到第 20 项,或者滚动到顶部/底部。

解决方法:用 Scroller

@Entry
@Component
struct MyListPage {
  private scroller: Scroller = new Scroller();
  @State list: Item[] = [];

  build() {
    Column() {
      Button('跳到第 20 项')
        .onClick(() => {
          // 滚动到指定位置
          this.scroller.scrollToIndex(20);
        })

      Button('滚到顶部')
        .onClick(() => {
          this.scroller.scrollToIndex(0);
        })

      List({ scroller: this.scroller }) {
        ForEach(this.list, (item: Item) => {
          ListItem() {
            Text(item.name)
          }
        })
      }
      .layoutWeight(1)
    }
  }
}

Scroller 的常用方法

方法 说明
scrollToIndex(index) 滚动到指定项
scrollTo(value) 滚动到指定像素位置
scrollPage(up/down) 滚动一页
currentOffset() 获取当前滚动偏移
isAtEnd() 是否到底部

问题5:列表怎么下拉刷新/上拉加载?

现象
列表需要下拉刷新和上拉加载更多的功能。

解决方法

下拉刷新:用 Refresh 组件包裹

@Entry
@Component
struct RefreshListPage {
  @State isRefreshing: boolean = false;
  @State list: Item[] = [];

  build() {
    Refresh({
      refreshing: this.isRefreshing,
      onRefresh: () => {
        // 下拉刷新回调
        this.refreshData();
      }
    }) {
      List() {
        ForEach(this.list, (item: Item) => {
          ListItem() {
            Text(item.name)
          }
        })
      }
    }
  }

  refreshData(): void {
    this.isRefreshing = true;
    // 加载新数据...
    setTimeout(() => {
      // 加载完了
      this.isRefreshing = false;
    }, 2000);
  }
}

上拉加载更多:监听 onScrollEnd 或者用 onReachEnd

List() {
  ForEach(this.list, (item: Item) => {
    ListItem() {
      Text(item.name)
    }
  })

  // 底部加载提示
  if (this.isLoadingMore) {
    ListItem() {
      Text('加载中...')
        .width('100%')
        .textAlign(TextAlign.Center)
        .padding(20)
    }
  }
}
.onReachEnd(() => {
  // 滚动到底部了,加载更多
  this.loadMore();
})

📝 本章小结

核心知识点

本文系统讲解了 ArkUI 的列表组件与性能优化:

1. ForEach

  • 最基础的列表渲染,遍历数组生成子组件
  • key 的重要性:唯一、稳定、不用 index
  • 适合小数据量(几十条以内)
  • 一次性渲染所有项,没有懒加载

2. List 组件

  • 高性能列表容器,内置虚拟化和复用
  • 子组件必须是 ListItem
  • 比 Scroll + Column 性能好很多
  • 支持滚动条、边缘效果、分割线等

3. LazyForEach 懒加载

  • 大数据量必备,按需渲染
  • 需要实现 IDataSource 接口
  • 首屏快、内存低、滚动流畅
  • cachedCount 控制缓存数量

4. Grid 网格布局

  • 多列网格布局
  • 子组件必须是 GridItem
  • columnsTemplate 定义列数和宽度
  • rowsGap / columnsGap 设置间距
  • 也支持 LazyForEach

5. 性能优化

  • 选对方案:数据量决定用 ForEach 还是 LazyForEach
  • 唯一 key:用数据 ID,不用 index
  • 简化列表项:减少嵌套,优化图片
  • 减少滚动回调里的耗时操作
  • 合理设置 cachedCount

最佳实践总结

key 一定要用数据唯一 ID

// ✅ 正确
ForEach(this.list, (item: Item) => {
  ItemCard({ item: item })
}, (item: Item) => item.id)

数据量 > 100 条用 LazyForEach

// 实现 IDataSource
// 用 List + LazyForEach
// 合理设置 cachedCount

列表容器用 List,不用 Scroll + Column

// ✅ List 有复用机制,性能好
List({ space: 12 }) {
  ForEach(...)
}

列表项组件要抽取

// 不要把所有逻辑都写在 ForEach 里
// 抽成单独的组件,结构清晰,也好复用
ForEach(this.list, (ethnic: EthnicGroup) => {
  EthnicCard({ ethnic: ethnic })
})

图片优化要重视

列表里的图片,往往是性能瓶颈
- 用合适的尺寸,不要大图小用
- 用合适的格式(WebP 优先)
- objectFit 用 Cover

不要过早优化,但也要心里有数

先写对,再优化
56 条数据,ForEach 就够了,不用硬上 LazyForEach
但要知道:数据量变大了,该换什么方案

下一步预告

在下一篇文章中,我们将:

  • 🌍 深入学习资源管理的多端适配机制
  • 📏 掌握 vp/fp/lpx 等单位的区别与使用场景
  • 🎨 理解限定符的匹配规则与优先级
  • 🌐 学会国际化(i18n)的实现方式
  • 🎯 掌握深色模式的适配方法
  • 🚀 为多设备、多语言、多主题适配打下基础

🔗 相关链接


💡 提示:列表是应用中最常见的界面,也是性能优化的重点。不要等到用户说卡了才来优化——写代码的时候就多想想:这个列表有多少条数据?卡片复杂吗?会不会快速滚动?心里有数,写出来的代码质量才高。好的列表体验,是流畅应用的基石。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐