鸿蒙原生 ArkTS 布局实战:@State + ForEach 动态列表布局完全指南


一、引言

1.1 背景

2024 年第四季度,HarmonyOS NEXT(鸿蒙星河版)正式面向开发者开放。这是华为彻底剥离 Android AOSP 代码、全栈自研的操作系统版本。从这一刻起,鸿蒙应用的开发语言栈也发生了根本性变化——ArkTS 取代了 JavaScript 和 Java,成为鸿蒙原生应用的一等公民

ArkTS 是 TypeScript 的超集,它在 TypeScript 的基础上增加了声明式 UI 描述、状态管理、编译时校验等特性,语法上更接近 SwiftUI 和 Jetpack Compose。对于有过 Android 或 iOS 开发经验的读者来说,ArkTS 的学习曲线其实相当平缓。

1.2 为什么是 @State + ForEach?

在任何一个面向用户的应用程序中,列表渲染都是最核心、最高频的 UI 场景。

  • 社交应用的信息流
  • 电商应用的商品列表
  • 即时通讯的会话列表
  • 效率工具的任务清单

这些场景都有一个共同的特点:数据是动态的,UI 需要随着数据的变化实时响应。在鸿蒙 ArkTS 中,@State 装饰器和 ForEach 指令就是解决这个问题的标准组合。@State 负责"感知数据变化",ForEach 负责"高效渲染列表项",二者配合构成了鸿蒙原生动态列表布局的基石。

1.3 本文目标

本文将通过一个完整的任务管理 Demo 应用,逐行讲解 @State + ForEach + List 组合的使用方法、底层原理和最佳实践。无论你是刚接触 HarmonyOS 开发的新手,还是从其他平台迁移过来的开发者,读完本文后都应该能够:

  • 理解 @State 装饰器的响应式原理
  • 掌握 ForEach 的正确用法和 keyGenerator 的重要性
  • 学会在 List 容器中高效渲染动态数据
  • 了解 API 24 中状态管理和列表渲染的新特性
  • 避免常见的性能陷阱和语法错误

二、核心技术详解

2.1 @State 装饰器:响应式数据的基石

2.1.1 基本原理

@State 是 ArkTS 中最基本的状态装饰器,它的工作可以用一句话概括:

当 @State 修饰的变量发生变化时,所有依赖该变量的 UI 组件会自动重新渲染。

这个机制在底层是通过编译时代码变换实现的。ArkTS 编译器会在构建阶段扫描所有 @State 变量和它们被引用的 UI 代码,生成一个依赖追踪图。当变量的 setter 被触发时,框架沿着依赖图找到受影响的组件,只更新这些组件的 UI 节点——而不是整个页面重绘。

2.1.2 支持的数据类型

@State 可以修饰以下类型:

类型 示例 变更检测方式
基本类型 string, number, boolean 直接赋值触发
枚举 enum Color { Red, Green } 赋值触发
对象 class TaskItem { ... } 属性修改也触发(深度监听)
数组 TaskItem[] push/splice/pop 及元素属性修改均触发

其中最重要的是 对象和数组的深度监听。在 Jetpack Compose 中,修改 MutableList 中的元素属性需要手动触发重组;而在 ArkTS 中,item.completed = true 这样的操作会被框架自动捕获并触发 UI 刷新。这大大简化了代码编写。

2.1.3 使用限制

在 ArkTS 的严格模式下(API 24 默认开启),@State 有以下限制:

  • 不能在非 @Component 结构体中使用@State 只能在被 @Component 装饰的结构体中使用
  • 初始化不能依赖其他 @State 变量@State 变量的初始值必须在声明时确定,或者通过 aboutToAppear 生命周期初始化
  • 不支持计算属性:如果需要一个由其他状态派生出的值,应该使用 @Prop@Computed(API 24 新增)

2.2 ForEach:高效的循环渲染指令

2.2.1 基本语法
ForEach(
  arr: any[],                          // 数据源数组
  itemGenerator: (item, index) => void, // UI 生成器
  keyGenerator?: (item, index) => string // Key 生成器(推荐必传)
)

三个参数各自的责任:

  1. 数据源数组:必须是 @State 或其他状态装饰器修饰的响应式数组
  2. UI 生成器:为数组中的每一项返回一个 UI 组件树
  3. Key 生成器(可选但强烈推荐):为每一项返回一个唯一且稳定的字符串标识
2.2.2 keyGenerator 为什么至关重要

这是初学者最容易忽略、也最容易踩坑的地方。

当你不传 keyGenerator 时,ForEach 会默认使用数组下标(index)作为 key。这在很多情况下会导致错误的节点复用:

场景模拟

