【共创季稿事节】鸿蒙原生 ArkTS 布局实战:Grid + ForEach 动态网格生成

鸿蒙原生 ArkTS 布局实战:Grid + ForEach 动态网格生成(API 24)


目录

  1. [引言:为什么 Grid + ForEach 是高频布局组合](#引言:为什么 Grid + ForEach 是高频布局组合)
  2. [Grid 容器:网格布局的基石](#Grid 容器:网格布局的基石)
  3. [ForEach 指令:声明式循环渲染](#ForEach 指令:声明式循环渲染)
  4. GridItem:网格单元的构建与交互
  5. [动态数据管理:@State 与不可变更新](#动态数据管理:@State 与不可变更新)
  6. 完整示例代码逐段解析
  7. 编译验证与常见错误排查
  8. 性能优化与最佳实践
  9. [从 API 11 迁移到 API 24 的注意事项](#从 API 11 迁移到 API 24 的注意事项)
  10. 总结

1. 引言:为什么 Grid + ForEach 是高频布局组合

在 HarmonyOS NEXT 的 ArkTS 声明式 UI 体系中,网格布局(Grid) 是最常用的一类容器布局。无论是手机上的「九宫格」应用图标、设置页的功能入口、商品浏览的瀑布流,还是平板上的仪表盘看板,底层都离不开 Grid 容器。

ForEach 指令 则是 ArkTS 中循环渲染数据列表的标准方式。它接收一个数组,为每个元素生成对应的 UI 组件。当与 Grid 结合时,ForEach 的每一项对应一个 GridItem,从而实现「数据驱动网格 UI」的经典模式。

这个组合之所以高频,原因有三:

  • 数据驱动:UI 完全由数据决定,增删改数据自动反映到界面上,无需手动操作 DOM。
  • 布局灵活:Grid 的列模板(columnsTemplate)支持固定列数、等分比例(1fr)、自适应(auto-fill)等多种模式。
  • 性能优异:ForEach 内置了高效的 diff 算法,只会增删变化的节点,不会重建整张网格。

本文将通过一个完整示例,带你从零掌握这一布局组合的所有要点。


2. Grid 容器:网格布局的基石

2.1 什么是 Grid

Grid 是 ArkTS 提供的一种二维布局容器,它将可用区域划分为行和列的网格,子组件(GridItem)按顺序填充到单元格中。与 CSS Grid 类似,但在 ArkTS 中是通过属性方法链式配置的。

Grid 容器的核心属性如下:

属性 类型 说明 示例值
columnsTemplate string 列模板,定义列的数量和宽度 '1fr 1fr 1fr'(三列等分)
rowsTemplate string 行模板,定义行的数量和高度 'auto'(自动撑开)
columnsGap Length 列间距(vp) 8
rowsGap Length 行间距(vp) 8
width / height Length 容器尺寸 '100%'
padding Padding 内边距 { left: 12, right: 12 }

2.2 columnsTemplate 的三种模式

columnsTemplate 是 Grid 最关键的属性,它决定了网格的列布局方式。在 API 24 中,支持以下三种模式:

模式一:固定列数 + 等分宽度(1fr)

复制代码
columnsTemplate('1fr 1fr 1fr')   // 三列,每列等宽
columnsTemplate('1fr 2fr 1fr')   // 三列,中间列是两侧的两倍宽

1fr 是弹性单位,代表可用空间的一份。几个 1fr 就等分几列。这是最常用的模式,适合功能入口、图标网格等场景。

模式二:固定宽度(vp / px / %)

复制代码
columnsTemplate('80vp 80vp 80vp')   // 三列,每列固定 80vp
columnsTemplate('100vp 1fr 100vp')  // 两侧固定,中间弹性

适合侧边栏 + 主内容区的布局,或者需要精确控制每列宽度的场景。

模式三:自适应列数(repeat(auto-fill, minWidth))

复制代码
columnsTemplate('repeat(auto-fill, 80vp)')

这是 API 12+ 引入的强大特性。容器会根据自身宽度自动计算能放下多少列,每列最小宽度为 80vp。当容器变宽时列数自动增加,变窄时列数自动减少。这个模式在响应式设计中极其有用。

2.3 rowsTemplate 的行控制

与列模板对称,rowsTemplate 控制行的高度。与列不同的是,大多数网格场景不需要固定行高,因此 'auto'(由内容撑开)是最常用的值。

如果你需要固定高度的行,可以写:

复制代码
rowsTemplate('80vp 80vp 80vp')   // 三行,每行固定 80vp
rowsTemplate('1fr 1fr 1fr')      // 三行,等分垂直空间

2.4 间距 gap

columnsGaprowsGap 控制网格单元格之间的间距。在 HarmonyOS API 24 中,这两个属性接受 Length 类型(即 number | string),单位为 vp(虚拟像素)。

需要注意的是,gap 值不会出现在 columnsTemplate 的计算中。例如:三列等分时,每列的实际宽度是 (容器总宽度 - 2 * columnsGap) / 3

2.5 Grid 的高度与滚动行为

Grid 容器的高度设置直接影响其滚动行为:

  1. 固定高度 :当 Grid 设置了固定高度(如 height('400vp')),且内容超出该高度时,Grid 会自动变为可滚动容器。用户可以通过手势上下滑动浏览所有网格项。

  2. 百分比高度 :当 Grid 设置 height('100%') 时,它会撑满父容器的可用空间。如果父容器也有明确的尺寸约束,Grid 会在这个范围内展示内容,超出部分进入滚动。

  3. 自适应高度 :如果不设置 Grid 的高度,或设置为 height('auto'),Grid 会按内容实际高度撑开。这种情况下 Grid 不会滚动,而是由外层的 Scroll 容器或页面本身控制滚动。

在实际项目中,绝大多数场景使用 height('100%') 让 Grid 填充可用区域,然后依赖 Grid 的内置滚动能力。这样做的好处是 Grid 可以配合 LazyForEach 实现高效的按需渲染------只加载可见区域的 GridItem。

2.6 跨列与跨行布局

对于需要合并单元格的复杂布局,GridItem 提供了 columnStartcolumnEndrowStartrowEnd 属性。这些属性的索引从 0 开始计数:

typescript 复制代码
GridItem() {
  Text('跨两列的标题')
    .fontSize(20).fontWeight(FontWeight.Bold)
    .height(60)
}
.columnStart(0)   // 从第 0 列开始
.columnEnd(1)     // 到第 1 列结束(即占据第 0、1 两列)

跨行合并的用法对称:

typescript 复制代码
GridItem() {
  Text('跨两行的侧边栏')
}
.rowStart(0)
.rowEnd(1)

需要特别注意的是,跨列/跨行设置要与 columnsTemplate 的列数协调一致。如果 columnsTemplate 定义了 3 列,那么 columnEnd 的最大值不能超过 2(从 0 开始计数)。如果跨列总和超过了总列数,布局可能会出现重叠或异常。

2.7 Grid 的嵌套使用

在某些复杂场景下,Grid 可以嵌套使用。例如在外层 Grid 中实现两栏布局,其中一栏内部又是一个 Grid 网格:

typescript 复制代码
Grid() {
  GridItem() {
    // 左侧栏:又是一个 Grid
    Grid() {
      // 左侧的小网格项
    }
    .columnsTemplate('1fr 1fr')
  }
  GridItem() {
    // 右侧内容区
  }
}
.columnsTemplate('1fr 2fr')

嵌套 Grid 时,每一层的 columnsTemplate 独立计算,互不干扰。但需要注意性能开销------每多一层 Grid,布局计算的时间复杂度就会成倍增长。因此建议嵌套深度不超过两层。


3. ForEach 指令:声明式循环渲染

3.1 基本语法

ForEach 是 ArkTS 中用于循环渲染的内置指令,它必须直接作为容器组件的子元素使用。其基本语法如下:

typescript 复制代码
ForEach(
  arr: any[],                              // 数据源数组
  itemGenerator: (item, index?) => void,   // 每一项的组件生成函数
  keyGenerator?: (item, index?) => string  // 键值生成函数(可选,但强烈建议提供)
)

3.2 参数详解

arr ------ 数据源数组

这个数组通常是一个 @State 修饰的状态变量。当数组的内容发生变化时,ForEach 会自动对比新旧数据,只增删变化的部分。需要注意的是,数组的引用必须变化(即产生一个新数组)才能触发更新,原地修改数组元素是不会触发 UI 刷新的。

itemGenerator ------ 组件生成函数

这是一个回调函数,接收当前元素(和可选的索引),返回一个组件。在 Grid 场景下,这个函数必须返回 GridItem(或其子类),因为 Grid 的直接子元素只能是 GridItem。

复制代码
(item: GridItemModel, index?: number) => {
  GridItem() {
    // 网格项的内容
  }
}

keyGenerator ------ 键值生成函数(关键!)

这个函数为每个元素生成一个唯一且稳定的字符串标识。ForEach 使用这个 key 来追踪元素的增删和移动。如果不提供 keyGenerator,ForEach 会使用索引作为默认 key,这会导致以下问题:

  1. 如果在数组中间插入或删除元素,后续所有 GridItem 会被重建(因为索引变了)。
  2. 列表动画无法正确追踪每个元素的身份。
  3. 状态保持(如输入框内容)会发生错乱。

正确的做法是使用业务 ID 作为 key:

复制代码
(item: GridItemModel): string => item.id.toString()

3.3 ForEach 的工作机制

ForEach 内部维护了一个 key → 组件的映射表。当数据源变化时,它会做三件事:

  1. 对比新旧 key 集合,找出新增的 key 和消失的 key。
  2. 删除消失的 key 对应的组件。
  3. 创建新增的 key 对应的组件(复用已有的相同 key 的组件)。

这个过程是声明式 UI 框架的标准 diff 机制,也是 ArkTS 高性能的基础。

3.4 ForEach 与 @State 的状态联动

复制代码
@State private gridData: GridItemModel[] = [];

gridData 赋值一个新数组时(注意是新数组!),ForEach 读取新数组并执行 diff。为了触发这个机制,数据更新必须产生新数组引用:

复制代码
// ✅ 正确:创建新数组
this.gridData = [...this.gridData, newItem];

// ✅ 正确:filter 返回新数组
this.gridData = this.gridData.filter(item => item.id !== id);

// ❌ 错误:原地修改不会触发 UI 刷新
this.gridData.push(newItem);

3.5 ForEach 与 LazyForEach 的选择策略

在 ArkTS 中,ForEach 和 LazyForEach 是两种循环渲染指令,它们有各自适用的场景。

ForEach 适用的场景

  • 数据量小于 100 项。
  • 所有数据需要一次性渲染(如功能入口网格、设置页)。
  • 数据频繁增删,但变化范围不大。

LazyForEach 适用的场景

  • 数据量超过 100 项。
  • 长列表 / 长网格的无限滚动加载。
  • 每个 GridItem 的内容较重(如包含图片加载、网络请求)。

选择策略的核心原则很简单:小数据量用 ForEach,大数据量用 LazyForEach。在 ForEach 中,所有 GridItem 会一次性创建并挂载,这在 100 项以内是完全没有性能压力的。但当数据量达到几百甚至上千时,一次性创建所有组件会导致明显的卡顿和内存飙升,此时必须切换到 LazyForEach。

LazyForEach 要求数据源实现 IDataSource 接口,提供 totalCount()getData(index)registerDataChangeListener() 等方法。从使用复杂度来说,ForEach 远比 LazyForEach 简单------ForEach 只需一个普通数组,而 LazyForEach 需要一个专门的数据源类。这也是为什么在示例应用中我们选择 ForEach 的原因:它足够简单,适合演示核心概念。

3.6 ForEach 中的条件渲染

在某些场景下,你可能需要在 ForEach 内部根据条件渲染不同的 GridItem 样式。ArkTS 支持在 itemGenerator 中使用条件语句:

typescript 复制代码
ForEach(this.gridData, (item: GridItemModel) => {
  GridItem() {
    if (item.id === 0) {
      // 第一个网格项使用特殊样式
      SpecialHeaderItem({ label: item.label })
    } else {
      // 其他项使用普通样式
      NormalGridItem({ label: item.label, color: item.color })
    }
  }
}, (item: GridItemModel) => item.id.toString())

这种条件渲染完全符合声明式 UI 的理念------UI 是数据状态的纯函数。但需要注意,不要在 ForEach 内部做过于复杂的条件判断,否则会影响代码的可读性。如果条件逻辑很复杂,建议抽取到独立的 @Builder 方法或自定义组件中。


4. GridItem:网格单元的构建与交互

4.1 GridItem 的本质

GridItem 是 Grid 的直接子组件,代表一个网格单元格。它本身就是一个容器,内部可以嵌套任意其他组件(Text、Image、Column、Row 等)。

在 ArkTS 的组件树中:

复制代码
Grid
 ├── GridItem
 │    └── Column
 │         ├── Text("图标")
 │         └── Text("标签")
 ├── GridItem
 │    └── ...
 └── ...

GridItem 的特殊之处在于,它的尺寸受 Grid 容器的模板控制。你不应该给 GridItem 设置固定宽高(除非你有特殊的跨列 / 跨行需求),否则会与 columnsTemplate 产生冲突。

4.2 GridItem 的交互事件

GridItem 支持所有标准触摸事件:

复制代码
GridItem()
  .onClick(() => { /* 点击处理 */ })
  .onLongPress(() => { /* 长按处理 */ })
  .onTouch((event) => { /* 触摸事件处理 */ })

在我们的示例中,点击 GridItem 会删除该项。这是通过在 GridItem 上绑定 onClick 实现的:

复制代码
GridItem()
  // ...
  .onClick(() => {
    this.removeGridItem(item.id);
  })

这种「点即删」的交互模式在演示动态数据增删时非常直观。

4.3 GridItem 的样式定制

GridItem 内部的内容完全由开发者控制。在我们的示例中,每个 GridItem 内部是一个 Column,包含一个 Emoji 图标和一个文字标签:

复制代码
Column({ space: 4 }) {
  Text(this.getEmojiByIndex(index ?? 0))
    .fontSize(28)
  Text(item.label)
    .fontSize(14)
    .fontColor(Color.White)
    .fontWeight(FontWeight.Medium)
    .maxLines(1)
    .textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.height(80)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor(item.color)
.borderRadius(12)

关键设计点:

  • 使用 Emoji 代替图标资源,避免了对图片资源的依赖,让示例开箱即用。
  • 文字标签设置了 .maxLines(1).textOverflow(Ellipsis),当标签过长时自动截断显示省略号。
  • 背景色使用 item.color 动态绑定,每个网格项颜色不同,视觉上易于区分。
  • 圆角 .borderRadius(12) 给网格项增加了「卡片感」。

5. 动态数据管理:@State 与不可变更新

5.1 @State 装饰器

在 ArkTS 中,@State 是用于声明组件内部状态的关键装饰器。被 @State 修饰的变量发生变化时,组件会自动重新渲染。

复制代码
@State private gridData: GridItemModel[] = [];
@State private currentColumn: number = 3;
@State private isAdaptiveMode: boolean = false;

5.2 不可变更新的重要性

对于数组和对象类型的 @State 变量,ArkTS 的变更检测基于引用比较。这意味着:

复制代码
// ❌ 不会触发 UI 刷新(引用没变)
this.gridData.push(newItem);

// ❌ 不会触发 UI 刷新(引用没变)
this.gridData.splice(index, 1);

// ✅ 会触发 UI 刷新(创建了新数组)
this.gridData = [...this.gridData, newItem];

// ✅ 会触发 UI 刷新(filter 返回新数组)
this.gridData = this.gridData.filter(item => item.id !== id);

这个规则是鸿蒙 ArkTS 与 React 中 setState 的类似之处。对于习惯了 Vue 响应式系统的开发者来说,需要特别注意这一点------Vue 可以检测到数组的 push/splice 操作,但 ArkTS 不会。

5.3 添加数据

添加数据时,使用展开运算符创建新数组:

复制代码
private addGridItem(): void {
  const newId = this.gridData.length > 0
    ? this.gridData[this.gridData.length - 1].id + 1
    : 0;
  const newItem = new GridItemModel(newId, `项目 ${newId}`, color);
  this.gridData = [...this.gridData, newItem];
}

这里关键的两点:

  1. 自增 ID:新项的 ID 基于最后一项的 ID 递增,确保 key 唯一且稳定。
  2. 新数组 :展开运算符 [...this.gridData, newItem] 创建了一个全新的数组。

5.4 删除数据

删除数据时,使用 filter 返回新数组:

复制代码
private removeGridItem(id: number): void {
  if (this.gridData.length <= 1) {
    return;  // 至少保留一项
  }
  this.gridData = this.gridData.filter(item => item.id !== id);
}

filter 方法天然返回一个新数组,不需要额外操作。

5.5 替换数据

如果需要修改某个网格项的内容,同样需要创建新数组:

复制代码
// 修改 id === 2 的项的 label
this.gridData = this.gridData.map(item =>
  item.id === 2 ? new GridItemModel(2, '新标题', item.color) : item
);

5.6 深拷贝与对象不可变

当 GridItemModel 中包含对象类型字段(如嵌套的对象或数组)时,简单的数组引用变更不足以触发深层字段的 UI 刷新。例如:

typescript 复制代码
class GridItemModel {
  public id: number;
  public label: string;
  public color: ResourceColor;
  public subItems: string[];  // 嵌套数组
}

如果修改了 subItems 中的某个元素,仅替换外层数组是不够的------内层数组的引用没有变化,GridItem 内部使用 subItems 的地方不会检测到变化。

解决方案是对整个对象进行深拷贝:

typescript 复制代码
// 修改某个网格项的 subItems
this.gridData = this.gridData.map(item => {
  if (item.id === targetId) {
    return new GridItemModel(
      item.id,
      item.label,
      item.color,
      [...item.subItems, '新元素']  // 创建新数组
    );
  }
  return item;
});

在 ArkTS 中,深拷贝没有内置的 structuredCloneJSON.parse(JSON.stringify(obj)) 方式(后者也不推荐,会丢失类型信息)。建议的做法是手动构建新对象,或者为数据模型类添加一个 clone() 方法:

typescript 复制代码
class GridItemModel {
  // ... 字段和构造函数

  public clone(overrides?: Partial<GridItemModel>): GridItemModel {
    return new GridItemModel(
      overrides?.id ?? this.id,
      overrides?.label ?? this.label,
      overrides?.color ?? this.color
    );
  }
}

// 使用示例
this.gridData = this.gridData.map(item =>
  item.id === targetId ? item.clone({ label: '更新标题' }) : item
);

5.7 状态提升与数据共享

在大型应用中,同一个数据源可能需要在多个组件间共享。此时有两种方案:

方案一:逐层传递

父组件持有 @State 数据,通过 @Prop@Link 传递给子组件:

typescript 复制代码
@Component
struct Parent {
  @State gridData: GridItemModel[] = [];

  build() {
    Child({ data: this.gridData })
  }
}

@Component
struct Child {
  @Prop data: GridItemModel[];

  build() {
    Grid() {
      ForEach(this.data, (item: GridItemModel) => {
        GridItem() { Text(item.label) }
      }, (item: GridItemModel) => item.id.toString())
    }
  }
}

方案二:全局状态(AppStorage / LocalStorage)

对于需要在多个页面间共享的数据,使用 @StorageLink@LocalStorageLink

typescript 复制代码
// 在 EntryAbility 中初始化
AppStorage.setOrCreate('gridData', JSON.stringify(initialData));

// 在任何组件中读取和修改
@StorageLink('gridData') gridData: string = '';

选择方案的关键在于数据的作用域:仅在同一页面内共享用 @Prop/@Link,跨页面共享用 AppStorage/LocalStorage。

5.8 @Watch 监听状态变化

在某些场景下,你可能需要在数据变化时执行额外的逻辑(如保存到本地、发送网络请求)。@Watch 装饰器可以监听 @State 变量的变化:

typescript 复制代码
@State @Watch('onGridDataChanged') gridData: GridItemModel[] = [];

private onGridDataChanged(): void {
  console.info('网格数据已变化,当前数量:' + this.gridData.length);
  // 执行额外的副作用逻辑
}

@Watch 的回调在 @State 变量被赋值新值后触发。需要注意的是,@Watch 不能检测数组内部元素属性的变化------它只能检测到数组引用本身的变化。


6. 完整示例代码逐段解析

6.1 数据模型定义

typescript 复制代码
class GridItemModel {
  public id: number;
  public label: string;
  public color: ResourceColor;

  constructor(id: number, label: string, color: ResourceColor) {
    this.id = id;
    this.label = label;
    this.color = color;
  }
}

设计要点

  • id 是唯一标识,也是 ForEach 的 key 来源。
  • color 的类型是 ResourceColor 而非 Color。因为 ResourceColor 可以接受 Color 枚举值(如 Color.Red)和十六进制字符串(如 '#FFD700'),灵活性更高。如果使用 Color 类型,就只能接受枚举中的值,无法使用自定义色值。
  • 使用 class 而非 interface,因为 class 可以包含构造函数和业务方法,更适合在业务层使用。

6.2 初始化数据

typescript 复制代码
private initGridData(): void {
  const colors: ResourceColor[] = [
    Color.Red, Color.Orange, Color.Yellow, Color.Green,
    Color.Blue, Color.Pink, Color.Grey, Color.Brown,
    '#FF8080', '#FFD700', '#FF69B4', '#008080'
  ];
  const labels: string[] = [
    '首页', '通讯录', '发现', '我的',
    '消息', '设置', '相册', '音乐',
    '视频', '文件', '日历', '天气'
  ];

  const data: GridItemModel[] = [];
  for (let i = 0; i < labels.length; i++) {
    data.push(new GridItemModel(i, labels[i], colors[i % colors.length]));
  }
  this.gridData = data;
}

设计要点

  • 使用多个 Color 枚举值加上十六进制字符串混合搭配,展示了 ResourceColor 的灵活性。
  • 12 个网格项分别对应不同的功能图标,模拟了真实应用的功能入口。
  • colors[i % colors.length] 确保颜色循环使用,不会越界。

6.3 核心 Grid + ForEach 布局

typescript 复制代码
Grid() {
  ForEach(this.gridData, (item: GridItemModel, index?: number) => {
    GridItem() {
      Column({ space: 4 }) {
        Text(this.getEmojiByIndex(index ?? 0)).fontSize(28)
        Text(item.label)
          .fontSize(14).fontColor(Color.White)
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
      }
      .width('100%').height(80)
      .justifyContent(FlexAlign.Center)
      .alignItems(HorizontalAlign.Center)
      .backgroundColor(item.color)
      .borderRadius(12)
    }
    .onClick(() => { this.removeGridItem(item.id); })
  }, (item: GridItemModel): string => item.id.toString())
}
.columnsTemplate(this.getColumnsTemplate())
.rowsTemplate('auto')
.columnsGap(8)
.rowsGap(8)
.width('100%')
.padding({ left: 12, right: 12 })

这段代码是整个示例的核心,包含了所有关键技术点:

  1. Grid 容器 :通过 .columnsTemplate() 动态生成列模板,支持固定列数和自适应模式切换。
  2. ForEach 循环 :遍历 gridData 数组,为每个元素生成一个 GridItem。
  3. GridItem 内容:Column 垂直排列 Emoji 图标和文字标签,背景色来自数据模型。
  4. 点击交互:每个 GridItem 绑定 onClick 事件,实现「点击删除」。
  5. key 生成 :使用 item.id.toString() 作为唯一标识,确保 ForEach 高效 diff。

6.4 列模板的动态生成

typescript 复制代码
private getColumnsTemplate(): string {
  if (this.isAdaptiveMode) {
    return `repeat(auto-fill, ${this.minItemWidth}vp)`;
  } else {
    const fragments: string[] = [];
    for (let i = 0; i < this.currentColumn; i++) {
      fragments.push('1fr');
    }
    return fragments.join(' ');
  }
}

这是响应式设计的核心方法 。根据 isAdaptiveMode 状态返回不同的模板字符串:

  • 固定模式 :生成 '1fr 1fr 1fr' 这样的字符串,列数由用户通过按钮切换(2/3/4)。
  • 自适应模式 :生成 'repeat(auto-fill, 80vp)',让容器自动计算列数。

使用字符串拼接生成模板,而不是硬编码,让 UI 的布局策略完全由数据驱动。

6.5 UI 操作面板

typescript 复制代码
Row({ space: 8 }) {
  Button('➕ 添加').onClick(() => this.addGridItem())
  Button('➖ 删除').onClick(() => { /* 删除最后一个 */ })
  Button('🔁 列数').onClick(() => this.cycleColumnCount())
  Button('📐 自适应').onClick(() => this.toggleAdaptiveMode())
}
.width('100%')
.padding({ left: 12, right: 12 })

操作面板的每个按钮控制一种数据变化,让用户直观地看到 Grid 对增/删/列数切换/自适应模式的实时响应。

交互对应关系

操作 UI 效果 触发机制
添加 网格末尾新增一项 [...this.gridData, newItem]
删除 最后一项消失 filter()
列数 列布局立即重新排列 columnsTemplate 字符串变化
自适应 网格根据容器宽度自动调整列数 columnsTemplate 切换为 auto-fill

7. 编译验证与常见错误排查

7.1 编译验证方法

在 HarmonyOS NEXT API 24 项目中,使用以下命令编译验证:

bash 复制代码
hvigorw --mode module -p module=entry -p product=default assembleHap

输出 BUILD SUCCESSFUL 表示无错误通过。

7.2 常见编译错误及修复

错误 1:Color 枚举值不存在
复制代码
Property 'Purple' does not exist on type 'typeof Color'.

原因Color 枚举在 API 24 中只包含基础颜色值(Red、Orange、Yellow、Green、Blue、Pink、Grey、Brown、Black、White 等),不包含 PurpleGoldCoralNavyCyan 等。

修复 :使用十六进制字符串 '#800080' 代替 Color.Purple。同时将变量类型从 Color 改为 ResourceColor,因为 ResourceColor 支持 Color 枚举和字符串两种形式。

错误 2:Color.fromArgb() 不存在
复制代码
Property 'fromArgb' does not exist on type 'typeof Color'.

原因Color.fromArgb() 是某些版本中的 API,但在 API 24 中不是标准 Color 的成员。

修复:直接使用十六进制字符串色值。

错误 3:类型不匹配
复制代码
Argument of type 'ResourceColor' is not assignable to parameter of type 'Color'.

原因 :数据模型中 color 字段声明为 Color 类型,但实际赋值时用了十六进制字符串。

修复 :将数据模型的 color 字段改为 ResourceColor 类型。

错误 4:showToast 已废弃
复制代码
'showToast' has been deprecated.

原因promptAction.showToast() 在 API 24 中已被标记为废弃。

修复 :使用 this.getUIContext().getPromptAction().showToast() 替代。同时建议用 try-catch 包裹,因为上下文获取和弹窗操作都可能抛出异常。

错误 5:ForEach key 重复
复制代码
ForEach: duplicate key detected.

原因:数据源中两个元素返回了相同的 key 值。

修复:确保 keyGenerator 返回唯一值,通常使用业务数据中的 id 字段。

7.3 运行时常见问题

问题:数据变了但 UI 没刷新

原因:直接调用了数组的 push/pop/splice 等方法,没有产生新数组引用。

修复 :使用展开运算符 [...arr, newItem]arr.filter() / arr.map() 等返回新数组的方法。

问题:Grid 内容超出容器

原因 :Grid 没有设置高度约束,或者设置了 height('100%') 但父容器也没有高度。

修复:确保 Grid 的父容器有明确的高度约束。

问题:自适应模式不生效

原因repeat(auto-fill, ...) 需要 Grid 容器有明确的宽度约束,且 auto-fill 关键字在 API 24 中可用。

修复 :检查 Grid 是否设置了 width('100%'),以及父容器是否提供了宽度。


8. 性能优化与最佳实践

8.1 始终提供 keyGenerator

这是最重要的一条优化建议。如果不提供 keyGenerator,ForEach 会使用索引作为 key,这样在数组中间插入或删除元素时,后面的所有项都会重建。

复制代码
// ✅ 好
ForEach(data, item => GridItem() { ... }, item => item.id.toString())

// ❌ 不好(默认使用索引)
ForEach(data, item => GridItem() { ... })

8.2 保持 key 的稳定性

Key 应该在元素的整个生命周期中保持不变。不要在 key 中拼接可变数据:

复制代码
// ❌ 不好:label 变化时 key 会变,导致 GridItem 重建
(item) => item.id + '-' + item.label

// ✅ 好:id 从不变化
(item) => item.id.toString()

8.3 使用 LazyForEach 替代 ForEach(大数据量)

对于超过 100 项的大型网格,应该使用 LazyForEach 替代 ForEach。LazyForEach 只渲染可见区域内的项,大幅减少内存占用和渲染开销。

复制代码
// 大数据量时使用
LazyForEach(this.dataSource, (item: GridItemModel) => {
  GridItem() { /* ... */ }
}, (item: GridItemModel) => item.id.toString())

LazyForEach 需要一个实现了 IDataSource 接口的数据源,而不是普通数组。

8.4 避免 GridItem 中嵌套过深

GridItem 内部的组件树不宜过深。每多一层嵌套,渲染和布局计算的开销都会增加。在保证布局需求的前提下,尽可能扁平化。

8.5 合理使用 gap 和 padding

Column 和 Row 的 space 参数,以及 Grid 的 gap 属性,在布局计算上比嵌套容器更高效。优先使用 gap 和 space 来控制间距,而非用多余的 Container + margin。

8.6 使用 @Builder 抽取复用子组件

当 GridItem 的内容复杂时,可以使用 @Builder 抽取:

复制代码
@Builder gridItemContent(item: GridItemModel) {
  Column() {
    Text(item.label).fontSize(14)
    // ... 更多子组件
  }
}

然后在 ForEach 中调用:

复制代码
GridItem() { this.gridItemContent(item) }

8.7 状态数据的不可变操作工具

对于复杂的状态更新,可以封装工具函数:

复制代码
// 插入
private insertAt(index: number, item: GridItemModel): void {
  this.gridData = [
    ...this.gridData.slice(0, index),
    item,
    ...this.gridData.slice(index)
  ];
}

// 更新
private updateItem(id: number, updater: (item: GridItemModel) => GridItemModel): void {
  this.gridData = this.gridData.map(item =>
    item.id === id ? updater(item) : item
  );
}

// 移动
private moveItem(fromIndex: number, toIndex: number): void {
  const newData = [...this.gridData];
  const [removed] = newData.splice(fromIndex, 1);
  newData.splice(toIndex, 0, removed);
  this.gridData = newData;
}

9. 从 API 11 迁移到 API 24 的注意事项

如果你的项目从 HarmonyOS API 11 升级到 API 24(HarmonyOS NEXT 5.0+),以下变化需要关注:

9.1 废弃的 API

API 11 方式 API 24 推荐方式 说明
promptAction.showToast() this.getUIContext().getPromptAction().showToast() showToast 已废弃
@State 装饰器基础语法 保持不变,但行为更严格 引用变化检测更严格
Column({ space: 8 }) 保持不变 space 参数在 API 24 中性能更好

9.2 @State 的行为变化

在 API 24 中,@State 的变更检测更加严格。对于对象类型,如果修改对象的属性而不是替换对象引用,可能不会触发 UI 刷新。

复制代码
// API 24 中可能不会刷新
item.label = '新标签';     // ❌

// API 24 中一定刷新
this.gridData[index] = new GridItemModel(item.id, '新标签', item.color);  // ✅ 但只改了引用
this.gridData = [...this.gridData];                                        // ✅ 新数组

9.3 新增的布局能力

API 24 新增或增强了以下布局能力:

  • auto-fill 在 columnsTemplate 中得到更好的性能优化。
  • LazyForEach 支持更细粒度的缓存控制。
  • GridItem 的跨行跨列 :通过 columnStart/columnEndrowStart/rowEnd 属性可以实现合并单元格。

9.4 构建配置变化

API 24 项目的 build-profile.json5 需要配置对应的 SDK 版本:

json5 复制代码
{
  "apiType": "StageModel",
  "buildOption": {
    "strictMode": {
      "useNormalizedOHMUrl": true
    }
  }
}

10. 总结

Grid + ForEach 是 HarmonyOS NEXT ArkTS 声明式 UI 中最基础、最高效的网格布局组合。通过本文的完整示例,我们深入剖析了以下技术要点:

  1. Grid 容器的列模板 :掌握 1fr 等分、固定宽度和 repeat(auto-fill) 自适应三种模式,可以应对绝大多数网格布局需求。

  2. ForEach 的 key 机制:始终提供基于业务 ID 的 keyGenerator,是保证性能和正确性的关键。

  3. @State 不可变更新:数组和对象必须创建新引用来触发 UI 刷新,这是 ArkTS 声明式 UI 的基本原则。

  4. GridItem 的交互与样式:每个网格单元可以独立绑定事件和设置样式,实现丰富的用户交互。

  5. API 24 的差异:注意废弃 API 的替换和新增功能的利用。

这个布局模式在鸿蒙应用开发中无处不在。理解并掌握它,你就掌握了 ArkTS 布局系统中最核心的一块拼图。


本文配套的完整示例代码位于 entry/src/main/ets/pages/Index.ets,使用 DevEco Studio 打开项目后,选择模拟器或真机即可运行。

API 24 对应 HarmonyOS NEXT 5.0+ 版本,请在运行前确认 SDK 版本配置正确。