鸿蒙 ArkTS 布局实战:Grid 固定列数网格——商品网格与图库的分列展示之道

本文基于 HarmonyOS NEXT 6.1.1(API 24)与 DevEco Studio 开发环境,以一个可直接运行的 ArkTS 示例应用为载体,深入讲解鸿蒙原生布局体系中"Grid 固定列数网格"的完整用法:从核心概念、属性语法到商品网格与图库两大真实场景的落地实现,并给出全部可运行代码与中文注释。

目录

  1. 引言:当商品遇上网格
  2. 环境准备:DevEco Studio 与工程创建
  3. 布局选型:为什么"分列展示"要用 Grid
  4. Grid 核心概念:columnsTemplate、fr 单位与 GridItem
  5. 实战一:商品网格——从数据模型到卡片渲染
  6. 实战二:图库网格——风格复用与间距控制
  7. 交互增强:点击卡片弹出 Toast
  8. 完整代码清单
  9. 运行效果与页面注册
  10. 进阶话题:性能、滚动与适配建议
  11. 总结

一、引言:当商品遇上网格

打开任何一个电商 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,其中 compatibleSdkVersiontargetSdkVersion 应配置为与 HarmonyOS NEXT 6.1.1(API 24)相匹配的版本号。这一步的意义在于:不同的 API 版本决定了 ArkUI 组件的可用属性集合,本文用到的 columnsTemplatecolumnsGaprowsGap 等 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 作为讲解对象的原因。

补充说明:GridList 一样支持 ForEachLazyForEach,数据量极大时(比如上千件商品)可以无缝切换到懒加载模式,这一话题我们会在第十章展开。


四、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 遍历商品数组,为每个元素生成一个 GridItemGridItem 内部调用 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 间距的艺术

两个网格分别使用了 1210 的间距,这不是随意取值,而是遵循了一个基本规律:间距应随单元内容密度调整。商品卡片内容多(图 + 两行文字),需要稍大的呼吸感,用 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 中字符串插值的标准写法,注意这里用的是反引号。

图库卡片同样可以挂点击事件(示例中为保持简洁未挂,读者可自行添加),例如点击后跳转到图片详情页——这正是相册类应用的真实交互路径。关于页面跳转,鸿蒙提供了 routerNavigation 两种方式,篇幅所限,本文不展开。


八、完整代码清单

将前述所有片段整合,得到完整的 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 预期效果

运行后,页面自上而下依次呈现:

  1. 大标题"固定列数网格 Grid 示例"与灰色说明文字;
  2. "🛒 商品网格"小节:9 张白色卡片排成 3 列 × 3 行,每张卡片包含彩色图标区、商品名与红色价格;
  3. "🖼️ 图库"小节:9 张马卡龙色"照片"同样 3 列排列,间距更紧凑;
  4. 页面整体可上下滚动,滑动到底部留有 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 接口的数据源类(提供 totalCountgetDataregisterDataChangeListener 等方法),代码量比 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 开发中四个高频踩坑点,供读者自查:

  1. 直接子组件不是 GridItem:Grid 内出现非 GridItem 的直接子节点会导致布局异常,务必保证"ForEach → GridItem"的结构;
  2. columnsTemplate 拼写错误:模板字符串必须是空格分隔的合法值,'1fr,1fr,1fr'(逗号分隔)或 '1fr 1fr'(末尾多空格)都可能引发解析异常;
  3. key 生成器返回重复值ForEach 的 key 不唯一会造成复用错乱,商品数据务必保证 key 全局唯一;
  4. 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 的能力远不止"固定列数":它支持行/列合并(跨行跨列的卡片墙)、支持 rowsTemplatecolumnsTemplate 组合成非均匀网格、支持 LazyForEach 支撑万级数据流、还能与 GridRow/GridColumn 栅格系统配合实现多端自适应。本文的示例应用是一个可以随时扩展的起点——试着把商品换成真实的 Image 图片、把 3 列改成 repeat(auto-fill, 100vp)、再给图库加上点击跳转详情页,一个完整的网格类应用就诞生了。

Logo

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

更多推荐