初始数据:[A, B, C]  → 渲染三个列表项(key: 0, 1, 2)
执行操作:在 A 前面插入 X
期望结果:[X, A, B, C] → 渲染四个列表项
实际结果(无 keyGenerator):
  - key 0 绑定的节点从 A → X(内容突变)
  - key 1 绑定的节点从 B → A
  - key 2 绑定的节点从 C → B
  - 新增 key 3 → C

这会导致:

  • 列表项的动画异常(不该移动的项发生了位移)
  • 状态丢失(比如某个列表项的 TextInput 正在编辑中,key 变化后组件被销毁重建)
  • 不必要的性能开销(框架需要对比更多的节点差异)

正确做法是使用数据本身的唯一 ID 作为 key:

ForEach(
  this.taskList,
  (item: TaskItem) => { ListItem() { ... } },
  (item: TaskItem): string => item.id.toString() // 稳定且唯一
)
2.2.3 与 JavaScript Array.map 的区别

很多初学者会问:为什么不能用 this.list.map() 来渲染列表?

对比维度 ForEach Array.map
响应式更新 ✅ 框架级 Diff,只更新变化的节点 ❌ 每次重新执行生成全新节点
节点复用 ✅ 通过 key 复用已有组件实例 ❌ 每次都销毁重建
虚拟滚动 ✅ 与 List 配合支持按需渲染 ❌ 无法参与 List 的虚拟滚动机制
批量操作 ✅ 增删移动都能精确更新 ❌ 只能整体替换

简单来说,ForEach 是框架层面的声明式渲染指令,而 map 只是一个纯 JavaScript/TypeScript 的数组方法。在 ArkTS 中,动态列表必须使用 ForEach

2.3 List 容器:高效的滚动列表

2.3.1 List 的核心优势

List 是鸿蒙提供的高性能可滚动列表容器,它的核心能力是虚拟滚动(Virtual Scrolling)

  • 只渲染当前可视区域内的列表项
  • 列表项滑出屏幕时,组件实例被回收
  • 新的列表项滑入屏幕时,复用已回收的实例

这意味着即使数据源有 十万条数据List 也只维护屏幕内可容纳的几十个组件实例,内存占用几乎不随数据量增长。

2.3.2 List 的核心属性
List() {
  // ForEach 放在这里
}
.width('100%')
.height('100%')
.divider({ strokeWidth: 1, color: '#EEEEEE' })  // 分割线
.edgeEffect(EdgeEffect.Spring)                    // 边缘回弹
.sticky(StickyStyle.Header)                       // 粘性头部
.scrollBar(BarState.Auto)                         // 滚动条

在 API 24 中,List 新增了以下能力:

  • cachedCount:控制屏幕外缓存列表项的数量,优化快速滚动时的白屏问题
  • scrollToIndex:支持动画平滑滚动到指定索引
  • nestedScroll:支持嵌套滚动场景(如列表内的可滚动子组件)
2.3.3 ListItem 与布局

每个由 ForEach 生成的列表项必须用 ListItem 包裹:

List() {
  ForEach(this.taskList, (item: TaskItem) => {
    ListItem() {
      // 这里放卡片布局
    }
    .swipeAction({ end: this.SwipeDeleteButton(item) })
  }, (item) => item.id.toString())
}

ListItem 支持以下高级交互:

  • .swipeAction:滑动操作菜单(左滑/右滑显示按钮)
  • .sticky:将该列表项设为粘性头部
  • .selectable:是否可选中(用于选择模式)
  • .onSelect:选中事件回调

三、Demo 应用完整解析

下面我们逐段分析示例应用中的核心代码。

3.1 数据模型定义

class TaskItem {
  id: number;
  title: string;
  description: string;
  completed: boolean;
  priority: '高' | '中' | '低';

  constructor(id: number, title: string, description: string, priority: '高' | '中' | '低') {
    this.id = id;
    this.title = title;
    this.description = description;
    this.completed = false;
    this.priority = priority;
  }
}

为什么用 class 而不是 interface

因为 @State 需要可变的引用类型才能触发深度监听。interface 在 TypeScript 中只是编译时类型约束,运行时不存在。如果使用 interface,修改 item.completed = true 框架无法感知变化。

API 24 的最佳实践:在 API 24 中,推荐使用 @Observed 装饰器配合 @ObjectLink 来实现更精细的跨组件状态同步。

3.2 组件结构

@Entry
@Component
struct Index {
  @State private taskList: TaskItem[] = [];
  @State private inputTitle: string = '';
  @State private selectedPriority: '高' | '中' | '低' = '中';
  private nextId: number = 1;
  // ...
}

代码要点:

  1. @Entry:标记该组件为应用入口页面,对应 main_pages 路由表中的第一个页面
  2. @Component:声明这是一个自定义组件,支持装饰器语法
  3. @State 放在 struct 顶层:所有状态变量都在结构体顶部声明,一目了然
  4. nextId 没有使用 @State:因为它只用于生成 ID,不直接影响 UI 渲染

