[鸿蒙从零到一] ArkUI 列表与网格实战:List、Grid 与 LazyForEach

ArkUI 列表与网格实战:List、Grid 与 LazyForEach

移动应用中,大量页面都可以归结为"展示一组数据":消息流适合纵向列表,频道入口适合网格,商品页面还可能同时包含分组、滚动定位、增删更新和空状态。

ArkUI 提供了 ListGridForEachLazyForEach 等能力。组件本身并不难,真正影响体验的是如何选择容器、怎样建立稳定的数据标识、何时使用懒加载,以及数据变化后如何准确通知界面。

本文从静态列表开始,逐步实现一个支持网格切换、数据增删和滚动定位的内容页面,并梳理常见性能问题。

一、List 与 Grid 分别解决什么问题

List 适合单轴滚动的数据集合,常见场景包括消息、联系人、设置项、新闻流和横向推荐内容。Grid 适合按行列排布内容,常见于相册、商品、应用入口和文件缩略图。

两者的直接子节点也不同:List 中使用 ListItemGrid 中使用 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)
  }
}

Listspace 用于设置列表项间距。页面给列表设置 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 层前准备好。列表子项负责渲染与轻量交互,不应承担数据清洗和复杂业务编排。

所有更新都调用全量刷新

全量刷新容易实现,但会失去局部更新优势。新增、删除、修改能够定位到具体索引时,应发出对应通知;只有整体替换、排序或过滤结果彻底变化时再重新加载全部数据。

九、组件选择清单

  • 数据量小、内容简单且基本不变化:ListGrid 配合 ForEach
  • 数据量较大,需要按需创建节点:ListGrid 配合 LazyForEach
  • 单列或单行滚动:优先 List
  • 多列卡片和缩略图:优先 Grid
  • 数据会频繁增删:实现稳定 key,并发送精确的数据源通知;
  • 需要定位与回顶:绑定 Scroller
  • 页面来自网络:显式处理加载、成功、空数据和错误状态。

总结

ArkUI 集合页面的核心不只是把数据循环出来,而是让容器、数据身份和更新机制彼此匹配。

List 负责单轴滚动,Grid 负责行列布局;ForEach 适合简单的小规模集合,LazyForEach 通过按需创建节点支撑更长的数据。稳定的 key 保证节点身份正确,精确的数据变化通知减少无效重建,明确的页面状态则让加载和异常流程保持可维护。

掌握这些原则后,无论是消息流、商品网格还是文件列表,都可以在同一套思路下完成建模,并在数据增长时保持稳定的滚动体验。

相关推荐
李明卫杭州18 小时前
Vue2 数组响应式为什么总"不更新"?深入源码对比 Vue3 的 Proxy 改造
前端·javascript·vue.js
索西引擎18 小时前
【React】useReducer 与 useState 的比较研究:复杂状态管理场景下的选型
前端·react.js·前端框架
hunterandroid18 小时前
前台服务适配与线上排查:通知权限、启动限制和任务保活
android·前端
我是大卫18 小时前
【图】React源码解析-从“上帝视角”俯瞰React的认知框架
前端·react.js·源码
不简说18 小时前
JS 代码技巧 vol.6 — 20 个性能优化野路子,从渲染到网络全栈提速
前端·javascript·程序员
小岛前端18 小时前
展示!我 Vibe 了一个 Codex HUD
前端·ai编程
我是大卫19 小时前
【图】React源码解析-从源码数据结构、执行机制对比、闭包陷阱与最佳实践四个维度,深挖useMemo和useCallback的底层原理
前端·react.js·源码
不一样的少年_19 小时前
不用 LangChain,手搓 AI Agent:给大模型装上“手”,让它自己读项目文件
前端·人工智能·agent
索西引擎19 小时前
【React】useState 函数式更新机制:闭包陷阱规避与状态一致性保障分析
前端·react.js·前端框架