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

简介: 本文详解ArkUI中List与Grid组件的实战应用,涵盖静态列表、网格切换、懒加载(LazyForEach)、滚动定位、空/错/加载态处理等核心场景,并强调稳定key、精准数据通知与性能优化要点,助开发者构建高性能集合页面。

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

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

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

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

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

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

两者的直接子节点也不同:List 中使用 ListItemGrid 中使用 GridItem。不要跳过这一层直接放业务组件,否则滚动容器无法正确管理子项。

二、用 List 构建基础列表

先定义页面需要的数据模型:

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 可以快速渲染一组规模较小的数据:

@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 为什么重要

下面这种写法看似方便,却不适合会增删或重排的数据:

ForEach(this.articles, (item: ArticleItem) => {
   
  ListItem() {
   
    ArticleRow({
    item: item })
  }
}, (item: ArticleItem, index: number) => index.toString())

删除中间元素后,后续所有元素的索引都会变化。框架可能将原有节点错误地复用给另一条数据,输入状态、动画状态或子组件内部状态也可能跟着错位。

键值应在同一集合内唯一,在数据生命周期内稳定,并能直接表示业务实体身份。服务端主键、本地生成的 UUID、不可变的组合键都比数组索引更合适。标题、昵称等可能重复或变化的字段也不宜单独作为键值。

四、长列表使用 LazyForEach

ForEach 会为当前数据集合创建对应的子组件。数据较多时,可以使用 LazyForEach 按需创建可见区域附近的节点,离开缓存范围的节点可被框架回收。

LazyForEach 需要实现 IDataSource。一个可复用的基础数据源如下:

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

@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 用于声明列轨道:

@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

@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%')
  }
}

滚动回调触发频率较高,不适合直接执行网络请求、复杂计算或频繁写入持久化存储。业务只需从回调中提取必要状态,重任务应做节流或移到其他时机。

七、加载态、空态与错误态

完整的数据页面不能只处理成功列表。可以先将页面状态明确建模:

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 保证节点身份正确,精确的数据变化通知减少无效重建,明确的页面状态则让加载和异常流程保持可维护。

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

相关文章
|
28天前
|
监控 安全 测试技术
Navigation 多模块导航与深链治理:让页面跳转可维护、可追踪
本文提出一套面向多模块 Android 项目的导航治理方案:通过密封接口定义路由契约、应用壳模块统一映射与拦截、深链校验+可观测性建设,解耦业务模块、保障类型安全、统一处理登录/权限等前置逻辑,并支持可追踪、易测试的跨模块跳转。
99 0
|
28天前
|
测试技术 Shell 开发工具
前台服务适配与线上排查:通知权限、启动限制和任务保活
本文详解前台服务的合规实现:涵盖通知权限适配、后台启动限制规避、多版本系统兼容及线上问题排查方法,强调按场景选型(如导航/播放用前台服务,同步用WorkManager),避免滥用保活,助你构建稳定长任务能力。
94 0
|
26天前
|
XML 数据采集 缓存
RecyclerView 多类型列表实战:稳定刷新、状态恢复与性能治理
本文详解RecyclerView多类型列表实战:通过密封类建模、稳定ID、DiffUtil精准对比、payload局部刷新、状态隔离与嵌套列表恢复等手段,解决闪烁、错位、状态串行等顽疾,兼顾性能、可维护性与扩展性。
80 0
|
28天前
|
人工智能 安全 前端开发
AI 时代文本加盐钓鱼攻击回潮的 LLM 检测失效机制与分层防御研究
本文基于Barracuda 2026年百万级文本加盐钓鱼攻击实证,揭示AI邮件过滤因忽略HTML“源码-渲染”双层结构而失效的根源;提出“源码清洗—可视文本还原—分层语义加权”三层检测架构,配套可落地Python代码,将识别率提升至93.4%,并构建覆盖网关、云服务、终端与行业的全链路防御体系。(239字)
77 0
|
28天前
|
人工智能 安全 网络安全
生成式 AI 赋能下旅游场景网络诈骗攻击机理与全域防御研究
本文揭示2026年AI驱动的旅游诈骗新态势:攻击者利用大模型、图像/语音生成技术批量伪造房源、订单通知与客服对话,结合钓鱼二维码、仿冒WiFi及私域渗透实施精准欺诈。研究拆解四大攻击链路,提出“AI对抗AI”多模态鉴伪体系,并构建平台-终端-支付-运营四层全域防御框架,配套可落地检测代码,实现100%全链路拦截。(239字)
104 0
|
28天前
|
数据采集 人工智能 数据挖掘
企业有多个AI应用,员工却不知道怎么用:一次AI工作助理路由改造实践
当一个任务能够被拆解、调用、评估、人工确认并持续改进时,智能体才真正从Demo进入业务。
159 1
|
28天前
|
IDE JavaScript 前端开发
【全网最详细】VS2022下载、安装、使用一篇搞定(附社区版安装包)
Visual Studio 2022(VS2022)是微软推出的首个原生64位IDE,突破4GB内存限制,性能更强。支持C/C++、C#、Python、JavaScript等多语言,提供免费社区版(功能完整)、专业版和企业版。推荐初学者与个人开发者使用社区版,安装灵活、学习成本低,是高效开发的理想选择。(239字)
|
28天前
|
人工智能 运维 BI
QoderWork + QoderWake 实战:AI 数字员工的企业级落地与效率革命
企业办公中大量重复性工作正在吞噬团队的创造力。QoderWork CN 作为桌面 AI 办公助手,本地运行、自主规划、安全可控,内置丰富 Skill 覆盖文案写作、幻灯片制作、浏览器自动化等场景;QoderWake CN 作为数字员工平台,全天在线、支持企业私域知识库与自定义扩展。本文从运营团队的实际痛点出发,完整拆解 QoderWork + QoderWake 的企业级落地路径,包含四大典型场景的实战配置、五个真实踩坑案例,以及从试点到推广的完整 SOP。
|
25天前
|
人工智能 缓存 负载均衡
一文说明白 AI API中转站是什么?
AI API中转站是连接国内开发者与海外大模型(如OpenAI、Claude)的代理平台,破解网络、支付、注册三重壁垒。支持微信/支付宝充值、统一OpenAI格式调用、智能路由与自建号池,以“倍率”计费(官方价×倍率)。适合中小团队降本提效,但需警惕低价掺水、数据安全与服务稳定性。
|
25天前
|
Ubuntu Linux 虚拟化
【VM】VMware下载、安装和创建虚拟机一篇搞定(附官网安装包)
虚拟机(VM)是在现有系统中运行另一操作系统的技术,如Windows里开Linux窗口。VMware Workstation Pro免费易用,性能稳定、兼容性好,支持快照、克隆等高级功能,是学习、开发与测试的理想选择。(239字)