3.3 数据初始化(aboutToAppear)

aboutToAppear(): void {
  const samples: Array<[string, string, '高' | '中' | '低']> = [
    ['学习 @State 装饰器', '掌握声明式响应式数据绑定', '高'],
    ['理解 ForEach 用法', '学会 keyGenerator 和按需渲染', '高'],
    ['实践 List 组件', '熟悉 List + ForEach 组合模式', '中'],
    ['探索 Grid 布局', '了解网格布局的参数配置', '低'],
    ['完成第一篇笔记', '将学到的知识整理成文档', '中'],
  ];
  samples.forEach((sample) => {
    const title = sample[0];
    const desc = sample[1];
    const priority = sample[2];
    this.taskList.push(new TaskItem(this.nextId++, title, desc, priority));
  });
}

生命周期钩子 aboutToAppear:在组件即将显示时调用,是初始化数据的推荐位置。需要注意的是,ArkTS 不支持构造函数中初始化 UI 依赖的数据,因为 @State 变量在构造阶段尚未完成依赖追踪的注册。

3.4 核心布局:List + ForEach

build() {
  Column() {
    // ... 顶部 UI ...

    List() {
      ForEach(
        this.taskList,                          // ① 数据源
        (item: TaskItem, index?: number) => {   // ② UI 生成器
          ListItem() {
            this.TaskCard(item, index ?? 0)
          }
          .swipeAction({ end: this.SwipeDeleteButton(item) })
        },
        (item: TaskItem): string => item.id.toString() // ③ Key 生成器
      )
    }
    .width('100%')
    .layoutWeight(1)
    .divider({ strokeWidth: 1, color: '#EEEEEE', startMargin: 16, endMargin: 16 })
    .edgeEffect(EdgeEffect.Spring)

    // ... 底部 UI ...
  }
}

布局层级关系

Column
 ├── Row (标题栏)
 ├── Row (输入区域)
 ├── Row (操作按钮)
 ├── List ─────────────────── 虚拟滚动容器
 │    ├── ListItem #0 ──── TaskCard(A)
 │    ├── ListItem #1 ──── TaskCard(B)
 │    ├── ListItem #2 ──── TaskCard(C)
 │    └── ...
 └── Text (底部提示)

layoutWeight(1) 是一个特别有用的属性——它让 ListColumn 中占据所有剩余空间,确保列表高度自适应不同屏幕尺寸。

3.5 使用 @Builder 封装列表项 UI

@Builder
TaskCard(item: TaskItem, index: number) {
  Row() {
    Text(`${index + 1}`)          // 序号
      .fontSize(13).fontColor('#BBBBBB').width(28)

    Column() {                     // 内容区
      Text(item.title)
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
        .fontColor(item.completed ? '#BBBBBB' : '#333333')
        .decoration({
          type: item.completed
            ? TextDecorationType.LineThrough
            : TextDecorationType.None
        })

      if (item.description) {
        Text(item.description)
          .fontSize(12).fontColor('#999999').margin({ top: 2 })
      }
    }
    .layoutWeight(1)

    Text(item.priority)            // 优先级标签
      .backgroundColor(/* 高=红, 中=黄, 低=灰 */)
    Text(item.completed ? '✅' : '⬜')  // 状态图标
  }
}

为什么用 @Builder 而不是独立的 @Component

@Builder 是当前组件的内部构建函数,它可以直接访问父组件的 @State 变量和方法(如 toggleCompleted),不需要通过 @Prop@Link 传递回调。对于简单的列表项 UI,使用 @Builder 更简洁。

如果列表项 UI 足够复杂,或者需要在多处复用,就应该提取为独立的 @Component 子组件,通过 @Prop 接收数据。

3.6 动态数据操作三部曲

应用演示了三种最常见的数据操作模式:

模式一:新增 — 数组 push
private addTask(): void {
  this.taskList.push(new TaskItem(/* ... */));
  // UI 自动新增一行
}

push 触发了数组的 length 变化和新增元素,框架在下一帧自动插入一个新的 ListItem

模式二:删除 — 数组 splice
private removeTask(id: number): void {
  const index = this.taskList.findIndex(item => item.id === id);
  if (index !== -1) {
    this.taskList.splice(index, 1);
    // UI 自动删除对应行(带从右向左的默认移除动画)
  }
}

splice 触发了数组元素移除,框架找到 key 对应的组件并执行移除动画。

模式三:更新 — 元素属性修改
private toggleCompleted(id: number): void {
  const item = this.taskList.find(task => task.id === id);
  if (item) {
    item.completed = !item.completed;
    // UI 自动刷新该行:文字颜色、删除线、图标全部更新
  }
}

这是最令人惊艳的一点——直接修改对象属性就能触发 UI 刷新,无需任何额外的"通知"机制。这是因为 @State 在编译时为 TaskItem 类的属性注入了代理 setter,每次修改都会被捕获。


