鸿蒙原生ArkTS布局方式之Grid固定列数网格
鸿蒙 ArkTS 布局实战:Grid 固定列数网格——商品网格与图库的分列展示之道
本文基于 HarmonyOS NEXT 6.1.1(API 24)与 DevEco Studio 开发环境,以一个可直接运行的 ArkTS 示例应用为载体,深入讲解鸿蒙原生布局体系中"Grid 固定列数网格"的完整用法:从核心概念、属性语法到商品网格与图库两大真实场景的落地实现,并给出全部可运行代码与中文注释。
目录
- 引言:当商品遇上网格
- 环境准备:DevEco Studio 与工程创建
- 布局选型:为什么"分列展示"要用 Grid
- Grid 核心概念:columnsTemplate、fr 单位与 GridItem
- 实战一:商品网格——从数据模型到卡片渲染
- 实战二:图库网格——风格复用与间距控制
- 交互增强:点击卡片弹出 Toast
- 完整代码清单
- 运行效果与页面注册
- 进阶话题:性能、滚动与适配建议
- 总结
一、引言:当商品遇上网格
打开任何一个电商 App,首页扑面而来的几乎都是"商品网格":一屏之内,商品按三列或两列整整齐齐地排列,每件商品占据一个等宽的格子,上下滑动即可浏览成百上千件商品。打开手机相册,看到的同样是网格——缩略图以固定的列数铺满屏幕,点击任意一张即可进入大图预览。
为什么"网格"会成为这类页面的不二之选?原因有三:
- 空间利用率高:等宽等距的格子可以让一屏容纳更多信息,用户在单位视野内能看到最多的内容;
- 浏览效率好:人眼习惯于"从左到右、从上到下"的扫视路径,网格天然贴合这一视觉习惯,用户可以在极短时间内定位目标;
- 实现成本低:开发者只需要声明"分成几列、间距多少",剩下的对齐、换行、滚动全部由框架完成,不需要手工计算坐标。
在 HarmonyOS NEXT 的 ArkUI 声明式开发体系中,实现这种"分列展示"最直接、最正统的方式,就是使用 Grid 容器组件。Grid 是鸿蒙为网格场景量身打造的高性能布局组件,配合 columnsTemplate 属性声明列模板,一行代码即可完成"固定列数"的布局声明:
Grid() {
// GridItem 网格单元...
}
.columnsTemplate('1fr 1fr 1fr') // 固定 3 列,均分宽度
本文要实现的示例应用,就是围绕这一行核心代码展开的:一个包含"商品网格"与"图库网格"两个场景的完整页面,全部代码均可直接运行,并配有详细的中文注释,帮助你彻底吃透 Grid 固定列数网格的每一个细节。
二、环境准备:DevEco Studio 与工程创建
在动手写代码之前,先确认开发环境。本文示例基于以下环境:
| 项目 | 版本 / 说明 |
|---|---|
| 操作系统 | Windows 10/11 及以上 |
| 开发工具 | DevEco Studio(HarmonyOS NEXT 版本) |
| SDK | HarmonyOS NEXT 6.1.1(API 24) |
| 开发语言 | ArkTS(TypeScript 的鸿蒙超集) |
| 应用模型 | Stage 模型 |
Step 1:创建工程
打开 DevEco Studio,依次选择 File → New → Create Project,在模板列表中选择 Empty Ability(空工程模板),填写工程名称(例如 GridDemo)、包名与保存路径,点击 Finish 完成创建。创建完成后,工程会自动生成一个标准的 Stage 模型骨架,其中与我们今天相关的主要目录如下:
GridDemo/
├── AppScope/ # 应用级配置
├── entry/
│ └── src/
│ └── main/
│ ├── ets/
│ │ ├── entryability/ # 入口 Ability
│ │ └── pages/ # 页面目录(我们在这里写页面)
│ ├── resources/ # 资源目录
│ └── module.json5 # 模块配置
└── build-profile.json5 # 工程构建配置
Step 2:确认 SDK 与 API 版本
打开工程根目录的 build-profile.json5,其中 compatibleSdkVersion 与 targetSdkVersion 应配置为与 HarmonyOS NEXT 6.1.1(API 24)相匹配的版本号。这一步的意义在于:不同的 API 版本决定了 ArkUI 组件的可用属性集合,本文用到的 columnsTemplate、columnsGap、rowsGap 等 API 在 API 24 上均已稳定支持,可以放心使用。
Step 3:规划页面文件
默认工程会在 entry/src/main/ets/pages/ 下生成一个 Index.ets 作为首页。为了不破坏模板结构,我们新建一个独立的页面文件 GridFixedColumns.ets 承载示例代码,稍后再把它注册到路由配置 main_pages.json 中。这样一个工程内同时保留了默认首页与示例页,便于对比学习。
环境就绪之后,下一节我们先做一次"布局选型"的推演,搞清楚为什么偏偏是 Grid。
三、布局选型:为什么"分列展示"要用 Grid
在 ArkUI 中,能实现"多列排列"的组件并不只有 Grid 一个。动手之前,我们有必要把常见的候选方案放在一起对比,理解各自的适用边界。这不仅是面试中的高频考点,更是工程落地时避免"用错组件"的关键。
3.1 候选方案一:Row / Column 手工排列
Row(横向排列)与 Column(纵向排列)是 ArkUI 中最基础的两个线性布局容器。理论上,我们可以用 Row 循环嵌套实现多列效果:
// 反例:用 Row 手工拼多列,代码冗余且难以维护
Row({ space: 12 }) {
ForEach(this.row1, (item) => { /* 第 1 行卡片 */ })
}
Row({ space: 12 }) {
ForEach(this.row2, (item) => { /* 第 2 行卡片 */ })
}
// 有多少行就要写多少个 Row,数据变化时极易出错
这种方式存在的问题非常明显:
- 行数写死:数据量变化时,需要手动增删
Row,无法自适应; - 无法滚动:内容超出屏幕后,需要额外套
Scroll,且滚动粒度为"整行",体验生硬; - 宽度对齐靠运气:每行最后一个格子的对齐、间距计算都需要开发者手工保证。
结论:Row/Column 适合"结构固定、数量有限"的简单场景,不适合商品流这类动态数据。
3.2 候选方案二:List(列表)
List 是鸿蒙最常用的滚动容器,配合 ListItem 也可以实现多列效果。例如把每一行视作一个 ListItem,行内再用 Row 排三列。这种"List 套 Row"的写法在早期项目里很常见:
// 早期方案:List 套 Row 模拟网格
List({ space: 12 }) {
ForEach(this.groupedData, (row) => {
ListItem() {
Row({ space: 12 }) {
ForEach(row, (item) => { /* 卡片 */ })
}
}
})
}
它解决了"滚动"问题,但依然存在缺陷:
- 分组逻辑要自己写:必须先把一维数组切成"每 3 个一组"的二维结构,数据切分逻辑容易出 bug;
- 行内不足 3 个时补位麻烦:最后一行只有 1~2 个商品时,空位处理非常别扭;
- 滚动性能:
List对整行做复用,粒度粗于Grid的单元级复用。
3.3 候选方案三:Grid(本文主角)
Grid 是 ArkUI 专为网格场景设计的容器组件,它与 List 同源(都继承自 Scrollable 滚动体系),但比"List 套 Row"做得更彻底:
| 对比维度 | Row/Column 手工拼 | List 套 Row | Grid |
|---|---|---|---|
| 多列声明 | 手工写死 | 手工切分组 | 一行模板声明 |
| 自动换行 | 不支持 | 不支持 | 支持 |
| 滚动能力 | 需外套 Scroll | 原生支持 | 原生支持 |
| 单元复用 | 无 | 行级复用 | 单元级复用 |
| 列数/间距调整 | 改动大 | 改动大 | 只改属性 |
| 动态数据适配 | 差 | 一般 | 优秀 |
从表格可以清晰看出,对于"商品网格、图库"这类数据动态、需要分列、需要滚动的场景,Grid 是唯一"只声明一次、其余全交给框架"的优雅答案。这也是本文选择 Grid 作为讲解对象的原因。
补充说明:
Grid与List一样支持ForEach与LazyForEach,数据量极大时(比如上千件商品)可以无缝切换到懒加载模式,这一话题我们会在第十章展开。
四、Grid 核心概念:columnsTemplate、fr 单位与 GridItem
在写第一个例子之前,先建立对 Grid 的完整认知。Grid 的使用可以拆解为三个层次:模板声明(怎么分列)→ 单元填充(每个格子里放什么)→ 间距与滚动(视觉与交互细节)。
4.1 模板声明:columnsTemplate 与 rowsTemplate
columnsTemplate 是 Grid 的核心属性,它用一个字符串声明"列模板",字符串中以空格分隔的每一段代表一列,段的长度值表示该列在总宽度中所占的比例。最常见的写法是等分:
.columnsTemplate('1fr 1fr 1fr') // 3 列,每列宽度相等
这里出现的 fr 是"fraction(分数)“的缩写,是 ArkUI 网格模板中的比例单位。它的计算规则是:先把所有 fr 值相加得到分母,再用容器总宽度乘以"当前列 fr 值 / fr 总和”,得到该列的实际宽度。举例来说:
'1fr 1fr 1fr':总份数 3,每列占 1/3,即三列等宽;'1fr 2fr 1fr':总份数 4,中间列宽度是两侧列的 2 倍;'2fr 1fr':总份数 3,第一列占 2/3,第二列占 1/3。
// 用不同模板对比效果
Grid() { /* ... */ }
.columnsTemplate('1fr 2fr 1fr') // 突出中间列,适合"主图+侧栏"类布局
需要注意的是,模板字符串中列与列之间必须用空格分隔,且每段只能是一个数字加 fr(或固定长度值,如 '100px 1fr'),不能写运算符或表达式。同样地,rowsTemplate 用于声明行模板,例如 '1fr 1fr' 表示固定两行;如果只声明 columnsTemplate 而不声明 rowsTemplate,则行高由 GridItem 内容自适应,这也是本文商品网格采用的方式。
4.2 单元填充:GridItem
Grid 容器的直接子组件只能是 GridItem(这一点与 List 的直接子组件只能是 ListItem 同理)。每个 GridItem 代表一个网格单元,我们通常在 GridItem 内部再嵌套 Column、Row 等普通组件来搭建卡片内容:
Grid() {
GridItem() {
Column() {
Text('商品名')
Text('¥299')
}
}
GridItem() {
// 第二个单元...
}
}
当 Grid 只有固定列模板、没有固定行模板时,GridItem 会按照"先从左到右、再从上到下"的顺序自动排列:第一行填满 3 个单元后,第 4 个单元自动换到第二行,行数随数据量自动增长。这就是固定列数网格最迷人的地方——我们只声明"几列",剩下交给框架。
4.3 间距与滚动:columnsGap、rowsGap 与 scrollBar
columnsGap(vp):设置列与列之间的间距,单位是 vp(虚拟像素,与 dp 等价);rowsGap(vp):设置行与行之间的间距;scrollBar(BarState.Off):隐藏滚动条。Grid 内容超过可视区域后可以上下滚动,滚动条默认显示,在商品流页面中通常隐藏以保持界面干净。
Grid() { /* ... */ }
.columnsTemplate('1fr 1fr 1fr')
.columnsGap(12) // 列间距 12vp
.rowsGap(12) // 行间距 12vp
.scrollBar(BarState.Off) // 隐藏滚动条
至此,Grid 的三层认知已经建立。接下来进入实战环节——先实现"商品网格"场景,把刚才的概念一一落到代码里。
4.4 尺寸单位:vp、px 与 fr 的区别
在 ArkUI 中会同时遇到三种尺寸单位,初学者极易混淆,这里一并厘清:
- vp(virtual pixel,虚拟像素):与屏幕密度无关的逻辑单位,1vp 在不同设备上的物理像素数不同(高密度屏 1vp 对应的 px 更多)。开发中所有尺寸、间距、字号都应优先使用 vp,保证不同分辨率设备上"看起来一样大";
- px(物理像素):屏幕上的真实像素点。直接使用 px 会导致高分辨率手机上 UI 偏小、低分辨率手机上偏大,因此仅在与系统底层交互时才会用到;
- fr(fraction,分数):Grid 模板专用的比例单位,只存在于
columnsTemplate/rowsTemplate字符串中,表示"按比例分配剩余空间",不存在于其他组件的尺寸属性中。
三种单位各司其职:vp 定尺寸,px 存底层,fr 分空间。理解它们的区别,是写出"一套代码、多端适配"的网格布局的前提。
// vp 与 fr 的典型组合
Grid() { /* ... */ }
.columnsTemplate('1fr 1fr 1fr') // fr:列按比例分宽
.columnsGap(12) // vp:间距用逻辑像素
.height(200) // vp:固定高度用逻辑像素
五、实战一:商品网格——从数据模型到卡片渲染
5.1 设计页面结构
我们的商品网格页面整体采用"纵向滚动大框架 + 网格子区域"的嵌套结构:
Scroll(页面整体可滚动)
└── Column(纵向排列:标题 → 商品网格 → 图库网格)
├── Text(页面大标题)
├── Text(布局说明)
├── Text(场景一标题)
├── Grid(商品网格,3 列固定)
│ └── ForEach → GridItem → ProductCard(@Builder 卡片)
├── Text(场景二标题)
└── Grid(图库网格,3 列固定)
└── ForEach → GridItem → GalleryCard(@Builder 卡片)
为什么要再包一层 Scroll?因为一个页面里有两个 Grid,如果两个 Grid 各自滚动,体验会很割裂;用外层 Scroll 把标题与两个网格串成"一整条内容流",更符合"瀑布流式长页面"的直觉。同时每个 Grid 依然保留独立的 scrollBar(BarState.Off) 设置,保证内容超出时网格内部仍可滚动。
5.2 第一步:定义数据模型
ArkTS 是静态类型语言,遵循严格的类型约束,因此我们首先用 class 定义商品的数据结构。这里有一个容易踩的坑:ArkTS 中类的属性必须在声明时初始化或在构造函数中赋值,否则编译会报"属性没有初始值"的错误。
// 商品数据模型:用于商品网格场景
class ProductItem {
name: string; // 商品名称
price: string; // 商品价格(字符串便于展示 ¥ 符号)
color: string; // 占位色块颜色(实际项目中此处应为图片资源)
icon: string; // 占位图标(用 emoji 模拟商品图,避免示例依赖图片资源)
constructor(name: string, price: string, color: string, icon: string) {
this.name = name;
this.price = price;
this.color = color;
this.icon = icon;
}
}
有两点设计考量值得说明:
- price 用 string 而非 number:价格展示天然带货币符号,用字符串直接存
'¥299',渲染时零转换,简单直观。如果后续要做价格计算、排序,再改为number并用format格式化展示即可; - 用 emoji 代替图片资源:示例工程没有准备图片素材,直接
Image($r('app.media.xxx'))会因资源不存在而编译失败。改用"色块 + emoji"占位,既保证示例可运行,又清晰标注了替换为真实图片的位置。
5.3 第二步:声明页面与状态
页面主体使用 @Entry @Component 装饰器声明。@Entry 标记这是一个可独立加载的入口页面(必须配合路由注册,见第九章);@Component 将其装饰为自定义组件。
@Entry
@Component
struct GridFixedColumns {
// @State 修饰的状态变量:数据变化时,UI 自动刷新(响应式编程)
@State products: ProductItem[] = [
new ProductItem('无线耳机', '¥299', '#4A90D9', '🎧'),
new ProductItem('智能手表', '¥1299', '#50B5A9', '⌚'),
new ProductItem('蓝牙音箱', '¥199', '#F5A623', '🔊'),
new ProductItem('机械键盘', '¥459', '#7B6FD0', '⌨️'),
new ProductItem('电竞鼠标', '¥159', '#E8634A', '🖱️'),
new ProductItem('4K显示器', '¥2499', '#3BB0DB', '🖥️'),
new ProductItem('充电宝', '¥89', '#8B9D5A', '🔋'),
new ProductItem('手机支架', '¥39', '#C77B8E', '📱'),
new ProductItem('降噪耳麦', '¥599', '#5B8D6E', '🎙️')
];
// ...其余代码
}
这里 @State 是 ArkUI 响应式体系的核心:当 products 数组的内容发生变化时(例如上拉加载更多后追加数据),框架会自动 diff 并只刷新受影响的 GridItem,无需手动操作 DOM。9 件商品恰好构成 3 列 × 3 行的正方形,视觉上最直观。
5.4 第三步:用 @Builder 抽取可复用卡片
商品卡片在网格中会被反复创建,如果直接在 GridItem 里写一堆 Text/Stack,代码会迅速膨胀。ArkUI 提供了 @Builder 装饰器来抽取可复用的 UI 片段——它相当于"UI 级函数",可以在一个页面内声明、多处调用:
// @Builder 用于抽取可复用的 UI 片段,传入一个商品对象即可生成一张卡片
@Builder
ProductCard(item: ProductItem) {
Column() {
// 商品占位图:实际项目请替换为 Image($r('app.media.xxx'))
Stack() {
Text(item.icon)
.fontSize(36)
}
.width('100%')
.height(90)
.borderRadius(12)
.backgroundColor(item.color)
// 商品名称
Text(item.name)
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
.margin({ top: 8 })
// 商品价格
Text(item.price)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#E64340')
.margin({ top: 4 })
}
.width('100%')
.padding(8)
.borderRadius(12)
.backgroundColor('#FFFFFF')
}
几个视觉细节:
- Stack 居中 + 圆角 + 底色:模拟商品主图的容器,
borderRadius(12)让图片区圆润,backgroundColor取自数据模型的color字段,不同商品自动呈现不同颜色,便于肉眼区分网格边界; - 主标题用中等字重、价格用粗体红色:
#E64340是电商常见的"价格红",通过字号与字重的层级对比,让价格成为卡片的视觉焦点; - margin 逐级递增:图片区 → 名称 → 价格,间距 8/4 递进,符合"图大文小"的卡片节奏。
5.5 第四步:组装 Grid 网格
核心布局代码在这一步诞生。ForEach 遍历商品数组,为每个元素生成一个 GridItem,GridItem 内部调用 ProductCard 渲染卡片:
// Grid 核心容器
Grid() {
ForEach(this.products, (item: ProductItem) => {
GridItem() {
this.ProductCard(item)
}
}, (item: ProductItem) => item.name) // key 生成器:数据变更时精确定位复用单元
}
.columnsTemplate('1fr 1fr 1fr') // 核心:固定 3 列,均分宽度
.columnsGap(12) // 列间距
.rowsGap(12) // 行间距
.width('100%') // 网格宽度铺满容器
.height('auto') // 高度由内容自适应
.scrollBar(BarState.Off) // 隐藏滚动条
关于 ForEach 的第三个参数(key 生成器),值得多花两句话:当 products 数据增删时,框架靠 key 判断哪些 GridItem 可以复用、哪些需要重建。我们以 item.name 作为 key——商品名在示例中唯一,可以保证 diff 的准确性。如果列表内存在重名商品,应改用自增 id 字段作为 key,这是生产环境必须注意的细节。
当 height('auto') 时,Grid 高度等于所有行高度之和;当不设高度或设固定高度时,Grid 变成"视口内滚动"模式。这两种模式各有用途:嵌入长页面用 auto,独立全屏列表用固定高度。
六、实战二:图库网格——风格复用与间距控制
商品网格验证了 Grid 的基本用法,接下来用"图库"场景展示 Grid 的复用性:同样的 columnsTemplate('1fr 1fr 1fr'),换一套数据模型与卡片样式,就得到了风格完全不同的第二个网格。
6.1 图库数据模型
图库的数据比商品简单:一张图配一个名称即可。
// 图片数据模型:用于图库场景
class GalleryItem {
title: string; // 图片名称
color: string; // 占位色块颜色(实际项目中此处应为图片资源)
constructor(title: string, color: string) {
this.title = title;
this.color = color;
}
}
同样用色块模拟图片。图库的色板选取了更"照片感"的马卡龙色系(#FF8A65、#4FC3F7、#81C784……),与商品网格的高饱和色块形成视觉区隔,让两个场景一眼可分。
6.2 图库卡片与网格
图库卡片的 UI 结构更简洁——一张"照片"加一行标题:
@Builder
GalleryCard(item: GalleryItem) {
Column() {
// 图片占位:实际项目请替换为 Image($r('app.media.xxx'))
Column()
.width('100%')
.height(100)
.borderRadius(10)
.backgroundColor(item.color)
// 图片名称
Text(item.title)
.fontSize(13)
.fontColor('#666666')
.margin({ top: 6 })
}
.width('100%')
.padding(6)
.borderRadius(12)
.backgroundColor('#FFFFFF')
}
注意这里的一个细节:占位"图片"本身也是一个 Column,通过 height(100) 撑出方形区域。在真实项目中,这行会替换成:
Image($r('app.media.photo_01'))
.width('100%')
.height(100)
.borderRadius(10)
.objectFit(ImageFit.Cover) // 裁剪填充,保证缩略图不变形
ImageFit.Cover 是图库场景的标配——无论原图是横图还是竖图,都按"覆盖"方式裁剪填满单元,保持网格整齐。
图库网格的组装与商品网格如出一辙,只微调了间距参数,展示 columnsGap/rowsGap 的可调性:
Grid() {
ForEach(this.gallery, (item: GalleryItem) => {
GridItem() {
this.GalleryCard(item)
}
}, (item: GalleryItem) => item.title)
}
.columnsTemplate('1fr 1fr 1fr') // 同一固定列模板,风格统一
.columnsGap(10) // 图库间距更紧凑
.rowsGap(10)
.width('100%')
.height('auto')
.scrollBar(BarState.Off)
6.3 间距的艺术
两个网格分别使用了 12 与 10 的间距,这不是随意取值,而是遵循了一个基本规律:间距应随单元内容密度调整。商品卡片内容多(图 + 两行文字),需要稍大的呼吸感,用 12;图库单元只有图和一行字,用 10 让照片彼此贴近,更像相册。如果间距再小到 4~6,网格就会趋向"无缝隙拼接",通常用于水印墙、图标墙等场景。间距是网格观感的"最后一公里",值得反复微调。
七、交互增强:点击卡片弹出 Toast
一个只展示的网格是不完整的,加上交互才更像真实应用。我们给商品卡片加上点击反馈:点击后弹出 Toast 提示选中了哪件商品。在 ArkTS 中,Toast 通过 promptAction 模块提供:
import { promptAction } from '@kit.ArkUI';
在卡片 Column 上挂 onClick 事件:
// 商品卡片(截取自 ProductCard @Builder)
Column() {
// ...卡片内容
}
.width('100%')
.padding(8)
.borderRadius(12)
.backgroundColor('#FFFFFF')
.onClick(() => {
promptAction.showToast({
message: `选中商品:${item.name}`,
duration: 1500
});
})
要点解释:
onClick是通用点击事件,挂在整个卡片容器上,点击卡片任意位置都会触发——比分别给内部元素挂事件更省事,也避免了点击空白处无响应的问题;promptAction.showToast接受一个对象参数,message为提示文本,duration为显示时长(毫秒,默认 1500 即可,范围为 1500~10000);- 模板字符串
`选中商品:${item.name}`是 ArkTS 中字符串插值的标准写法,注意这里用的是反引号。
图库卡片同样可以挂点击事件(示例中为保持简洁未挂,读者可自行添加),例如点击后跳转到图片详情页——这正是相册类应用的真实交互路径。关于页面跳转,鸿蒙提供了 router 与 Navigation 两种方式,篇幅所限,本文不展开。
八、完整代码清单
将前述所有片段整合,得到完整的 GridFixedColumns.ets 文件。整个文件可以直接复制到 entry/src/main/ets/pages/ 目录下使用,注释中已标注所有布局要点:
/**
* GridFixedColumns.ets
* 鸿蒙原生 ArkTS 布局方式:Grid 固定列数网格(Fixed Columns Grid)
*
* 应用场景:商品网格、图库等分列展示
* 核心技术:Grid + columnsTemplate('1fr 1fr 1fr')
*
* 布局要点:
* 1. Grid 是鸿蒙提供的高性能网格布局容器,可横向 / 纵向滚动;
* 2. columnsTemplate 指定列的模板,'1fr 1fr 1fr' 表示平均分成 3 列(fr 为比例单位);
* 3. 列数固定后,数据会按「先从左到右、再从上到下」的顺序填充每一行;
* 4. GridItem 是 Grid 的唯一直接子组件,每个 GridItem 占据一个网格单元;
* 5. 配合 columnsGap / rowsGap 控制行列间距,使网格规整美观。
*/
import { promptAction } from '@kit.ArkUI';
// ---------- 数据模型 ----------
// 商品数据模型:用于商品网格场景
class ProductItem {
name: string; // 商品名称
price: string; // 商品价格(字符串便于展示 ¥)
color: string; // 占位色块颜色(实际项目中此处应为图片资源)
icon: string; // 占位图标(用 emoji 模拟商品图,实际项目可用 Image + $r 图片资源)
constructor(name: string, price: string, color: string, icon: string) {
this.name = name;
this.price = price;
this.color = color;
this.icon = icon;
}
}
// 图片数据模型:用于图库场景
class GalleryItem {
title: string; // 图片名称
color: string; // 占位色块颜色(实际项目中此处应为图片资源)
constructor(title: string, color: string) {
this.title = title;
this.color = color;
}
}
@Entry
@Component
struct GridFixedColumns {
// ---------- 页面状态数据 ----------
// 商品列表:共 9 件商品,3 列 × 3 行恰好铺满
@State products: ProductItem[] = [
new ProductItem('无线耳机', '¥299', '#4A90D9', '🎧'),
new ProductItem('智能手表', '¥1299', '#50B5A9', '⌚'),
new ProductItem('蓝牙音箱', '¥199', '#F5A623', '🔊'),
new ProductItem('机械键盘', '¥459', '#7B6FD0', '⌨️'),
new ProductItem('电竞鼠标', '¥159', '#E8634A', '🖱️'),
new ProductItem('4K显示器', '¥2499', '#3BB0DB', '🖥️'),
new ProductItem('充电宝', '¥89', '#8B9D5A', '🔋'),
new ProductItem('手机支架', '¥39', '#C77B8E', '📱'),
new ProductItem('降噪耳麦', '¥599', '#5B8D6E', '🎙️')
];
// ---------- 商品卡片(@Builder 复用) ----------
// @Builder 用于抽取可复用的 UI 片段,传入一个商品对象即可生成一张卡片
@Builder
ProductCard(item: ProductItem) {
Column() {
// 商品占位图:实际项目请替换为 Image($r('app.media.xxx'))
// 这里用圆角色块 + emoji 模拟商品图,保证示例无需额外图片资源即可运行
Stack() {
Text(item.icon)
.fontSize(36)
}
.width('100%')
.height(90)
.borderRadius(12)
.backgroundColor(item.color)
// 商品名称
Text(item.name)
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
.margin({ top: 8 })
// 商品价格
Text(item.price)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#E64340')
.margin({ top: 4 })
}
.width('100%')
.padding(8)
.borderRadius(12)
.backgroundColor('#FFFFFF')
// 点击卡片时弹出提示,直观验证每个网格单元都可交互
.onClick(() => {
promptAction.showToast({
message: `选中商品:${item.name}`,
duration: 1500
});
})
}
// ---------- 图库卡片 ----------
@Builder
GalleryCard(item: GalleryItem) {
Column() {
// 图片占位:实际项目请替换为 Image($r('app.media.xxx'))
Column()
.width('100%')
.height(100)
.borderRadius(10)
.backgroundColor(item.color)
// 图片名称
Text(item.title)
.fontSize(13)
.fontColor('#666666')
.margin({ top: 6 })
}
.width('100%')
.padding(6)
.borderRadius(12)
.backgroundColor('#FFFFFF')
}
// ---------- 页面标题栏 ----------
@Builder
PageTitle(title: string) {
Text(title)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#1A1A1A')
.width('100%')
.margin({ top: 12, bottom: 10 })
}
// ---------- 页面主体 ----------
build() {
// 外层使用 Scroll 包裹,让页面整体(标题 + 商品网格 + 图库网格)可上下滚动
Scroll() {
Column({ space: 8 }) {
// 页面大标题
Text('固定列数网格 Grid 示例')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.width('100%')
.margin({ top: 16 })
// 布局说明文字
Text('columnsTemplate(\'1fr 1fr 1fr\'):固定 3 列,按行依次排列')
.fontSize(13)
.fontColor('#999999')
.width('100%')
.margin({ bottom: 4 })
// ============ 场景一:商品网格(3 列固定) ============
this.PageTitle('🛒 商品网格')
// Grid 核心容器:
// - columnsTemplate 固定列数('1fr 1fr 1fr' = 均分 3 列)
// - 不设置高度时按内容自适应,数据超过一屏后 Grid 内部可滚动
Grid() {
// ForEach 遍历商品数据,每个元素生成一个 GridItem(网格单元)
ForEach(this.products, (item: ProductItem) => {
GridItem() {
// 网格单元内放置商品卡片
this.ProductCard(item)
}
}, (item: ProductItem) => item.name) // key 生成器:保证数据变更时能精确定位
}
// ====== 核心技术点:固定 3 列的列模板 ======
// '1fr 1fr 1fr':三个等宽列,比例均为 1
// 若改为 '1fr 2fr 1fr' 则中间列宽度为两侧的 2 倍
.columnsTemplate('1fr 1fr 1fr')
// 列间距 12vp
.columnsGap(12)
// 行间距 12vp
.rowsGap(12)
// 网格宽度铺满容器
.width('100%')
// 不设置固定高度,高度由内容自适应
.height('auto')
// 隐藏滚动条(内容超出时仍可上下滚动)
.scrollBar(BarState.Off)
// ============ 场景二:图库网格(同样 3 列固定) ============
this.PageTitle('🖼️ 图库')
Grid() {
ForEach(this.gallery, (item: GalleryItem) => {
GridItem() {
this.GalleryCard(item)
}
}, (item: GalleryItem) => item.title)
}
// 同一网格使用相同的固定列模板,保持页面风格统一
.columnsTemplate('1fr 1fr 1fr')
.columnsGap(10)
.rowsGap(10)
.width('100%')
.height('auto')
.scrollBar(BarState.Off)
// 底部留白,避免内容贴边
Blank()
.height(24)
}
.width('100%')
.padding({ left: 12, right: 12 })
}
.width('100%')
.height('100%')
// 页面浅灰背景,衬托白色卡片
.backgroundColor('#F2F3F5')
// 滚动条样式:细滚动条,不遮挡内容
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
}
整份代码的结构可以概括为一句话:两个 @Builder 卡片 + 一个 build 方法 + 两个 ForEach 网格。理解了这句话,你就掌握了 Grid 固定列数网格的全部骨架。下一节介绍如何让这个页面真正跑起来。
九、运行效果与页面注册
9.1 注册页面路由
在 Stage 模型中,@Entry 页面必须先在路由配置中注册才能被加载。路由配置文件位于 entry/src/main/resources/base/profile/main_pages.json:
{
"src": [
"pages/Index",
"pages/GridFixedColumns"
]
}
src 数组中的每一项对应一个页面,第一项是应用启动后默认加载的首页。把 pages/GridFixedColumns 追加进数组后,页面就完成了注册。如果希望应用一启动就展示网格示例,把 "pages/GridFixedColumns" 移到数组第一位即可:
{
"src": [
"pages/GridFixedColumns",
"pages/Index"
]
}
9.2 运行与预览
页面注册完成后,有两种方式查看效果:
- Previewer 预览:在 DevEco Studio 中打开
GridFixedColumns.ets,点击右上角的 Previewer 按钮,即可在不安装真机的情况下实时预览布局效果。Previewer 支持多尺寸设备模拟,可以直观看到 3 列网格在不同屏幕宽度下的表现; - 真机 / 模拟器运行:连接 HarmonyOS NEXT 设备或启动模拟器,点击 Run 按钮编译安装。真机上可以进一步验证滚动体验、点击 Toast 等交互效果。
9.3 预期效果
运行后,页面自上而下依次呈现:
- 大标题"固定列数网格 Grid 示例"与灰色说明文字;
- "🛒 商品网格"小节:9 张白色卡片排成 3 列 × 3 行,每张卡片包含彩色图标区、商品名与红色价格;
- "🖼️ 图库"小节:9 张马卡龙色"照片"同样 3 列排列,间距更紧凑;
- 页面整体可上下滚动,滑动到底部留有 24vp 空白;点击任意商品卡片,屏幕底部弹出"选中商品:xxx"的 Toast 提示。
3 列布局之所以"一眼可见",正是 columnsTemplate('1fr 1fr 1fr') 的功劳——三列等宽、列间距恒定,无论屏幕多宽,网格都保持规整。
十、进阶话题:性能、滚动与适配建议
示例可以运行只是第一步。当网格进入真实项目,还会遇到性能、滚动、适配三类问题,本节给出针对性建议。
10.1 性能:从 ForEach 到 LazyForEach
示例中的 9 件商品用 ForEach 毫无压力,但商品列表动辄成百上千条,此时必须切换到 LazyForEach(懒加载)。LazyForEach 只在 GridItem 进入可视区域时才创建对应组件,离开可视区域即销毁,内存占用与首屏渲染时间都大幅下降,是长列表场景的标准姿势:
// 伪代码:LazyForEach 需要数据源实现 IDataSource 接口
LazyForEach(this.productsDataSource, (item: ProductItem) => {
GridItem() {
this.ProductCard(item)
}
}, (item: ProductItem) => item.name)
LazyForEach 的使用需要额外定义一个实现 IDataSource 接口的数据源类(提供 totalCount、getData、registerDataChangeListener 等方法),代码量比 ForEach 多,但换来的是接近零的滚动卡顿。规则很简单:数据量小于几十条用 ForEach,可能上百上千条一律用 LazyForEach。
10.2 滚动:边缘效果与滚动条
网格滚动体验由两个属性控制:
edgeEffect(EdgeEffect.Spring):设置边缘回弹效果。Spring是弹性回弹(iOS 风格),None是干脆停止,Fade是渐隐。商品流页面用Spring会让滑动更有"手感";scrollBar(BarState.Off):隐藏滚动条。网格类页面通常隐藏滚动条,靠滑动惯性提示用户"还有内容"。若需要展示,BarState.Auto是"滑动时出现、静止后消失"的折中方案。
Grid() { /* ... */ }
.columnsTemplate('1fr 1fr 1fr')
.edgeEffect(EdgeEffect.Spring) // 弹性回弹
.scrollBar(BarState.Off) // 隐藏滚动条
10.3 适配:让列数随屏幕宽度变化
1fr 1fr 1fr 是"固定列数",无论手机还是折叠屏都是 3 列——这是本示例的主题,但真实项目往往需要"列数自适应":手机 2 列、平板 3~4 列。做法是在 columnsTemplate 中动态拼接模板字符串:
// 根据屏幕宽度计算列数:宽屏更多列
const columns = this.screenWidth > 600 ? '1fr 1fr 1fr 1fr' : '1fr 1fr 1fr';
Grid() { /* ... */ }
.columnsTemplate(columns)
更进阶的方案是使用 gridTemplateOptions 配合 GridRow/GridColumn 栅格组件做响应式布局。固定列数的 Grid 与自适应栅格并非互斥:列数要求固定时用 Grid,布局需要按断点响应时用 GridRow,两者可以按需组合。此外,还有一种"列宽固定、列数自适应"的写法:
Grid() { /* ... */ }
.columnsTemplate('repeat(auto-fill, 100vp)') // 每列至少 100vp,能放几列算几列
repeat(auto-fill, 100vp) 让列宽固定为 100vp,列数随容器宽度自动增减,是"固定列数"之外最常用的变体,适合图标墙、标签墙等场景。
10.4 常见坑位排查
最后汇总 Grid 开发中四个高频踩坑点,供读者自查:
- 直接子组件不是 GridItem:Grid 内出现非 GridItem 的直接子节点会导致布局异常,务必保证"ForEach → GridItem"的结构;
- columnsTemplate 拼写错误:模板字符串必须是空格分隔的合法值,
'1fr,1fr,1fr'(逗号分隔)或'1fr 1fr'(末尾多空格)都可能引发解析异常; - key 生成器返回重复值:
ForEach的 key 不唯一会造成复用错乱,商品数据务必保证 key 全局唯一; - Grid 高度模式混淆:
height('auto')时 Grid 在 Scroll 内展开全部内容,固定高度时 Grid 内部滚动——两种模式切换时记得同步调整外层结构,避免出现"双重滚动条"。
十一、总结
本文以"商品网格 + 图库"两个真实场景为载体,系统讲解了鸿蒙 ArkTS 中 Grid 固定列数网格的完整用法。回顾全文,核心知识点可以浓缩为一张速查表:
| 知识点 | 关键内容 | 示例代码 |
|---|---|---|
| 组件选择 | 分列展示场景优先用 Grid | Grid() { GridItem() { ... } } |
| 固定列数 | columnsTemplate 声明列模板 | .columnsTemplate('1fr 1fr 1fr') |
| 比例单位 | fr = 分数,按比例分配宽度 | '1fr 2fr 1fr' 中间列宽 2 倍 |
| 单元填充 | 直接子组件只能是 GridItem | ForEach → GridItem → 卡片 |
| 间距控制 | columnsGap / rowsGap | .columnsGap(12).rowsGap(12) |
| 高度模式 | auto 展开 / 固定高度滚动 | .height('auto') |
| 滚动体验 | 边缘回弹 + 隐藏滚动条 | .edgeEffect(EdgeEffect.Spring).scrollBar(BarState.Off) |
| 性能进阶 | 大数据量用 LazyForEach | LazyForEach(dataSource, ...) |
| 适配变体 | 列宽固定、列数自适应 | .columnsTemplate('repeat(auto-fill, 100vp)') |
本文的代码骨架(一图流)
@Entry
@Component
struct GridFixedColumns {
@State products: ProductItem[] = [ /* 数据 */ ];
build() {
Scroll() {
Column() {
Grid() {
ForEach(this.products, (item) => {
GridItem() { this.ProductCard(item) }
}, (item) => item.name)
}
.columnsTemplate('1fr 1fr 1fr') // ← 固定 3 列
.columnsGap(12).rowsGap(12)
}
}
}
}
写在最后
网格是移动端 UI 中最经典、最高频的布局形态之一,而鸿蒙的 Grid 组件把它做到了"声明即所得"的程度:一行 columnsTemplate 定义列结构,一个 ForEach 循环生成单元,剩下的对齐、换行、滚动、复用全部交给框架。掌握了 Grid 固定列数网格,你就掌握了电商首页、相册、九宫格、图标墙等一大批页面的通用解法。
更进一步,Grid 的能力远不止"固定列数":它支持行/列合并(跨行跨列的卡片墙)、支持 rowsTemplate 与 columnsTemplate 组合成非均匀网格、支持 LazyForEach 支撑万级数据流、还能与 GridRow/GridColumn 栅格系统配合实现多端自适应。本文的示例应用是一个可以随时扩展的起点——试着把商品换成真实的 Image 图片、把 3 列改成 repeat(auto-fill, 100vp)、再给图库加上点击跳转详情页,一个完整的网格类应用就诞生了。
更多推荐




所有评论(0)