【共创季稿事节】鸿蒙原生 ArkTS 布局深度解析:Grid 与 Scroll 联动实现可滚动网格

鸿蒙原生 ArkTS 布局深度解析:Grid 与 Scroll 联动实现可滚动网格


一、前言

1.1 鸿蒙生态与 ArkTS 布局体系

自 HarmonyOS NEXT 发布以来,华为彻底剥离了 Android AOSP 代码,构建了一套完全自研的操作系统底座。在这套全新的生态体系中,ArkTS(Ark TypeScript)作为首选的声明式 UI 开发语言,承担着构建鸿蒙原生应用界面的重任。ArkTS 基于 TypeScript 语法作了针对性增强,引入了一套以 @Component@Entry 装饰器为核心的组件化开发范式,配合丰富的内置容器组件(如 StackColumnRowGridScrollListSwiperTabs 等),使得开发者能够高效构建出复杂且流畅的用户界面。

对于从 Android(XML + RecyclerView)或 iOS(UIKit + UICollectionView)转过来的开发者来说,ArkTS 的布局方式在理念上更接近 Flutter 和 SwiftUI------它强调通过代码直接描述界面结构,而非通过外部 XML 文件;它推崇组合优于继承,通过小部件的嵌套与组合来表达复杂的 UI 层次。这套体系带来的直接好处是:代码即界面、逻辑与视图高度内聚、运行时性能优秀。ArkTS 还内置了响应式数据绑定机制,开发者只需使用 @State@Prop@Link 等装饰器标记状态变量,框架就会自动追踪数据变化并增量更新 UI,无需手动调用 setState 或 notifyDataSetChanged 之类的方法。

HarmonyOS NEXT 对 ArkTS 的性能优化也值得一提。它采用了一套自研的声明式 UI 渲染引擎,在布局阶段使用基于约束的线性求解算法替代了传统的多次 measure/layout 过程,大幅减少了布局计算的时间复杂度。配合方舟编译器的 AOT(Ahead-of-Time)编译能力,ArkTS 代码在安装时就被编译为机器码,运行时无需解释执行,启动速度和帧率稳定性均达到原生级别。这意味着即使是包含数百个 GridItem 的复杂网格页面,在鸿蒙设备上也能保持 120 fps 的丝滑滚动体验。

1.2 为什么需要可滚动网格

在移动应用开发中,网格布局是最常见、最实用的布局模式之一。从手机桌面图标排列到电商商品展示,从相册缩略图浏览到应用管理中心,网格布局无处不在。然而,移动设备的屏幕尺寸是有限的,当网格中的数据项超过一屏能容纳的数量时,滚动能力就成为刚需。

传统的实现方式通常是在 ScrollView(或等效容器)中嵌套一个多列 Flex 布局,由开发者手动管理每行放置多少个元素、处理换行逻辑、计算行高等细节。这种方式不仅代码冗长,而且在数据动态变化时容易出 bug。鸿蒙 ArkTS 提供了一种更优雅的解决方案------Grid + Scroll 联动 ,即用 Grid 组件专职负责行列布局,用 Scroll 组件专职负责滚动交互,二者各司其职、协作无间。

本文将以一个完整的示例应用为切入点,深度剖析 Grid 与 Scroll 联动的实现原理、编码技巧和生产最佳实践,帮助开发者彻底掌握这一布局模式。


二、项目初始化与环境准备

2.1 创建 HarmonyOS NEXT 项目

在 DevEco Studio 中创建一个新的 HarmonyOS 工程,选择 Empty Ability 模板,API 版本选择 API 24(HarmonyOS NEXT) 。项目创建完成后,默认会生成一个 pages/Index.ets 入口页面。我们将以此为基础,逐步构建完整的可滚动网格演示应用。

2.2 项目结构概览

复制代码
entry/src/main/ets/
├── pages/
│   ├── Index.ets              # 应用首页(入口 + 导航按钮)
│   └── GridScrollDemo.ets      # 核心演示页面(Grid + Scroll 联动)
entry/src/main/resources/
└── base/
    └── profile/
        └── main_pages.json     # 路由注册文件

其中 main_pages.json 是路由表,需要将新增的 GridScrollDemo 页面注册进去,以便通过 router.pushUrl 实现页面跳转:

json 复制代码
{
  "src": [
    "pages/Index",
    "pages/GridScrollDemo"
  ]
}

2.3 首页导航实现

首页 Index.ets 的代码非常简洁,仅包含一个标题和一个跳转按钮。核心逻辑是通过 router.pushUrl 导航到演示页面,并使用 @Entry@Component 装饰器标记页面入口:

typescript 复制代码
import { router } from '@kit.ArkUI';

@Entry
@Component
struct Index {
  build() {
    Stack({ alignContent: Alignment.Center }) {
      Column({ space: 20 }) {
        Text('鸿蒙 ArkTS 布局示例')
          .fontSize(24).fontWeight(FontWeight.Bold).fontColor('#1E293B')

        Text('Grid + Scroll 联动:可滚动网格')
          .fontSize(14).fontColor('#64748B')

        Button('进入演示')
          .type(ButtonType.Capsule).width(200).height(48)
          .backgroundColor('#3B82F6').fontSize(16).fontWeight(FontWeight.Medium)
          .onClick(() => {
            router.pushUrl({ url: 'pages/GridScrollDemo' });
          })
      }
    }.width('100%').height('100%').backgroundColor('#F8FAFC')
  }
}