四、API 24 新特性与迁移指南

4.1 API 24 的新增能力

HarmonyOS NEXT API 24(对应 SDK 7.0.0)在状态管理和列表渲染方面引入了多项重要改进:

4.1.1 @Computed 装饰器
@State private tasks: TaskItem[] = [];

@Computed
get incompleteCount(): number {
  return this.tasks.filter(t => !t.completed).length;
}

@Computed 类似于 Vue 的 computed 或 SwiftUI 中依赖 @State 的计算属性。它会自动追踪内部依赖的 @State 变量,只有依赖变化时才重新计算,避免不必要的重复计算。

4.1.2 @Observed 和 @ObjectLink

对于跨组件的嵌套对象状态同步,API 24 推荐使用 @Observed + @ObjectLink

@Observed
class TaskItem {
  // ...
}

@Component
struct TaskCard {
  @ObjectLink item: TaskItem;  // 共享同一个对象引用
  // 修改 item 会同时反映到父组件
}
4.1.3 LazyForEach 正式稳定

在 API 24 中,LazyForEach 从实验性功能转为正式 API。与 ForEach 的区别:

特性 ForEach LazyForEach
数据加载方式 一次性全部加载 按需加载(支持数据源分页)
大数据量 受限于内存 支持百万级数据
数据源接口 普通数组 需实现 IDataSource 接口
适用场景 中小型列表(< 1000 项) 大型列表(1000+ 项)
4.1.4 List 的视觉刷新
  • animateItem:支持为列表项的增删移动指定自定义动画
  • stickyHeader 增强:支持多级粘性头部
  • scrollBar 可定制:自定义滚动条样式和宽度

4.2 从 API 23 迁移到 API 24

如果你的项目当前使用 API 23,迁移到 API 24 需要关注以下几点:

  1. showToast 完全移除promptAction.showToast 在 API 23 中已弃用,API 24 中彻底移除。替代方案是使用 @ohos.prompt 模块的 showToastshowDialog

  2. 严格模式默认开启:API 24 中 arkts-no-destruct-paramsarkts-no-untyped-obj-literals 等限制不再只是警告,而是编译错误。建议在开发阶段就开启这些规则。

  3. ForEach keyGenerator 必传:在 API 24 中,不传 keyGenerator 会触发编译警告,强烈建议始终提供稳定的 key 生成器。

  4. @State 深度监听行为调整:API 24 中对嵌套属性的监听更严格,如果修改了未在 @State 声明时出现的属性,可能不会被捕获。建议声明所有需要监听的属性。


五、项目实战:从零搭建动态任务管理应用

理论知识学完了,现在我们从一个空白页面开始,完整地走一遍开发流程。这不仅能巩固前面的知识点,还能帮助你建立鸿蒙应用开发的整体思维。

5.1 第一步:设计数据模型

任何数据驱动的应用,第一步都是设计数据模型。我们的任务管理应用需要记录以下信息:

  • 唯一标识(id):用于 ForEach 的 key 生成,也用于查找和删除操作
  • 标题(title):任务的简要描述,必填字段
  • 详细描述(description):可选的补充说明
  • 完成状态(completed):布尔值,控制 UI 上的贯穿样式和图标
  • 优先级(priority):三档可选——高、中、低,影响标签颜色

选择 class 而非 interface 的理由我们已经讨论过——@State 需要对嵌套属性做代理监听,而 class 的实例是引用类型,便于框架注入响应式代理。

设计思考:为什么不在模型中包含创建时间、截止时间等字段?在设计初期,应该遵循"最小可行模型"原则——只包含当前 UI 明确需要的字段。后续可以通过继承或组合来扩展。过度设计反而会让初学者分不清主次。

5.2 第二步:组织组件结构

一个页面的组件层次结构直接影响可维护性。我们的应用采用"一页一组件"的简单结构:

Index(根页面)
 ├── 标题栏(Row)
 ├── 输入区域(Row)
 │    ├── TextInput(标题输入框)
 │    ├── Select(优先级选择器)
 │    └── Button(添加按钮)
 ├── 操作按钮行(Row)
 │    ├── Button(清除已完成)
 │    └── Button(重置示例)
 ├── List(核心列表区域)
 │    ├── ListItem × N(任务卡片)
 │    └── 每个 ListItem 包含:
 │         ├── 序号 Text
 │         ├── 内容 Column(标题 + 描述)
 │         ├── 优先级标签 Text
 │         └── 状态图标 Text
 └── 底部提示(Text)

这种分层清晰的结构有以下几个好处:

  • 自上而下的阅读顺序:符合用户浏览页面的自然视线流向
  • 每个区域职责单一:标题栏只做标题展示,输入区域只处理新增逻辑,互不干扰
  • 便于拆分组件:当某个区域逻辑变复杂时(比如输入区域需要表单校验),可以轻松提取为独立组件

