你写完 Row/Column、Stack、Flex、RelativeContainer 之后,下一类很常见的页面结构就是“栅格”:
- 首页功能入口宫格
- 分类页/频道页的卡片墙
- 大屏上的“右侧多列内容区”
这类页面的痛点通常也很一致:同一套数据,手机两列、平板三列、横屏四列;布局应该跟着窗口宽度变,而不是靠一堆 if/else 手写 width 去凑。
这一篇先做一个“入口宫格”:手机上每行 2 个入口,中等宽度每行 2 个,大屏每行 3 个;遇到 Banner 或提示条时,可以让它跨满整行。这里主要用 GridRow / GridCol 讲断点、列数、span 和 offset,不展开列表虚拟化、瀑布流、导航和动效。
先把“栅格”算清楚
栅格布局不是直接说“卡片宽 160vp”,而是先把容器横向切成 N 列,再定每个卡片占其中几列:
N = 12 列(lg)
|1|2|3|4|5|6|7|8|9|10|11|12|
一个卡片 span=4(占 4 列)
[ card ][ card ][ card ]
当屏幕变窄,你通常不会改“卡片长什么样”,而是改两件事:
- 这一行切成多少列(
columns) - 卡片之间的间距(
gutter)
所以栅格需要考虑的通常不是“宽高”,而是这套规则:断点决定总列数,卡片用 span 决定自己占几列。
在下面的示例里,规则可以先写成这样:
| 断点 | columns |
普通入口 span |
实际效果 |
|---|---|---|---|
sm |
4 | 2 | 每行 2 个入口 |
md |
8 | 4 | 每行 2 个入口,卡片更宽 |
lg |
12 | 4 | 每行 3 个入口 |
这张表比“某个卡片宽多少”更重要。设计改成大屏 4 列时,你只需要调整 lg 下的 span,而不是逐个改卡片宽度。
ArkUI:先把入口宫格做出来(GridRow/GridCol)
下面这个例子只做三件事:
columns:三套列数(例子用 4 / 8 / 12)gutter:统一间距(x/y)span/offset:每个卡片“占几列/偏几列”
interface GridEntry {
title: string
desc: string
symbol: string
color: ResourceColor
}
@Entry
@Component
struct EntryGridDemo {
private entries: GridEntry[] = [
{ title: '扫一扫', desc: '扫码 / 识别', symbol: '扫', color: '#2563EB' },
{ title: '出行', desc: '公交 / 地铁', symbol: '行', color: '#16A34A' },
{ title: '外卖', desc: '附近服务', symbol: '餐', color: '#EA580C' },
{ title: '缴费', desc: '水电燃气', symbol: '缴', color: '#7C3AED' },
{ title: '更多', desc: '全部入口', symbol: '···', color: '#475569' },
]
build() {
Column() {
GridRow({
columns: { sm: 4, md: 8, lg: 12 },
gutter: { x: 12, y: 12 },
direction: GridRowDirection.Row,
breakpoints: {
value: ['320vp', '600vp', '840vp'],
reference: BreakpointsReference.WindowSize
}
}) {
ForEach(this.entries, (item: GridEntry) => {
GridCol({
span: { sm: 2, md: 4, lg: 4 },
offset: { sm: 0, md: 0, lg: 0 },
}) {
Row() {
Text(item.symbol)
.fontSize(16)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.width(36)
.height(36)
.borderRadius(18)
.backgroundColor(item.color)
Column() {
Text(item.title)
.fontSize(14)
.fontWeight(FontWeight.Medium)
Text(item.desc)
.fontSize(11)
.fontColor('#64748B')
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor($r('sys.color.background_secondary'))
.alignItems(VerticalAlign.Center)
}
}, (item: GridEntry) => item.title)
}
.padding(16)
.onBreakpointChange((breakpoint: string) => {
console.info(`grid breakpoint: ${breakpoint}`)
})
}
.width('100%')
}
}
效果:

breakpoints.reference决定按谁的宽度切断点。这里用WindowSize,适合多窗口或窗口拉伸场景。columns决定当前断点下“这一行切成几列”。span决定“这个卡片占几列”。例子里sm: 2、md: 4、lg: 4,所以分别是 2 列、2 列、3 列。offset决定“这个卡片向右空出几列”(常用于居中块/右对齐块)。gutter决定“卡片之间的距离”。
这里没有让业务入口图标依赖应用入口标识。应用入口图标不是内容资源;教学示例里可以先用文字标识和颜色块表达结构,真实项目再替换成业务图标资源。
两个小坑也顺便记住:
span/offset尽量用整数,不要用小数(官方推荐规则里也明确不建议用小数)。- 不要让所有
GridCol的span都等于当前columns。那等价于所有子组件永远占满一整行,栅格系统没有发挥作用,还会增加组件树复杂度。
一个常见变体:某张卡片在大屏占满整行
只要把 span 改成 { sm: 4, md: 8, lg: 12 },它就会在不同断点下自动“占满整行”。这个写法适合 Banner、风险提示、运营位这类本来就应该横跨整行的内容:
GridCol({ span: { sm: 4, md: 8, lg: 12 } }) {
// Banner / 提示条 / 运营位
}
Android:同一个入口宫格,在 View/Compose 里怎么落地
1) View 体系(RecyclerView + GridLayoutManager)
你的核心变量是 spanCount(一行切成几份),和 ArkUI 的 columns 是一回事。
val spanCount = when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Compact -> 2
WindowWidthSizeClass.Medium -> 3
else -> 4
}
val layoutManager = GridLayoutManager(context, spanCount)
layoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int = 1 // 某些位置想占满整行就返回 spanCount
}
recyclerView.layoutManager = layoutManager
间距一般用 ItemDecoration 统一处理(对应 ArkUI 的 gutter),不要把 margin 散落在每个 item 里。
2) Compose(LazyVerticalGrid)
Compose 更常见的写法是“固定列”或“自适应列”:
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 160.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(entries) { entry ->
EntryCard(entry)
}
}
这段代码表达的是:我只给“卡片最小宽度”和“间距”,列数由系统推导。 如果你需要“某个 item 占满整行”,一般会用 GridItemSpan(或把它放到单独的区块):
item(span = { GridItemSpan(maxLineSpan) }) { Banner() }
对照 ArkUI 时,重点不是逐行翻译 LazyVerticalGrid,而是把“列数推导结果”显式化:sm/md/lg 各是多少列、常规卡片 span=多少、哪些卡片需要满行/偏移。
对照表:GridRow/GridCol ↔ Android 的三种栅格思路
| 你脑子里的问题 | Android(View/XML)常用做法 | Android(Compose)常用做法 | ArkUI(ArkTS)常用做法 | 学习转换点 |
|---|---|---|---|---|
| “整行被切成几列?” | GridLayoutManager(spanCount) |
GridCells.Fixed(n) / Adaptive(minSize) |
GridRow({ columns: { sm, md, lg } }) |
ArkUI 更偏“断点配置表” |
| “某个卡片占几列?” | SpanSizeLookup |
GridItemSpan |
GridCol({ span: ... }) |
本质都是 span |
| “间距在哪里管?” | ItemDecoration |
Arrangement.spacedBy |
gutter |
间距尽量集中管理 |
| “断点怎么做?” | sw<N>dp / WindowSizeClass |
WindowSizeClass |
breakpoints + { sm, md, lg } |
断点只影响配置,不影响结构 |
检查清单:把栅格从“能排”写到“可维护”
- 先确定“内容密度”的目标:手机 2 列/3 列?平板 3 列/4 列?
- 把“断点 → 列数”做成一张表:Android(
WindowSizeClass)与 ArkUI(columns: { sm, md, lg })都只读这张表 - 把“卡片占几列”统一用
span表达:不要在卡片内部用 width 去硬凑 - 间距优先放在栅格层(
gutter/Arrangement.spacedBy),卡片内部只管 padding - 有“居中块/右对齐块”时,优先用
offset(ArkUI)或SpanSizeLookup/分组(Android)表达意图,不要靠空白占位 hack
参考素材(少量高质量)
- HarmonyOS Developers:栅格布局(GridRow/GridCol)
- HarmonyOS References:GridRow
- HarmonyOS References:GridCol
- OpenHarmony Code Linter:grid-columns-span 推荐规则
- OpenHarmony Code Linter:grid-span-value 推荐规则
- Android Developers:Create dynamic lists with RecyclerView
- Android Developers:Lazy grids in Compose
// Kai@CodeHubble // 观测坐标:Android-HarmonyOS/2026-05-26