ArkUI 列表与网格实战:List、Grid 与 LazyForEach
移动应用中,大量页面都可以归结为"展示一组数据":消息流适合纵向列表,频道入口适合网格,商品页面还可能同时包含分组、滚动定位、增删更新和空状态。
ArkUI 提供了 List、Grid、ForEach 与 LazyForEach 等能力。组件本身并不难,真正影响体验的是如何选择容器、怎样建立稳定的数据标识、何时使用懒加载,以及数据变化后如何准确通知界面。
本文从静态列表开始,逐步实现一个支持网格切换、数据增删和滚动定位的内容页面,并梳理常见性能问题。
一、List 与 Grid 分别解决什么问题
List 适合单轴滚动的数据集合,常见场景包括消息、联系人、设置项、新闻流和横向推荐内容。Grid 适合按行列排布内容,常见于相册、商品、应用入口和文件缩略图。
两者的直接子节点也不同:List 中使用 ListItem,Grid 中使用 GridItem。不要跳过这一层直接放业务组件,否则滚动容器无法正确管理子项。
二、用 List 构建基础列表
先定义页面需要的数据模型:
ts
class ArticleItem {
id: string
title: string
category: string
readCount: number
constructor(id: string, title: string, category: string, readCount: number) {
this.id = id
this.title = title
this.category = category
this.readCount = readCount
}
}
使用 ForEach 可以快速渲染一组规模较小的数据:
ts
@Entry
@Component
struct ArticleListPage {
@State articles: ArticleItem[] = [
new ArticleItem('arkui-layout', 'ArkUI 布局实践', 'ArkUI', 1280),
new ArticleItem('stage-model', 'Stage 模型解析', '工程化', 960),
new ArticleItem('network-request', '网络请求与错误处理', '网络', 1560)
]
build() {
Column() {
Text('技术文章')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.width('100%')
.padding({ left: 16, top: 16, bottom: 12 })
List({ space: 12 }) {
ForEach(this.articles, (item: ArticleItem) => {
ListItem() {
ArticleRow({ item: item })
}
}, (item: ArticleItem) => item.id)
}
.width('100%')
.layoutWeight(1)
.padding({ left: 16, right: 16, bottom: 16 })
}
.width('100%')
.height('100%')
}
}
@Component
struct ArticleRow {
item: ArticleItem
build() {
Row() {
Column({ space: 8 }) {
Text(this.item.title)
.fontSize(17)
.fontWeight(FontWeight.Medium)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(`${this.item.category} · ${this.item.readCount} 次阅读`)
.fontSize(13)
.fontColor('#6B7280')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding(16)
.backgroundColor('#FFFFFF')
.borderRadius(8)
}
}
List 的 space 用于设置列表项间距。页面给列表设置 layoutWeight(1),让它占据标题之外的剩余空间,同时获得明确的可滚动区域。
ForEach 的末尾参数是键生成函数。这里使用业务数据中的 id,框架就能识别某条数据是新增、删除、移动还是保持不变。
三、稳定的 key 为什么重要
下面这种写法看似方便,却不适合会增删或重排的数据:
ts
ForEach(this.articles, (item: ArticleItem) => {
ListItem() {
ArticleRow({ item: item })
}
}, (item: ArticleItem, index: number) => index.toString())
删除中间元素后,后续所有元素的索引都会变化。框架可能将原有节点错误地复用给另一条数据,输入状态、动画状态或子组件内部状态也可能跟着错位。
键值应在同一集合内唯一,在数据生命周期内稳定,并能直接表示业务实体身份。服务端主键、本地生成的 UUID、不可变的组合键都比数组索引更合适。标题、昵称等可能重复或变化的字段也不宜单独作为键值。
四、长列表使用 LazyForEach
ForEach 会为当前数据集合创建对应的子组件。数据较多时,可以使用 LazyForEach 按需创建可见区域附近的节点,离开缓存范围的节点可被框架回收。
LazyForEach 需要实现 IDataSource。一个可复用的基础数据源如下:
ts
class ArticleDataSource implements IDataSource {
private items: ArticleItem[] = []
private listeners: DataChangeListener[] = []
totalCount(): number {
return this.items.length
}
getData(index: number): ArticleItem {
return this.items[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 >= 0) {
this.listeners.splice(index, 1)
}
}
replaceAll(items: ArticleItem[]): void {
this.items = items
this.listeners.forEach((listener: DataChangeListener) => {
listener.onDataReloaded()
})
}
add(item: ArticleItem): void {
const index = this.items.length
this.items.push(item)
this.listeners.forEach((listener: DataChangeListener) => {
listener.onDataAdd(index)
})
}
update(index: number, item: ArticleItem): void {
if (index < 0 || index >= this.items.length) {
return
}
this.items[index] = item
this.listeners.forEach((listener: DataChangeListener) => {
listener.onDataChange(index)
})
}
remove(index: number): void {
if (index < 0 || index >= this.items.length) {
return
}
this.items.splice(index, 1)
this.listeners.forEach((listener: DataChangeListener) => {
listener.onDataDelete(index)
})
}
}
页面持有数据源后,可以直接交给 LazyForEach:
ts
@Entry
@Component
struct LazyArticlePage {
private dataSource: ArticleDataSource = new ArticleDataSource()
aboutToAppear(): void {
const items: ArticleItem[] = []
for (let index = 0; index < 1000; index++) {
items.push(new ArticleItem(
`article-${index}`,
`ArkTS 实战内容 ${index + 1}`,
index % 2 === 0 ? 'ArkUI' : '工程化',
500 + index
))
}
this.dataSource.replaceAll(items)
}
build() {
List({ space: 10 }) {
LazyForEach(this.dataSource, (item: ArticleItem) => {
ListItem() {
ArticleRow({ item: item })
}
}, (item: ArticleItem) => item.id)
}
.width('100%')
.height('100%')
.padding(16)
}
}
这里最容易遗漏的是数据变化通知。仅修改 items 数组并不能完整表达变化类型,数据源必须调用对应的监听方法:
| 数据操作 | 通知方法 | 适用情况 |
|---|---|---|
| 全量替换 | onDataReloaded() |
刷新、重新排序、条件整体变化 |
| 新增一项 | onDataAdd(index) |
插入或追加数据 |
| 更新一项 | onDataChange(index) |
指定位置内容变化 |
| 删除一项 | onDataDelete(index) |
指定位置数据移除 |
能准确描述局部变化时,应优先发送局部通知。频繁调用全量刷新会增加不必要的组件重建和布局计算。
五、用 Grid 实现自适应网格
相同的数据可以切换为网格展示。columnsTemplate 用于声明列轨道:
ts
@Component
struct ArticleGrid {
dataSource: ArticleDataSource
build() {
Grid() {
LazyForEach(this.dataSource, (item: ArticleItem) => {
GridItem() {
Column({ space: 10 }) {
Text(item.category)
.fontSize(12)
.fontColor('#2563EB')
.width('100%')
Text(item.title)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
Text(`${item.readCount} 次阅读`)
.fontSize(12)
.fontColor('#6B7280')
.width('100%')
}
.alignItems(HorizontalAlign.Start)
.justifyContent(FlexAlign.SpaceBetween)
.width('100%')
.height(140)
.padding(14)
.backgroundColor('#FFFFFF')
.borderRadius(8)
}
}, (item: ArticleItem) => item.id)
}
.columnsTemplate('1fr 1fr')
.columnsGap(12)
.rowsGap(12)
.width('100%')
.height('100%')
.padding(16)
}
}
1fr 1fr 表示等宽两列。在平板、折叠屏等大窗口上,可以结合窗口断点将模板切换为三列或四列。业务应依据最小卡片宽度统一制定适配规则,避免每个页面维护一套互相冲突的阈值。
六、滚动控制与定位
需要"回到顶部"或跳转到指定位置时,可以为列表绑定 Scroller:
ts
@Entry
@Component
struct ScrollableArticlePage {
private scroller: Scroller = new Scroller()
private dataSource: ArticleDataSource = new ArticleDataSource()
@State showBackToTop: boolean = false
build() {
Stack({ alignContent: Alignment.BottomEnd }) {
List({ space: 10, scroller: this.scroller }) {
LazyForEach(this.dataSource, (item: ArticleItem) => {
ListItem() {
ArticleRow({ item: item })
}
}, (item: ArticleItem) => item.id)
}
.onScrollIndex((start: number) => {
this.showBackToTop = start > 8
})
if (this.showBackToTop) {
Button('回到顶部')
.margin(20)
.onClick(() => {
this.scroller.scrollToIndex(0, true, ScrollAlign.Start)
})
}
}
.width('100%')
.height('100%')
}
}
滚动回调触发频率较高,不适合直接执行网络请求、复杂计算或频繁写入持久化存储。业务只需从回调中提取必要状态,重任务应做节流或移到其他时机。
七、加载态、空态与错误态
完整的数据页面不能只处理成功列表。可以先将页面状态明确建模:
ts
enum LoadStatus {
Loading,
Success,
Empty,
Error
}
@Component
struct ArticleContent {
@State status: LoadStatus = LoadStatus.Loading
private dataSource: ArticleDataSource = new ArticleDataSource()
build() {
Stack() {
if (this.status === LoadStatus.Loading) {
LoadingProgress()
.width(36)
.height(36)
} else if (this.status === LoadStatus.Empty) {
Text('暂无内容')
.fontColor('#6B7280')
} else if (this.status === LoadStatus.Error) {
Column({ space: 12 }) {
Text('加载失败,请稍后重试')
Button('重新加载')
.onClick(() => this.loadData())
}
} else {
List() {
LazyForEach(this.dataSource, (item: ArticleItem) => {
ListItem() {
ArticleRow({ item: item })
}
}, (item: ArticleItem) => item.id)
}
}
}
.width('100%')
.height('100%')
}
private loadData(): void {
this.status = LoadStatus.Loading
// 请求完成后更新数据源,并将状态切换为 Success、Empty 或 Error
}
}
将加载状态与数据集合分开后,页面逻辑会更清晰。空数组不再同时承担"尚未请求""请求失败"和"确实没有数据"三种含义。
八、常见问题与性能建议
列表没有滚动
滚动容器必须获得受约束的尺寸。如果 List 放在 Column 中却没有高度或 layoutWeight,其内容可能按完整高度参与布局,自然不会形成滚动区域。
数据变化但界面没有更新
使用普通数组时,要确认它处于可观察状态,并通过可被框架识别的方式更新。使用 LazyForEach 时,则要确认数据源发送了与操作一致的通知。
滚动过程中明显掉帧
可以依次检查以下位置:
- 子项
build()中是否执行排序、过滤、JSON 解析等重复计算; - 是否为每个子项同步解码大图;
- key 是否稳定,是否造成大量节点失效;
- 是否在滚动回调中高频修改多个状态;
- 子项层级是否过深,是否存在不必要的透明叠加和阴影;
- 是否在长数据集合上继续使用一次性构建的
ForEach。
展示模型应尽量在进入 UI 层前准备好。列表子项负责渲染与轻量交互,不应承担数据清洗和复杂业务编排。
所有更新都调用全量刷新
全量刷新容易实现,但会失去局部更新优势。新增、删除、修改能够定位到具体索引时,应发出对应通知;只有整体替换、排序或过滤结果彻底变化时再重新加载全部数据。
九、组件选择清单
- 数据量小、内容简单且基本不变化:
List或Grid配合ForEach; - 数据量较大,需要按需创建节点:
List或Grid配合LazyForEach; - 单列或单行滚动:优先
List; - 多列卡片和缩略图:优先
Grid; - 数据会频繁增删:实现稳定 key,并发送精确的数据源通知;
- 需要定位与回顶:绑定
Scroller; - 页面来自网络:显式处理加载、成功、空数据和错误状态。
总结
ArkUI 集合页面的核心不只是把数据循环出来,而是让容器、数据身份和更新机制彼此匹配。
List 负责单轴滚动,Grid 负责行列布局;ForEach 适合简单的小规模集合,LazyForEach 通过按需创建节点支撑更长的数据。稳定的 key 保证节点身份正确,精确的数据变化通知减少无效重建,明确的页面状态则让加载和异常流程保持可维护。
掌握这些原则后,无论是消息流、商品网格还是文件列表,都可以在同一套思路下完成建模,并在数据增长时保持稳定的滚动体验。