5.3 第三步:实现状态管理

在 ArkTS 中,状态管理的核心是"哪些数据会变化,变化后影响哪些 UI"。我们的应用中:

状态变量 变化时机 影响的 UI
taskList 增/删/改任务 整个列表区域
inputTitle 用户输入 输入框的显示值
selectedPriority 用户切换 选择器的显示值

一个常见的误区是"把所有变量都用 @State 装饰"。实际上,只有直接参与 UI 渲染影响用户交互感知的数据才需要设为状态。比如 nextId 只用于生成 ID,不直接影响界面,所以就不需要 @State

5.4 第四步:编写 UI 布局

UI 布局的编写遵循"从外到内、从大到小"的顺序:

  1. 确定容器:最外层用 Column 实现垂直排列
  2. 划分区域:标题栏、输入区、按钮区、列表区、底部提示
  3. 填充内容:在每个区域内填入组件
  4. 调整细节:间距、颜色、字体、交互反馈

鸿蒙 ArkTS 的布局思维与传统前端(HTML + CSS)有显著区别:

  • 没有"盒模型"的外边距合并问题,paddingmargin 行为更接近 SwiftUI
  • 使用 .layoutWeight(1) 替代 Flexbox 的 flex: 1
  • 组件通过链式调用设置属性,每个属性方法都返回组件本身
  • 条件渲染使用 if 语句而非三元表达式或 v-if 指令

5.5 第五步:添加交互逻辑

交互逻辑的添加需要遵循"数据驱动"而不是"事件驱动"的思维:

传统方式(事件驱动)

用户点击 → 事件回调 → 直接操作 DOM → 页面变化

鸿蒙方式(数据驱动)

用户点击 → 事件回调 → 修改 @State 数据 → 框架自动更新 UI

在我们的应用中,所有交互都遵循后者:

  • 点击添加按钮addTask()this.taskList.push(...) → 列表自动新增一行
  • 点击完成图标toggleCompleted()item.completed = !item.completed → 文字颜色和删除线自动变化
  • 左滑删除removeTask()this.taskList.splice(...) → 列表项自动移除带动画

这种思维转变是理解 ArkTS 的关键。开发者不再需要思考"如何操作 UI",只需要思考"数据应该变成什么样"。

5.6 第六步:调试与验证

在 DevEco Studio 中运行应用时,可以利用以下调试手段:

  1. Previewer 预览:代码编辑时实时查看布局效果,适合调整视觉细节
  2. Inspector 调试:运行时查看组件树和状态值,确认 @State 变量是否正确更新
  3. Profiler 性能分析:监测列表滑动帧率,验证虚拟滚动是否生效
  4. 日志输出:使用 console.info() 在调试面板打印状态变化,追踪数据流

一个实用的调试技巧是在 aboutToAppear 中加入 console.info,确认生命周期钩子按预期执行:

aboutToAppear(): void {
  console.info('[Index] aboutToAppear triggered, initializing data...');
  // 初始化数据
}

六、实际场景扩展

6.1 对接网络接口

在实际业务中,数据通常来自后端 API。将我们的 Demo 改造为真实应用需要以下步骤:

  1. 引入网络请求模块:使用 @ohos.net.http 或第三方库
  2. 添加加载状态:定义 @State isLoading: boolean = false 控制加载动画
  3. 实现分页加载:结合 LazyForEachIDataSource 接口,实现上拉加载更多
  4. 错误处理:捕获网络异常,显示错误提示和重试按钮
private async loadTasksFromAPI(): Promise<void> {
  this.isLoading = true;
  try {
    const response = await http.request('https://api.example.com/tasks');
    const data = JSON.parse(response.result as string) as TaskItem[];
    this.taskList = data;
  } catch (error) {
    console.error('Failed to load tasks:', error);
    // 显示错误提示
  } finally {
    this.isLoading = false;
  }
}

6.2 列表项编辑模式

除了展示和删除,很多场景需要支持原地编辑。实现思路如下:

  • 定义 @State editingId: number | null = null 标记当前正在编辑的项
  • TaskCard 中根据 editingId 决定显示 Text 还是 TextInput
  • 编辑完成后调用回调更新 taskList 中对应的数据
// 在 ListItem 中
if (this.editingId === item.id) {
  TextInput({ text: item.title })
    .onChange((value) => { item.title = value; })
} else {
  Text(item.title)
    .onClick(() => { this.editingId = item.id; })
}

6.3 跨组件通信进阶

当应用规模增大,父子组件之间、兄弟组件之间的状态共享需求就会出现。API 24 提供了多种跨组件通信方案:

