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



目录
- [引言:为什么 Grid + ForEach 是高频布局组合](#引言:为什么 Grid + ForEach 是高频布局组合)
- [Grid 容器:网格布局的基石](#Grid 容器:网格布局的基石)
- [ForEach 指令:声明式循环渲染](#ForEach 指令:声明式循环渲染)
- GridItem:网格单元的构建与交互
- [动态数据管理:@State 与不可变更新](#动态数据管理:@State 与不可变更新)
- 完整示例代码逐段解析
- 编译验证与常见错误排查
- 性能优化与最佳实践
- [从 API 11 迁移到 API 24 的注意事项](#从 API 11 迁移到 API 24 的注意事项)
- 总结
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
columnsGap 和 rowsGap 控制网格单元格之间的间距。在 HarmonyOS API 24 中,这两个属性接受 Length 类型(即 number | string),单位为 vp(虚拟像素)。
需要注意的是,gap 值不会出现在 columnsTemplate 的计算中。例如:三列等分时,每列的实际宽度是 (容器总宽度 - 2 * columnsGap) / 3。
2.5 Grid 的高度与滚动行为
Grid 容器的高度设置直接影响其滚动行为:
-
固定高度 :当 Grid 设置了固定高度(如
height('400vp')),且内容超出该高度时,Grid 会自动变为可滚动容器。用户可以通过手势上下滑动浏览所有网格项。 -
百分比高度 :当 Grid 设置
height('100%')时,它会撑满父容器的可用空间。如果父容器也有明确的尺寸约束,Grid 会在这个范围内展示内容,超出部分进入滚动。 -
自适应高度 :如果不设置 Grid 的高度,或设置为
height('auto'),Grid 会按内容实际高度撑开。这种情况下 Grid 不会滚动,而是由外层的 Scroll 容器或页面本身控制滚动。
在实际项目中,绝大多数场景使用 height('100%') 让 Grid 填充可用区域,然后依赖 Grid 的内置滚动能力。这样做的好处是 Grid 可以配合 LazyForEach 实现高效的按需渲染------只加载可见区域的 GridItem。
2.6 跨列与跨行布局
对于需要合并单元格的复杂布局,GridItem 提供了 columnStart、columnEnd 和 rowStart、rowEnd 属性。这些属性的索引从 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,这会导致以下问题:
- 如果在数组中间插入或删除元素,后续所有 GridItem 会被重建(因为索引变了)。
- 列表动画无法正确追踪每个元素的身份。
- 状态保持(如输入框内容)会发生错乱。
正确的做法是使用业务 ID 作为 key:
(item: GridItemModel): string => item.id.toString()
3.3 ForEach 的工作机制
ForEach 内部维护了一个 key → 组件的映射表。当数据源变化时,它会做三件事:
- 对比新旧 key 集合,找出新增的 key 和消失的 key。
- 删除消失的 key 对应的组件。
- 创建新增的 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];
}
这里关键的两点:
- 自增 ID:新项的 ID 基于最后一项的 ID 递增,确保 key 唯一且稳定。
- 新数组 :展开运算符
[...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 中,深拷贝没有内置的 structuredClone 或 JSON.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 })
这段代码是整个示例的核心,包含了所有关键技术点:
- Grid 容器 :通过
.columnsTemplate()动态生成列模板,支持固定列数和自适应模式切换。 - ForEach 循环 :遍历
gridData数组,为每个元素生成一个 GridItem。 - GridItem 内容:Column 垂直排列 Emoji 图标和文字标签,背景色来自数据模型。
- 点击交互:每个 GridItem 绑定 onClick 事件,实现「点击删除」。
- 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 等),不包含 Purple、Gold、Coral、Navy、Cyan 等。
修复 :使用十六进制字符串 '#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/columnEnd和rowStart/rowEnd属性可以实现合并单元格。
9.4 构建配置变化
API 24 项目的 build-profile.json5 需要配置对应的 SDK 版本:
json5
{
"apiType": "StageModel",
"buildOption": {
"strictMode": {
"useNormalizedOHMUrl": true
}
}
}
10. 总结
Grid + ForEach 是 HarmonyOS NEXT ArkTS 声明式 UI 中最基础、最高效的网格布局组合。通过本文的完整示例,我们深入剖析了以下技术要点:
-
Grid 容器的列模板 :掌握
1fr等分、固定宽度和repeat(auto-fill)自适应三种模式,可以应对绝大多数网格布局需求。 -
ForEach 的 key 机制:始终提供基于业务 ID 的 keyGenerator,是保证性能和正确性的关键。
-
@State 不可变更新:数组和对象必须创建新引用来触发 UI 刷新,这是 ArkTS 声明式 UI 的基本原则。
-
GridItem 的交互与样式:每个网格单元可以独立绑定事件和设置样式,实现丰富的用户交互。
-
API 24 的差异:注意废弃 API 的替换和新增功能的利用。
这个布局模式在鸿蒙应用开发中无处不在。理解并掌握它,你就掌握了 ArkTS 布局系统中最核心的一块拼图。
本文配套的完整示例代码位于 entry/src/main/ets/pages/Index.ets,使用 DevEco Studio 打开项目后,选择模拟器或真机即可运行。
API 24 对应 HarmonyOS NEXT 5.0+ 版本,请在运行前确认 SDK 版本配置正确。