这段代码展示了典型的 ArkTS 声明式写法:组件通过链式调用设置属性,闭包处理事件,整个 UI 结构一目了然。Stack 居中对齐配合 Column 垂直排列,用最少的嵌套实现了优雅的导航界面。


三、核心布局原理:Grid 与 Scroll 如何联动

3.1 布局架构总览

在进入代码细节之前,我们需要从宏观上理解 Grid 与 Scroll 的协作关系。下面这张架构图清晰地展示了二者在布局中的角色:

复制代码
┌──────────────────────────────────────┐
│  Stack (全屏背景 #F1F5F9)            │
│  ┌────────────────────────────────┐  │
│  │  Column (垂直方向)              │  │
│  │  ┌──────────────────────────┐  │  │
│  │  │  buildHeader()            │  │  │
│  │  │  "Grid + Scroll 联动演示"  │  │  │
│  │  └──────────────────────────┘  │  │
│  │  ┌──────────────────────────┐  │  │
│  │  │  Scroll (layoutWeight=1)  │  │  │ ← 占满剩余高度
│  │  │  ┌────────────────────┐  │  │  │
│  │  │  │  Grid (3列,1fr)   │  │  │  │ ← 24个正方形卡片
│  │  │  │  ┌──┐ ┌──┐ ┌──┐   │  │  │  │
│  │  │  │  │📁│ │🤖│ │🌤│   │  │  │  │
│  │  │  │  └──┘ └──┘ └──┘   │  │  │  │
│  │  │  │  ┌──┐ ┌──┐ ┌──┐   │  │  │  │
│  │  │  │  │📅│ │📝│ │🎵│   │  │  │  │
│  │  │  │  └──┘ └──┘ └──┘   │  │  │  │
│  │  │  │       ...          │  │  │  │
│  │  │  │  整体往下滚动 →    │  │  │  │
│  │  │  └────────────────────┘  │  │  │
│  │  └──────────────────────────┘  │  │
│  └────────────────────────────────┘  │
└──────────────────────────────────────┘

3.2 组件职责划分

Scroll 组件 ------ 滚动容器

Scroll 是 ArkTS 中专门用于提供滚动能力的容器组件。它的核心职责是:当其子组件的内容尺寸超过自身视口尺寸时,自动启用滚动交互。在本示例中,Scroll 被配置为仅垂直方向滚动,并开启了弹簧回弹效果。

关键属性解读:

  • scrollable(ScrollDirection.Vertical) :限定滚动方向为垂直。ScrollDirection 枚举还包含 Horizontal(水平滚动)和 Free(自由滚动),但不建议在 Grid 场景中使用 Free,因为二维滚动会带来复杂的交互冲突。
  • edgeEffect(EdgeEffect.Spring) :当用户滚动到内容边界时,产生弹性拉伸回弹效果,提升交互手感。EdgeEffect.None 则表现为硬边界(到顶即停)。
  • enableScrollInteraction(true):显式启用手势滚动交互。此属性默认为 true,但明确写出有助于阅读者理解意图。
  • scrollBar(BarState.Auto) :滚动条根据滚动状态自动显示或隐藏,Android 开发者可以类比 ScrollView.setScrollBarStyle
  • layoutWeight(1) :这是整个布局中最关键的一环。layoutWeight 使 Scroll 容器占据父容器(Column)标题栏之后的所有剩余空间。如果没有这一设置,Scroll 的高度可能由内容撑起,从而失去滚动的前提条件------因为只有容器高度固定且小于内容高度时,滚动才有意义。
Grid 组件 ------ 网格布局

Grid 是 ArkTS 中用于排列子项到行和列中的布局组件。在本示例中,Grid 被配置为一个三列的网格,子项从左到右、从上到下逐行排列。

关键属性解读:

  • columnsTemplate('repeat(3, 1fr)') :定义网格的列模板。1fr 是 CSS Grid 开发者非常熟悉的弹性单位,表示按比例分配可用空间。repeat(3, 1fr) 即生成三个等宽的列。如果希望实现响应式列数,可以改用 repeat(auto-fill, minmax(100px, 1fr)),Grid 会根据容器宽度自动计算列数。
  • columnsGap(12)rowsGap(12) :分别控制列间距和行间距,单位为 vp(虚拟像素)。这两个属性分别独立设置,不像 CSS 的 gap 简写属性。
  • Grid 高度不设:Grid 的高度由其子项总行数撑起,而非固定值。这样当 Grid 的总高度超过 Scroll 的视口高度时,Scroll 才能感知到并启用滚动。
协作机制

Scroll 与 Grid 的协作可以概括为一句话:Grid 负责"多高",Scroll 负责"能滚"

具体来说,Grid 作为一个非滚动容器 ,其高度完全由内容决定------24 个卡片排成 8 行(3 列 × 8 行),每行高度约等于卡片的 aspectRatio(1.0) 算出的高度加上 rowsGap,总高度大约在 2000 vp 左右。Scroll 通过 layoutWeight(1) 被约束为剩余空间的高度,大约为 700 vp。当 2000 > 700 时,Scroll 组件自动进入滚动模式,用户可以通过手指滑动逐行浏览所有网格项。

这种方案相比传统的 Scroll + Flex 手动管理换行的做法,最大的优势在于:Grid 内部自动处理了子项的排列、换行、间距和对齐,开发者无需编写任何与换行逻辑相关的代码。


四、数据模型与状态管理

4.1 定义 GridItemModel 接口

好的布局从好的数据模型开始。我们定义一个 GridItemModel 接口,用于描述网格中每个卡片的数据结构:

typescript 复制代码
interface GridItemModel {
  id: number;           // 唯一标识,用于 ForEach 的 key 生成
  title: string;        // 主标题(中文)
  subtitle: string;     // 副标题(英文)
  color: ResourceColor; // 装饰条颜色,兼容 Color 枚举和十六进制字符串
  icon: string;         // 图标(使用 Emoji 模拟)
}

这里特别说明 color 字段的类型:在早期的 ArkTS 版本中,颜色属性通常仅接受 Color 枚举值(如 Color.BlueColor.Red)。但在 API 24 中,鸿蒙引入了 ResourceColor 联合类型,它同时接受 Color 枚举、十六进制颜色字符串(如 '#6366F1')以及 Resource 资源引用(如 $r('app.color.primary'))。这使得颜色表达更加灵活,开发者可以根据场景自由选择最便捷的方式。

4.2 @State 装饰器与数据驱动

在 ArkTS 中,@State 装饰器用于声明组件的内部状态。当被 @State 标记的变量发生变化时,ArkTS 框架会自动触发 UI 重新渲染,这就是数据驱动 UI 的核心机制。

typescript 复制代码
@State gridData: GridItemModel[] = [
  // 24 条数据,远超一屏可展示数量
  { id: 1, title: '云存储', subtitle: 'Cloud Storage', color: Color.Blue, icon: '📁' },
  { id: 2, title: '智能助手', subtitle: 'AI Assistant', color: Color.Green, icon: '🤖' },
  // ...
  { id: 24, title: '文件管理', subtitle: 'Files', color: '#F43F5E', icon: '📂' },
];

@State 在这里有两个层面的作用:

  1. 初始化渲染 :页面加载时,gridData 作为数据源驱动 ForEach 循环,生成 24 个 GridItem。
  2. 响应式更新 :如果后续通过异步数据加载、用户操作或定时器修改了 gridData 的内容(例如增删某项、修改标题),UI 将自动更新匹配的数据变化。这是声明式 UI 最核心的编程范式------开发者只需关注数据的变化,无需手动操作 DOM 或 Component 实例。

我们在数据源中准备了四组不同色系的卡片数据,每组 6 个,总计 24 个项。这一数量经过精心设计:在常见的 6.7 英寸手机屏幕上,3 列网格一屏大约可展示 3 行(9 项),24 项需要滚动 2~3 屏才能完全展示,足以清晰验证 Scroll 的滚动效果。

4.3 配置常量提取

为了提高代码的可维护性,我们将网格的关键布局参数提取为类的私有只读属性:

typescript 复制代码
private readonly columnsCount: number = 3;   // 网格列数
private readonly rowGap: number = 12;         // 行间距(vp)
private readonly columnGap: number = 12;      // 列间距(vp)
private readonly gridPadding: number = 16;    // 网格内边距(vp)

这种做法的好处:

  • 单一数据来源:列数、间距等参数集中在类顶部定义,修改时无需在整个文件中搜索散落的魔法数字。
  • 代码可读性:属性名自解释其用途,配合中文注释,即使不熟悉上下文的开发者也能快速理解。
  • 便于扩展 :如果将来需要支持动态调整列数或间距(例如响应横竖屏),只需将 columnsCount 也从 private readonly 改为 @State 即可。

五、核心代码逐层解析

5.1 页面根布局:Stack + Column

typescript 复制代码
build() {
  Stack({ alignContent: Alignment.Top }) {
    Column() {
      this.buildHeader()
      this.buildScrollableGrid()
    }
    .width('100%')
    .height('100%')
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#F1F5F9')
}

最外层使用 Stack 容器,其作用是为整个页面设置统一的背景色(#F1F5F9,一种柔和的浅灰蓝色)。StackalignContent 设置为 Alignment.Top,确保子组件从顶部开始排列。

内部嵌套一个 Column 容器,用于组织标题栏滚动网格 两个垂直区域。Column 的高度设为 100%,与 Stack 等高------这为后续 Scroll 的 layoutWeight(1) 提供了父容器的高度约束基础。

5.2 标题栏构建:buildHeader

typescript 复制代码
@Builder
buildHeader() {
  Row() {
    Text('Grid + Scroll 联动演示')
      .fontSize(20)
      .fontWeight(FontWeight.Bold)
      .fontColor('#1E293B')
    Blank()
    Text(`${this.gridData.length} 项 · ${this.columnsCount} 列`)
      .fontSize(13)
      .fontColor('#94A3B8')
      .backgroundColor('#FFFFFF')
      .borderRadius(12)
      .padding({ left: 10, right: 10, top: 4, bottom: 4 })
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 12, bottom: 8 })
  .backgroundColor('#FFFFFF')
}

@Builder 装饰器是 ArkTS 中复用 UI 代码块的利器。它类似于 SwiftUI 中的 @ViewBuilder,允许开发者将一段 UI 片段封装为可复用的构建函数。

标题栏采用 Row 水平布局,左端放置主标题,右端通过 Blank() 弹性撑开,在右侧放置一个提示标签显示数据规模。Blank() 组件是 ArkTS 中实现弹性占位的核心工具------它会自动填充 Row 中的剩余空间,将其两侧的内容推向两端。

右侧的提示标签通过 borderRadius(12)backgroundColor('#FFFFFF') 营造出类似 Material Design 中 Chip(纸片)的视觉效果,增加了界面的精致感。

5.3 核心:可滚动网格 buildScrollableGrid

这是整个页面的灵魂所在,也是 Grid + Scroll 联动的代码实现:

typescript 复制代码
@Builder
buildScrollableGrid() {
  Scroll() {
    Grid() {
      ForEach(this.gridData, (item: GridItemModel) => {
        this.buildGridItem(item)
      }, (item: GridItemModel): string => item.id.toString())
    }
    .columnsTemplate(`repeat(${this.columnsCount}, 1fr)`)
    .columnsGap(this.columnGap)
    .rowsGap(this.rowGap)
    .width('100%')
    .padding({ left: 16, right: 16, top: 4, bottom: 16 })
  }
  .scrollable(ScrollDirection.Vertical)
  .edgeEffect(EdgeEffect.Spring)
  .enableScrollInteraction(true)
  .scrollBar(BarState.Auto)
  .width('100%')
  .layoutWeight(1)
}

这段代码的逻辑链条如下:

  1. Scroll 容器 :作为最外层,它的高度由 layoutWeight(1) 决定------即占满 Column 中标题栏之后的所有剩余空间。这是一个固定高度。
  2. Grid 子容器 :Grid 的宽度设为 100%(撑满 Scroll 的内容区宽度),但高度不设固定值,由子项的行数动态撑起。
  3. ForEach 循环 :遍历 gridData 数组,为每个数据项调用 buildGridItem 生成一个卡片。ForEach 的第三个参数是 key 生成函数,使用 item.id.toString() 确保每个项有稳定的唯一标识------这有助于 ArkTS 框架在数据变化时高效地执行差异化更新(diff)。
  4. 布局约束:当 Grid 的总高度(24 个卡片 × 每行高度 + 间距)超过 Scroll 的视口高度时,Scroll 自动激活垂直滚动。

这里面有一个容易忽视但至关重要的细节:Grid 没有设置 height 属性 。在 ArkTS 中,如果一个容器没有显式设置高度,它的高度将由子项内容撑起(即 height: auto 行为)。这正是 Scroll + Grid 联动得以工作的前提------如果 Grid 也设了固定高度,那么 Grid 内部会自动滚动(Grid 本身只是一个布局容器,不会滚动),而外层的 Scroll 将因为子内容高度等于视口高度而无法滚动。

5.4 网格卡片构建:buildGridItem

每个卡片是一个方块区域,内部包含图标、主标题和副标题,顶部有一条彩色装饰条。从前面的编译过程中我们了解到,buildGridItemStack 替代了 overlay 来实现装饰条:

typescript 复制代码
@Builder
buildGridItem(item: GridItemModel) {
  Stack() {
    // 主内容区
    Column({ space: 6 }) {
      Text(item.icon).fontSize(32).lineHeight(40).textAlign(TextAlign.Center)
      Text(item.title).fontSize(14).fontWeight(FontWeight.Medium)
        .fontColor('#1E293B').lineHeight(20).maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
      Text(item.subtitle).fontSize(10).fontColor('#94A3B8')
        .lineHeight(14).maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
    }
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
    .width('100%').height('100%').padding({ top: 6 })

    // 顶部彩色装饰条
    Row()
      .width('100%').height(4)
      .backgroundColor(item.color)
      .borderRadius({ topLeft: 16, topRight: 16 })
      .alignSelf(ItemAlign.Start)
  }
  .width('100%').aspectRatio(1.0)
  .backgroundColor('#FFFFFF').borderRadius(16)
  .shadow({ radius: 6, offsetX: 0, offsetY: 2, color: 'rgba(0, 0, 0, 0.08)' })
  .onClick(() => {
    promptAction.showToast({ message: `点击了「${item.title}」`, duration: 1500 });
  })
}
卡片设计思路

每个卡片采用 aspectRatio(1.0) 约束为正方形。aspectRatio 是一个非常有用的属性------它在一个维度确定的情况下自动推算另一个维度,从而保持宽高比。这里 Grid 的宽度已由 columnsTemplate 均分为三列,aspectRatio(1.0) 自动推算出卡片的高度等于宽度,实现了一个完美的方块。

彩色装饰条的实现方式值得一提:最初我们尝试使用 overlay API 在卡片顶部叠加一个色条,但在 API 24 的编译过程中发现 overlay 的签名已更新为仅接受 CustomBuilder 直接参数(不再支持 { builder, align } 对象形式)。最终的解决方案是使用 Stack 容器,将内容 Column 和装饰条 Row 作为两个叠加层,利用 Stack 的默认居中对齐特性,使装饰条自然贴合卡片的顶部。

文字溢出处理

主标题和副标题都设置了 maxLines(1)textOverflow({ overflow: TextOverflow.Ellipsis }),确保当文字过长时不会破坏卡片布局,而是以省略号结尾。这在网格布局中尤为重要------网格的列宽由容器平均分配,项与项之间互不影响,但每个项内部的内容仍需做好溢出防护。

交互反馈

卡片绑定了 onClick 事件,点击时通过 promptAction.showToast 弹出轻提示。showToast 接受一个包含 messageduration 的对象参数,这是 API 24 中推荐的非阻塞式用户反馈方式。虽然编译器给出弃用警告(提示将来可能迁移到新的 API),但当前版本中它仍然完全可用。


六、Grid 布局的进阶技巧

6.1 列模板的多种写法

columnsTemplate 属性支持多种写法,适应不同的布局需求:

typescript 复制代码
// 写法一:固定列数 + 等宽
.columnsTemplate('1fr 1fr 1fr')      // 3列等宽
.columnsTemplate('repeat(3, 1fr)')    // 同上,repeat语法更简洁

// 写法二:固定列数 + 不等宽
.columnsTemplate('2fr 1fr 1fr')      // 第1列占1/2,后两列各占1/4
.columnsTemplate('100px 1fr 1fr')    // 第1列固定100vp,后两列平分剩余

// 写法三:自适应列数(CSS Grid 风格的 auto-fill)
.columnsTemplate('repeat(auto-fill, minmax(120vp, 1fr))')

写法三是响应式布局的利器。当容器宽度变化时(例如横竖屏切换),Grid 会自动重新计算可容纳的列数。例如一个宽度为 360vp 的容器,在 minmax(120vp, 1fr) 的约束下,最多可排 3 列;如果容器宽度增加到 720vp,则会自动扩展为 6 列。这种弹性布局在小屏到大屏的适配中表现极为出色。在实际开发中,推荐将 minmax 的最小值设为 120~140 vp,这是人机工学研究中得出的最小可触摸目标尺寸,低于该值会导致用户难以准确点击网格项。同时,最大值的设定也不宜过大,否则在宽屏设备上每行仅显示 1~2 列,浪费了宝贵的屏幕空间,网格布局的视觉效率将大打折扣。

行模板的不对称设计

rowsTemplate 除了可以设置统一的行高,还支持不对称设计,适用于仪表盘、控制面板等需要突出某些特定项的界面:

typescript 复制代码
Grid() {
  // 第 0 行:大卡片(特色推荐)
  GridItem() { /* 特色内容占一整行 */ }
    .columnStart(0).columnEnd(2)  // 跨 3 列
  // 第 1~3 行:标准卡片
  ForEach(items.slice(1), (item) => { /* 标准卡片 */ })
}
.columnsTemplate('1fr 1fr 1fr')
.rowsTemplate('200vp 120vp 120vp 120vp')

这里 GridItemcolumnStartcolumnEnd 属性允许子项跨越多个列,类似于 HTML <td colspan="3"> 的效果。结合 GridrowsTemplate 固定行高,可以实现类似杂志排版的丰富布局效果。

6.2 行模板与不规则网格

鸿蒙 Grid 组件还支持 rowsTemplate 属性,与 columnsTemplate 配合使用可以构建不规则网格。例如一个类似 Pinterest 瀑布流的布局:

typescript 复制代码
Grid() {
  // ... GridItem
}
.columnsTemplate('1fr 1fr')
.rowsTemplate('100vp 150vp 100vp 120vp')  // 每行高度不同

当同时设置了 rowsTemplatecolumnsTemplate 时,Grid 变为固定行列数 的二维布局,子项按先行后列的顺序填充。若子项数量超过 行数 × 列数,超出部分将不再显示------此时就需要配合 Scroll 来滚动浏览全部内容。

GridItem 的跨行跨列

Grid 中的一个高级特性是 GridItem 的跨行跨列能力。通过设置 columnStartcolumnEndrowStartrowEnd 属性,可以让某个子项跨越多个网格单元,实现类似杂志排版的不规则布局:

typescript 复制代码
Grid() {
  // 跨两列的大卡片
  GridItem() {
    this.buildFeaturedCard(data[0])
  }
  .columnStart(0).columnEnd(1)   // 从第0列跨到第1列(占2列)
  .rowStart(0).rowEnd(0)         // 仅占第0行

  // 标准卡片
  ForEach(data.slice(1), (item) => {
    this.buildGridItem(item)
  })
}
.columnsTemplate('1fr 1fr 1fr')
.rowsTemplate('200vp 120vp 120vp')

这种不规则的网格排布方式在新闻资讯、商品推荐、个人主页等场景中非常实用。需要注意的是,跨行跨列会改变 Grid 的子项计数方式:Grid 不再简单地从左到右从上到下排列子项,而是根据坐标属性进行定位。未被占用的网格位置将由后续的子项依次填充。

6.3 Grid 的子项对齐与分布

Grid 组件提供了丰富的子项对齐和分布控制属性,这些属性在 CSS Grid 中对应 align-itemsjustify-itemsalign-contentjustify-content

typescript 复制代码
Grid() {
  // ...子项
}
.columnsTemplate('1fr 1fr 1fr')
.rowsTemplate('100vp 100vp 100vp')
.alignItems(GridAlign.Center)        // 子项在网格单元中垂直居中
.justifyItems(GridAlign.Center)      // 子项在网格单元中水平居中
.alignContent(FlexAlign.SpaceAround) // 行整体在容器中均匀分布

alignItems 控制每个子项在其网格单元内的垂直对齐方式,可选值包括 Start(顶部)、Center(居中)、End(底部)和 Stretch(拉伸填充)。justifyItems 控制水平对齐,选项同上。当 Grid 的总高度小于容器高度时,alignContent 控制所有行的整体分布方式,FlexAlign.SpaceBetween(两端对齐)、SpaceAround(均匀分布)和 SpaceEvenly(等距分布)是最常用的三种模式。

6.3 性能优化:LazyForEach 替代 ForEach

当网格数据量极大(数百甚至数千项)时,ForEach 将所有子项一次性渲染的策略会带来严重的性能问题。此时应该使用 LazyForEach 代替 ForEach

typescript 复制代码
import { LazyForEach, IDataSource } from '@kit.ArkUI';

class GridDataSource implements IDataSource {
  // 实现 totalCount、getData、registerDataChangeListener 等方法
}

// 在 build 中使用
Grid() {
  LazyForEach(new GridDataSource(this.gridData), (item: GridItemModel) => {
    this.buildGridItem(item)
  }, (item: GridItemModel): string => item.id.toString())
}
.columnsTemplate('repeat(3, 1fr)')

LazyForEach 的核心思想是按需渲染------只有当某个 GridItem 即将进入 Scroll 的视口范围时,才真正创建对应的组件实例;当它滚出视口后,组件实例会被回收。对于数百个卡片的场景,屏幕上始终只维持 3~4 行(约 9~12 个)组件的活跃实例,内存开销从 O(n) 降至 O(1)。这一优化在商品列表、社交动态流、图库等高频滚动场景中至关重要。

6.4 横竖屏适配

Grid + Scroll 联动天然支持横竖屏适配,只需配合 GridColumnLayout 或监听窗口尺寸变化即可。一个常见的做法是根据屏幕宽度动态计算列数:

typescript 复制代码
@State private columnsCount: number = 3;

aboutToAppear(): void {
  const displayInfo = display.getDefaultDisplaySync();
  const width = displayInfo.width;
  if (width >= 800) {
    this.columnsCount = 5;   // 横屏或平板:5列
  } else if (width >= 600) {
    this.columnsCount = 4;   // 大屏手机:4列
  } else {
    this.columnsCount = 3;   // 小屏手机:3列
  }
}

aboutToAppear 是 ArkTS 组件的生命周期回调,在组件即将显示时触发,常用于执行初始化逻辑。结合 display.getDefaultDisplaySync() 获取屏幕信息,开发者可以在页面加载前确定最佳的列数配置。


七、编译验证与调试经验

7.1 使用 hvigorw assembleApp 进行编译检查

在开发过程中,建议定期使用 hvigorw assembleApp 命令进行编译检查:

bash 复制代码
cd 项目根目录
hvigorw assembleApp

该命令会执行完整的编译流程,包括 ArkTS 语法检查、资源编译、HAP 打包等。相比 DevEco Studio 中的实时语法检查,命令行方式提供了更全面、更准确的编译反馈,特别适合在 CI/CD 流水线中使用。

7.2 常见编译错误与解决方案

在我们的项目开发过程中,遇到了两个典型的编译错误,这里与大家分享解决方法:

错误一:Property 'Cyan' does not exist on type 'typeof Color'

typescript 复制代码
// 错误写法
color: Color.Cyan

// 正确写法
color: '#06B6D4'    // 使用十六进制字符串代替 Color 枚举

Color 枚举在 API 24 中仅包含基础颜色值:Color.BlueColor.GreenColor.RedColor.OrangeColor.YellowColor.PinkColor.GreyColor.BlackColor.WhiteCyan(青色)不在枚举范围内。对于枚举未覆盖的颜色,推荐使用十六进制字符串(ResourceColor 类型完全兼容)。

错误二:'builder' does not exist in parameter type

typescript 复制代码
// 错误写法(API 24 中不再支持)
.overlay({
  builder: () => { /* ... */ },
  align: Alignment.Top
})

// 正确写法:改用 Stack 容器或 clip 方式
Stack() {
  // 主内容
  Column() { /* ... */ }
  // 装饰条叠在上层
  Row().width('100%').height(4).backgroundColor(item.color)
}

这个 API 变更提醒我们:鸿蒙 ArkTS 的 API 仍在快速演进中,开发时应以目标 API 版本的官方文档为准,避免依赖实验中或已弃用的 API 特性。

7.3 弃用警告的处理策略

编译过程中可能出现的弃用警告(如 pushUrlshowToast 的弃用警告)应被视为提醒信号而非阻塞错误。通常处理策略是:

  1. 在目标 API 版本的官方文档中查找替代 API。
  2. 如果替代 API 稳定可用,及时迁移代码。
  3. 如果替代 API 仍在预览阶段(标记为 @systemapi@ohos.stage),可以暂缓迁移,但要做好记录。
  4. 无论如何,弃用警告不影响应用的编译和运行

八、生产环境最佳实践

8.1 组件拆分与复用

在实际项目中,GridScrollDemo 这样的页面往往会包含更复杂的功能。建议将页面拆分为更小的组件:

复制代码
GridScrollPage.ets (主页面)
├── GridHeader.ets (标题栏,可复用)
├── GridContent.ets (滚动网格主体)
│   ├── GridCard.ets (单个卡片,可复用)
│   └── GridDataSource.ts (数据源与业务逻辑)
└── GridUtils.ts (工具函数)

组件拆分不仅提高了代码的可维护性,还使得单元测试成为可能。每个独立的组件可以在 ohosTest 目录中编写测试用例,验证其在各种边界条件下的表现。

8.2 数据懒加载

对于生产环境的应用,24 条数据通常只是起点。当数据量达到 100+ 时,强烈推荐使用 LazyForEach + 分页加载的组合:

typescript 复制代码
@State gridData: GridItemModel[] = [];
private dataSource: GridDataSource;
private currentPage: number = 0;
private readonly pageSize: number = 30;

aboutToAppear(): void {
  this.loadMoreData();
}

loadMoreData(): void {
  // 模拟分页请求
  fetchGridDataFromServer(this.currentPage++, this.pageSize)
    .then((newItems) => {
      this.gridData = [...this.gridData, ...newItems];
    });
}

配合 Scroll 的 onScrollIndex 事件,可以实现"滚动到底部自动加载更多"的无限滚动体验。

8.3 交互增强

生产级网格还需要考虑以下交互细节:

  • 长按拖拽排序 :通过 GridItemonDragStartonDrop 事件实现。ArkTS 的事件冒泡机制使得拖拽手势可以在 Scroll 的滚动手势和 GridItem 的拖拽手势之间自动协调,开发者只需在 onDragStart 中返回被拖拽的数据索引,在 onDrop 中执行数组元素的交换操作即可。需要注意的是,拖拽过程中的视觉反馈(如阴影、半透明、位移)需要手动通过 @State 管理拖拽状态来实现。
  • 滑动删除 :结合 SwipeGesture 手势识别器实现。在 GridItem 的外层包裹 GestureGroup,监听手指在水平方向的滑动距离,当超过阈值(通常为 80 vp)时触发删除操作。为避免与 Scroll 的垂直滚动冲突,需要在 SwipeGesture 中设置 direction(SwipeDirection.Horizontal) 明确限定滑动方向。
  • 多选模式 :长按进入多选状态,配合 @State 管理选中项的集合。多选模式下的典型交互包括:选中项显示勾选标记、工具栏显示"全选/取消全选"按钮、顶部标题栏切换为"已选 N 项"的计数模式。退出多选模式可以通过点击空白区域或按返回键触发。
  • 空状态展示 :当 gridData 为空数组时,显示空状态占位图而非空白页面。一个完整的空状态组件应包含:插图或图标(建议使用 SVG 矢量图,适配深色模式)、提示文案(简要说明当前无数据的原因)、建议操作按钮(如"重新加载"或"去添加")。在 Grid 场景中,空状态应替换整个 Grid 区域而非覆盖在 Grid 之上,以保持无障碍语义树的整洁。
  • 下拉刷新 :使用 PullToRefresh 组件或手动监听 ScrollonReachStart 事件实现。onReachStart 在 Scroll 滚动到顶部时触发,配合 Refresh 组件可以开箱即用地实现标准的 Material Design 下滑刷新效果。刷新期间应显示加载指示器,并禁用 Scroll 的滚动交互以防止用户在刷新时误操作。
  • 上拉加载更多 :监听 ScrollonReachEnd 事件。该事件在 Scroll 滚动到底部时触发,非常适合实现分页加载效果。加载期间可以在网格底部显示一个加载中的提示行,加载完成后移除该提示行并将新数据追加到 gridData 末尾。
  • 骨架屏加载 :在数据尚未加载完成时,显示与最终内容结构一致的灰色占位块,让用户感知到页面正在加载而非空白一片。骨架屏的实现通常使用一个独立的 @State loading: boolean 控制,加载中时渲染占位 Grid,加载完成后切换为真实数据 Grid。

8.4 主题与暗黑模式

使用 $r('app.color.xxx') 资源引用替代硬编码颜色值,可以使应用原生支持暗黑模式切换:

typescript 复制代码
// 将硬编码颜色替换为资源引用
.fontColor($r('app.color.text_primary'))
.backgroundColor($r('app.color.background_primary'))

// 在 resources/dark/element/color.json 中定义暗黑模式下的颜色值
// {
//   "color": [
//     { "name": "text_primary", "value": "#E2E8F0" },
//     { "name": "background_primary", "value": "#0F172A" }
//   ]
// }

当用户在系统设置中切换深色/浅色模式时,应用界面会自动适应,无需任何代码改动。

8.5 典型应用场景

Grid + Scroll 联动布局在实际项目中有广泛的应用场景。以下是几个典型的案例,供开发者在架构设计时参考。

场景一:应用管理中心

手机设置中的应用管理界面通常以网格形式展示所有已安装的应用列表。每个应用项包含图标、名称、大小和版本号,用户可以点击进入应用详情页,或长按进入编辑模式卸载应用。这个场景对滚动性能要求极高------手机上通常安装有 100~300 个应用,采用 LazyForEach 按需渲染后,初始加载时间可以从 500 ms 降至 50 ms 以内。配合分页加载策略,每次仅渲染 20~30 个应用卡片,内存占用可控制在 10 MB 以内。

场景二:相册缩略图浏览

相册是网格布局最经典的应用场景之一。一张照片网格需要支持以下特性:可变列数(横屏 5 列、竖屏 3 列)、选择模式(批量分享或删除)、拖拽排序(自定义相册封面)以及加载动画(图片渐显或缩放进入)。对于包含数千张照片的大图库,必须同时使用 LazyForEach 和图片分级加载策略(先加载 64x64 的缩略图占位,再逐步替换为高清图),否则内存会迅速耗尽导致应用被系统杀掉。

场景三:电商商品列表

电商首页或搜索结果页的商品网格是 Grid + Scroll 联动的高频使用场景。商品卡片通常包含商品图片、标题、价格、销量、评分等信息。在电商场景中,网格布局常与标签系统(如"新品"、"特价"、"包邮"等角标)结合使用,卡片的宽度和高度可能需要根据商品图的比例动态调整。此时将 Grid 与 WaterFlow 组件配合使用,可以实现类似主流电商应用的瀑布流商品展示效果,大幅提升商品陈列密度。

场景四:后台管理仪表盘

企业级应用的仪表盘页面通常由多个功能卡片组成网格,每个卡片显示不同的业务指标,如日活用户数、订单转化率、收入趋势图等。这类场景对布局的灵活性要求极高------某些卡片需要跨列显示(如折线图卡片),某些卡片需要固定宽高比(如环形图卡片),且通常支持拖拽自定义布局。Grid 的跨行跨列能力配合 GridItem 的拖拽事件可以完美满足这一需求,开发者无需引入第三方布局库即可实现可自定义的仪表盘布局。

8.6 无障碍访问

在构建网格布局时,不应忽视无障碍访问(Accessibility)的支持。HarmonyOS NEXT 的 ArkTS 框架内置了无障碍基础设施,但开发者仍需注意以下几点:

  • 为每个 GridItem 设置 accessibilityText 属性,提供有意义的描述文本而非默认的组件层级描述。例如对于一个显示"云存储"的卡片,无障碍描述应为"云存储应用,点击进入",而非"图标,文字,文字"。
  • 确保 GridItem 的最小触摸目标尺寸不小于 44 vp,约合 12 毫米,这是人机工程学研究中得出的舒适触摸区域下限,低于该值会导致用户难以精准点击。
  • 对于纯图片类型的内容,设置 alt 属性或 accessibilityDescription 属性,方便视障用户通过屏幕朗读理解图片内容。
  • 使用语义化的容器组织卡片内部结构。Column 和 Row 是语义化的线性容器,无障碍服务可以将它们解析为有序的内容序列,而 Stack 的叠加特性可能导致内容顺序被误解。
  • 在空状态和错误状态下,使用 accessibilityLiveRegion 属性通知无障碍服务页面状态发生了变化,让使用屏幕朗读的用户及时获知反馈。

九、总结与展望

9.1 核心要点回顾

本文通过一个完整的 ArkTS 示例应用,深入剖析了鸿蒙原生 Grid + Scroll 联动实现可滚动网格的布局方案。核心要点包括:

  1. 职责分离:Scroll 负责滚动交互,Grid 负责行列布局,各司其职、协作无间。
  2. 高度约定 :Grid 高度由内容撑起(不设固定高度),Scroll 高度由 layoutWeight 约束为固定值------这是两者联动的关键前提。
  3. 数据驱动@State + ForEach 实现数据到 UI 的单向数据流,增删改数据自动反映在界面上。
  4. 资源类型 :API 24 引入的 ResourceColor 联合类型,兼顾了 Color 枚举的便捷性和十六进制字符串的灵活性。
  5. 性能考量 :大规模数据场景应使用 LazyForEach 实现按需渲染,避免一次性创建过多组件实例。

9.2 从 Grid + Scroll 看鸿蒙布局哲学

Grid + Scroll 联动的设计折射出鸿蒙 ArkTS 布局体系的核心理念:组合优于继承 。不是创建一个功能臃肿的 "ScrollableGrid" 组件,而是让专注的 Scroll 和专注的 Grid 通过组合协同工作。这种思想贯穿 ArkTS 整个组件体系------TabsTabBar + TabContent 组合,Swiper 由多个页面组合,NavigationNavBar + NavContent 组合。

对于开发者而言,理解这一理念将带来显著的思维转变:不要再试图寻找"一个组件解决所有问题"的万能方案,而是学会识别问题域中的不同维度,用多个专注的组件组合解决复杂问题。

9.3 下一步学习方向

掌握 Grid + Scroll 联动后,可以继续探索鸿蒙 ArkTS 布局体系中的其他主题:

  • WaterFlow 组件:鸿蒙原生实现的瀑布流布局,适合图片分享、商品推荐等不规则高度场景。
  • GridRow / GridCol 响应式布局:基于 12 列栅格系统的页面级布局方案,适用于大屏适配。
  • RelativeContainer 相对布局:通过锚点约束实现子组件间的相对定位,适合复杂仪表盘和自定义布局。
  • Canvas 自绘布局:当标准容器无法满足需求时,使用 Canvas API 绘制自定义 UI,适合游戏、图表等场景。

附录:源码获取

本文完整的示例代码可在项目 entry/src/main/ets/pages/ 目录下找到。

主入口文件:Index.ets

核心演示文件:GridScrollDemo.ets(299 行,含详细中文注释)

路由注册文件:resources/base/profile/main_pages.json

运行方式:在 DevEco Studio 中打开项目,选择 API 24 的模拟器或真机,点击运行即可。页面加载后点击"进入演示"按钮查看 Grid + Scroll 联动效果。


本文基于 HarmonyOS NEXT API 24(ArkTS)撰写,代码已在 API 24 环境下编译验证通过。API 版本差异可能导致部分语法或行为不同,请以官方文档为准。