方式 装饰器 适用场景
父子传值 @Prop 父传子,单向数据流
父子共享 @Link 双向同步,父子共享同一引用
跨层共享 @Provide + @Consume 祖先传后代,跳过中间层级
全局共享 AppStorage / LocalStorage 跨页面、跨组件的全局状态

6.4 列表搜索与筛选

在真实应用中,列表数据通常需要搜索和筛选。实现方式是在 @State 数据源之上再加一层"计算"逻辑:

@State private searchKeyword: string = '';

// 在 build 中使用过滤后的数组
get filteredList(): TaskItem[] {
  if (!this.searchKeyword.trim()) {
    return this.taskList;
  }
  return this.taskList.filter(item =>
    item.title.includes(this.searchKeyword)
  );
}

在 API 24 中,可以将此逻辑封装为 @Computed 属性,让框架自动追踪依赖并缓存计算结果。


七、性能优化与最佳实践

本章节承接上文第六部分的实际场景扩展,深入探讨列表性能的优化方法和常见陷阱。

7.1 列表性能优化七条

7.1.1 始终提供稳定的 keyGenerator

这是最重要的一条优化。稳定的 key 让框架能够精确识别每个列表项,最小化 DOM 更新的范围。使用数据库或数据中台返回的唯一 ID,避免使用数组下标或随机数。

7.1.2 避免在 ForEach 内部创建匿名函数

不推荐

ForEach(this.list, (item) => {
  ListItem() {
    Button('点击')
      .onClick(() => this.doSomething(item)) // 每次渲染都创建新函数
  }
})

推荐

ForEach(this.list, (item) => {
  ListItem() {
    Button('点击')
      .onClick(() => this.doSomething(item.id)) // 通过 ID 查找
  }
})

或者使用 @Builder 提取列表项。

7.1.3 控制列表项的高度

给每个 ListItem 或内部的根容器设置明确的 height,可以帮助 List 更准确地计算滚动高度,避免滚动条跳动。

7.1.4 使用 cachedCount 优化快速滚动
List() { ... }
.cachedCount(10)  // 在屏幕外额外缓存 10 个列表项

当用户快速滑动列表时,cachedCount 可以让新的列表项提前渲染好,减少白屏时间。

7.1.5 大数据量使用 LazyForEach

当数据量超过 1000 条时,切换到 LazyForEach + IDataSourceLazyForEach 支持数据分页、延迟加载和按需销毁,内存占用更可控。

7.1.6 避免在 ForEach 中嵌套 ForEach

嵌套的 ForEach 会导致 diff 算法的复杂度指数级上升。如果确实需要嵌套列表(如评论回复),考虑扁平化数据或使用单独的列表组件。

7.1.7 使用 shouldUpdate 控制刷新范围

在 API 24 中,可以通过 shouldUpdate 生命周期钩子手动控制组件是否需要重新渲染:

@Component
struct TaskCard {
  @Prop item: TaskItem;

  shouldUpdate(nextProps: Record<string, Object>): boolean {
    return (nextProps['item'] as TaskItem).id !== this.item.id;
  }
}

7.2 常见错误与解决方案

错误 症状 解决方案
未传 keyGenerator 列表项动画异常、状态丢失 传入基于 ID 的 key 生成器
使用 interface 而非 class 属性修改不触发 UI 刷新 改用 class 定义数据模型
在 ForEach 中直接修改数组 UI 没有反应 使用 push/splice 或整体赋值
对象字面量作为 Builder 参数 编译失败:arkts-no-untyped-obj-literals 使用独立参数或声明接口
在构造函数中初始化 @State 运行时状态丢失 aboutToAppear 中初始化
解构函数参数 编译失败:arkts-no-destruct-params 使用临时变量逐一赋值

八、完整代码清单

以下是完整的 Index.ets 文件代码,可以直接在 HarmonyOS NEXT API 24 项目中运行。

/**
 * 示例:鸿蒙原生 ArkTS 布局方式 —— @State + ForEach 动态列表布局
 *
 * ── 布局要点 ───────────────────────────────────────────────
 * 1. @State 装饰器:声明式响应数据源,数据变化时自动触发 UI 刷新
 * 2. ForEach 指令:基于数据源数组循环渲染列表项,keyGenerator 保证节点复用
 * 3. List / Grid 容器:提供高效的可滚动列表/网格布局能力
 * ───────────────────────────────────────────────────────────
 */

import { promptAction } from '@kit.ArkUI';

/**
 * 数据模型 —— 列表中的每一项
 */
class TaskItem {
  id: number;
  title: string;
  description: string;
  completed: boolean;
  priority: '高' | '中' | '低';

  constructor(id: number, title: string, description: string, priority: '高' | '中' | '低') {
    this.id = id;
    this.title = title;
    this.description = description;
    this.completed = false;
    this.priority = priority;
  }
}

/**
 * 主页面 —— 动态任务列表
 *
 * @Entry 标记该组件为应用入口页面
 * @Component 标记为可复用的自定义组件
 */
@Entry
@Component
struct Index {
  // ─── @State 响应式数据 ────────────────────────────────────
  @State private taskList: TaskItem[] = [];
  @State private inputTitle: string = '';
  @State private selectedPriority: '高' | '中' | '低' = '中';
  private nextId: number = 1;

  /**
   * 组件生命周期 —— aboutToAppear
   * 在此处初始化模拟数据
   */
  aboutToAppear(): void {
    const samples: Array<[string, string, '高' | '中' | '低']> = [
      ['学习 @State 装饰器', '掌握声明式响应式数据绑定', '高'],
      ['理解 ForEach 用法', '学会 keyGenerator 和按需渲染', '高'],
      ['实践 List 组件', '熟悉 List + ForEach 组合模式', '中'],
      ['探索 Grid 布局', '了解网格布局的参数配置', '低'],
      ['完成第一篇笔记', '将学到的知识整理成文档', '中'],
    ];
    samples.forEach((sample) => {
      const title: string = sample[0];
      const desc: string = sample[1];
      const priority: '高' | '中' | '低' = sample[2];
      this.taskList.push(new TaskItem(this.nextId++, title, desc, priority));
    });
  }

  /**
   * 添加任务
   */
  private addTask(): void {
    const title = this.inputTitle.trim();
    if (!title) {
      try { promptAction.showToast({ message: '请输入任务标题', duration: 1500 }); } catch (e) {}
      return;
    }
    this.taskList.push(new TaskItem(this.nextId++, title, '', this.selectedPriority));
    this.inputTitle = '';
    try { promptAction.showToast({ message: `✅ 已添加:「${title}」`, duration: 1500 }); } catch (e) {}
  }

  /**
   * 删除任务(按 ID)
   */
  private removeTask(id: number): void {
    const index = this.taskList.findIndex(item => item.id === id);
    if (index !== -1) {
      this.taskList.splice(index, 1);
      try { promptAction.showToast({ message: '🗑️ 已删除', duration: 1000 }); } catch (e) {}
    }
  }

  /**
   * 切换任务完成状态
   */
  private toggleCompleted(id: number): void {
    const item = this.taskList.find(task => task.id === id);
    if (item) {
      item.completed = !item.completed;
    }
  }

  /**
   * 清空所有已完成的任务
   */
  private clearCompleted(): void {
    this.taskList = this.taskList.filter(item => !item.completed);
  }

  /**
   * 重置为初始示例数据
   */
  private resetToSample(): void {
    this.taskList = [];
    this.nextId = 1;
    this.aboutToAppear();
  }

  // ─── UI 构建 ──────────────────────────────────────────────
  build() {
    Column() {
      // ─── 顶部标题栏 ────────────────────────────────────
      Row() {
        Text('📋 动态任务列表')
          .fontSize(22)
          .fontWeight(FontWeight.Bold)
          .textAlign(TextAlign.Start)
          .layoutWeight(1)

        Text(`共 ${this.taskList.length} 项`)
          .fontSize(14)
          .fontColor('#888888')
      }
      .width('100%')
      .padding({ top: 16, bottom: 8, left: 16, right: 16 })

      // ─── 输入区域:新增任务 ───────────────────────────────
      Row() {
        TextInput({ placeholder: '输入新任务标题…', text: this.inputTitle })
          .onChange((value: string) => { this.inputTitle = value; })
          .layoutWeight(1)
          .height(40)
          .backgroundColor('#F5F5F5')
          .borderRadius(8)
          .padding({ left: 12 })
        Blank()
        Select([{ value: '高' }, { value: '中' }, { value: '低' }])
          .value(`优先: ${this.selectedPriority}`)
          .onSelect((index: number) => {
            const map: Array<'高' | '中' | '低'> = ['高', '中', '低'];
            this.selectedPriority = map[index];
          })
          .height(40)
        Blank()
        Button('➕ 添加')
          .onClick(() => this.addTask())
          .height(40)
          .fontSize(14)
          .borderRadius(8)
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 8, bottom: 8 })
      .alignItems(VerticalAlign.Center)

      // ─── 操作按钮行 ───────────────────────────────────
      Row() {
        Button('🧹 清除已完成')
          .onClick(() => this.clearCompleted())
          .fontSize(13).height(32).borderRadius(6)
          .backgroundColor('#E74C3C')
        Blank()
        Button('🔄 重置示例')
          .onClick(() => this.resetToSample())
          .fontSize(13).height(32).borderRadius(6)
          .backgroundColor('#2ECC71')
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 4, bottom: 8 })

      // ═══════════════════════════════════════════════════════
      // 核心区域:@State + ForEach + List 动态列表布局
      // ═══════════════════════════════════════════════════════
      List() {
        ForEach(
          this.taskList,
          (item: TaskItem, index?: number) => {
            ListItem() {
              this.TaskCard(item, index ?? 0)
            }
            .swipeAction({ end: this.SwipeDeleteButton(item) })
          },
          (item: TaskItem): string => item.id.toString(),
        )
      }
      .width('100%')
      .layoutWeight(1)
      .divider({ strokeWidth: 1, color: '#EEEEEE', startMargin: 16, endMargin: 16 })
      .edgeEffect(EdgeEffect.Spring)

      // ─── 底部提示 ─────────────────────────────────────
      Text('💡 左滑任务项可删除 | 点击任务项切换完成状态')
        .fontSize(12).fontColor('#AAAAAA')
        .textAlign(TextAlign.Center)
        .width('100%').padding(12)
    }
    .width('100%').height('100%').backgroundColor('#FFFFFF')
  }

  // ─── @Builder 构建卡片和按钮 ───────────────────────────────
  @Builder
  TaskCard(item: TaskItem, index: number) {
    Row() {
      Text(`${index + 1}`).fontSize(13).fontColor('#BBBBBB').width(28).textAlign(TextAlign.Center)

      Column() {
        Text(item.title)
          .fontSize(16).fontWeight(FontWeight.Medium)
          .fontColor(item.completed ? '#BBBBBB' : '#333333')
          .decoration({ type: item.completed ? TextDecorationType.LineThrough : TextDecorationType.None })
          .width('100%')
        if (item.description) {
          Text(item.description).fontSize(12).fontColor('#999999').width('100%').margin({ top: 2 })
        }
      }
      .layoutWeight(1).alignItems(HorizontalAlign.Start).justifyContent(FlexAlign.Center)

      Text(item.priority)
        .fontSize(11).fontColor('#FFFFFF')
        .backgroundColor(item.priority === '高' ? '#E74C3C' : item.priority === '中' ? '#F39C12' : '#95A5A6')
        .borderRadius(4).padding({ left: 6, right: 6, top: 2, bottom: 2 }).margin({ right: 8 })

      Text(item.completed ? '✅' : '⬜').fontSize(18)
        .onClick(() => this.toggleCompleted(item.id))
    }
    .width('100%').height(60).padding({ left: 8, right: 16 })
    .alignItems(VerticalAlign.Center)
    .onClick(() => this.toggleCompleted(item.id))
  }

  @Builder
  SwipeDeleteButton(item: TaskItem) {
    Button('🗑️ 删除')
      .onClick(() => this.removeTask(item.id))
      .height('100%').width(80).backgroundColor('#E74C3C').fontColor('#FFFFFF')
      .fontSize(14).borderRadius(0)
  }
}

九、总结与展望

9.1 核心要点回顾

  1. @State 是 ArkTS 响应式编程的基石。它将数据与 UI 绑定,让开发者只需关注数据的变化,框架自动处理 UI 的刷新。

  2. ForEach 必须配合稳定的 keyGenerator 使用。不传 key 或使用数组下标作为 key,会导致组件复用错误、动画异常和状态丢失。

  3. List 容器提供虚拟滚动机制。只渲染可视区域的列表项,支持百万级数据量的流畅滚动。

  4. @Builder 适合封装列表项 UI。对于简单场景,比提取独立 @Component 更简洁。

  5. API 24 进一步强化了状态管理能力@Computed@Observed@ObjectLinkLazyForEach 等特性让复杂场景的代码更加简洁高效。

9.2 下一步学习方向

  • Grid 网格布局Grid + ForEach 实现类似美团、淘宝的网格商品列表
  • LazyForEach + IDataSource:对接网络分页接口,实现无限滚动加载
  • @Provide + @Consume:跨多层组件的状态共享(类似 React Context)
  • @Animatable:为列表项增删添加自定义动画
  • 自定义组件复用:将 TaskCard 提取为独立 @Component,通过 @Prop@Link 传递数据

9.3 写在最后

HarmonyOS NEXT 的 ArkTS 框架在设计上借鉴了 SwiftUI、Jetpack Compose 和 Flutter 等现代声明式 UI 框架的优点,同时结合了鸿蒙分布式、全场景的独特优势。@State + ForEach + List 的组合看似简单,却是构建几乎所有数据驱动页面(信息流、商品列表、聊天记录、待办事项等)的基础。

希望本文能帮助你快速上手鸿蒙原生开发。如果你在实际开发中遇到任何问题,欢迎在评论区留言交流。


版权声明:本文为 HarmonyOS NEXT 开发系列教程之一,遵循 CC BY-NC 4.0 许可协议。文中代码示例可自由用于个人学习和商业项目。


附录:文中涉及 API 24 特性的描述基于 HarmonyOS NEXT 7.0.0 Developer Preview,正式发布时如有调整请以官方文档为准。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

Logo

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

更多推荐