以前用 RecyclerView 写网格布局一套 Adapter、一个 GridLayoutManager、一个 ViewHolder都快成肌肉记忆了。后来切到 Jetpack Compose第一次用 LazyVerticalGrid 的时候我最大的感受是这玩意儿简直就像照着 LazyColumn 的用法做出来的孪生兄弟声明式写法让网格布局变得直白很多。这篇文章我就围绕 LazyVerticalGrid 这个 Composable 组件把它的设计思路、关键参数、真实可跑的示例代码以及我在项目里踩过的坑一次讲清楚。如果你正在把老项目从 XML 和 RecyclerView 往 Compose 迁移或者刚学 Compose 没多久想搞明白网格列表到底怎么写得优雅这篇文章应该能帮你省下不少试错时间。我会按“为什么用、怎么选型、怎么写、怎么排坑”这个顺序来聊没有废话全是实操向的内容。1. LazyVerticalGrid 的核心设计思路为什么网格布局非它不可1.1 从 RecyclerView 时代看网格布局的痛点迁移以前在 XML 体系里做网格列表流程很固定先给 RecyclerView 设置 GridLayoutManager再写 Adapter创建 ViewHolder处理 item 点击还得手动处理加载更多的滚动监听。这套流程本身没啥问题但代码量不小而且 UI 和数据解耦得很“别扭”。尤其是当你需要“第一个 item 占整行”“某些 item 跨多列”这种特殊布局时GridLayoutManager 要写 SpanSizeLookup还要小心 index 对应关系很容易出 bug。到了 Compose 时代LazyVerticalGrid 把这些全抽象掉了。它属于 androidx.compose.foundation.lazy 包里的 Lazy 系列组件核心特点是数据驱动、按需组合、滚动复用。你不用再写 Adapter不用手动管 ViewHolder只需要在 content 里用 GridItemSpan 之类的参数来描述每个 item 的展示规则。数据量不管是一百条还是十万条它都能只组合当前可见区域那几项。这一点跟 LazyColumn 的思路完全一致学习成本很低。提示LazyVerticalGrid 和 LazyColumn 一样都是 Lazy 组件接收一个 LazyGridScope 作用域。在这个作用域里你能调用的 items / item / spans 相关 API 和 LazyListScope 高度相似所以之前会写 LazyColumn 的人上手 LazyVerticalGrid 几乎是零门槛。1.2 从“列数”维度重新理解网格网格布局和普通列表最大的区别在于“列数”的确定方式。用 RecyclerView 时列数是 GridLayoutManager 构造参数里的一个固定 int比如 GridLayoutManager(this, 3)。这 3 列在横竖屏切换、不同尺寸平板上都不变你只能手动监听配置变化再去改列数。LazyVerticalGrid 的做法是直接抽象出一个 GridCells 类型提供了三种创建方式GridCells.Fixed、GridCells.Adaptive、GridCells.FixedSize。这三种方式对应的适用场景差别很大我后面会单独用一节来拆。但这里先强调一个宏观层面的变化LazyVerticalGrid 把“列数”从“写死”变成了“策略化”。你不再需要自己去判断屏幕宽度再去计算多少列而是告诉 Compose“我想固定三列”或者“我每个 item 最小要 100.dp”剩下的交给组件自己去算。这种思路在响应式适配里特别香尤其做那些要同时适配手机和平板的页面能少写一坨 if 判断。1.3 三种单元格尺寸策略到底该用哪种我用实际场景来对比GridCells.Fixed、GridCells.Adaptive、GridCells.FixedSize这三种方式的差异策略写法表现适合场景GridCells.Fixed(3)固定 3 列不管屏幕多宽永远 3 列宫格入口、设置选项、功能菜单GridCells.Adaptive(120.dp)每列最小 120.dp自动算列数屏幕越宽列数越多商品列表、图片瀑布流、自适配网格GridCells.FixedSize(120.dp)每列固定 120.dp 宽多出来的空间留白不会撑破规整的卡片墙但不想让列数变化从实践经验看GridCells.Adaptive是我用得最多的一个因为移动端屏幕宽度碎片化太严重了。比如同样写Adaptive(minSize 140.dp)在宽 360.dp 的手机上可能分成 2 列在宽 412.dp 的手机上变成 3 列在平板上变成 5 列。这个“自动列数”不是简单的除法它内部会计算一行里最多能放几个不小于 minSize 的列并可能会让列宽略大于 minSize以保证刚好放满一行。记住一句话Adaptive 只关心“最小宽度”不关心精确列数所以不要试图去推算某个 item 在第几行第几列除非你用了 Fixed。2. 关键参数与特性拆解从滚动方向到跨列单元格LazyVerticalGrid 的参数看着多其实真正影响日常使用的就那几个。我挑几个最核心的逐个分析它们的实际作用和选择理由。2.1 滚动状态控制rememberLazyGridState 与上下滚动行为LazyVerticalGrid 默认就是上下滚动的。如果你想在代码里控制滚动位置或者监听滚动状态就需要一个LazyGridState它和LazyListState的用法基本一致val gridState rememberLazyGridState() LazyVerticalGrid( columns GridCells.Adaptive(minSize 120.dp), state gridState, modifier Modifier.fillMaxSize() ) { items(100) { index - Text(Item $index) } }拿到 gridState 后你能用它做很多操作gridState.scrollToItem(index: Int)直接跳到某个 item无动画。gridState.animateScrollToItem(index: Int)带动画滚动到某个 item。gridState.firstVisibleItemIndex当前第一个可见 item 的索引做阅读进度记录很方便。gridState.layoutInfo.visibleItemsInfo获取当前可见 items 的信息分页判断就靠它。我经常用以下方式监听列表是不是滑到底部val shouldLoadMore by remember { derivedStateOf { val lastVisibleItem gridState.layoutInfo.visibleItemsInfo.lastOrNull() lastVisibleItem ! null lastVisibleItem.index gridState.layoutInfo.totalItemsCount - 3 } }这个判断里用derivedStateOf包裹很重要。如果直接在 composition 里读取 layoutInfo每次滚动都会触发重组包一层 derivedStateOf 之后只有“距离底部是否小于 3 个 item”这个布尔值发生变化时才会触发重组。这一招在长列表性能优化里非常关键我后面讲性能时会再提。注意rememberLazyGridState()默认只记住滚动位置如果页面因为配置变化重建它也会通过 rememberSaveable 自动恢复位置。但如果列表数据是在 init 阶段异步加载的可能出现“恢复的 item 索引超出了新数据的总数”的情况建议在数据处理时加一层 min(index, totalCount - 1) 的保护。2.2 userScrollEnabled 和 reverseLayout两个容易被忽视的滚动开关LazyVerticalGrid 还有两个跟滚动行为直接相关的参数userScrollEnabled和reverseLayout。先看 userScrollEnabled。默认是 true表示用户可以通过手势滚动列表。当你把它设为 false 时列表仍然可以通过代码滚动scrollToItem 依然有效但用户手指滑动不会有反应。这种场景我遇到比较多的有弹窗内的固定网格选择器、配合父容器手势冲突的页面、或者数据变更时希望列表固定住不被打断的展示页。再看 reverseLayout这个参数默认是 false内容从顶部开始排列并向下延伸。设为 true 之后内容会“反过来”起始位置在底部滚动方向也反了。这跟聊天列表倒序加载是一个套路。对于网格我实际用得比较少但如果你做那种“下方固定一个输入框、上方网格消息记录倒序展示”的界面把 reverseLayout 设为 true 后列表内容会自动从底部开始新增数据时视觉上还保持在底部体验会好很多。提示reverseLayout 并不会真的帮你反转 item 内容它只是改变了排列和滚动的方向。不要在 item 里面写镜像类的代码。2.3 内容内边距与排列方式contentPadding 和 Arrangement 的配合网格列表和普通列表一样内容区域和屏幕边缘之间需要留白。最直接的做法是给 modifier 加 padding但这样会带来一个问题滚动时padding 区域也被滚走了。当你希望列表滚动到边缘时内容依然能“贴住”某个边距就要用contentPadding参数。LazyVerticalGrid( columns GridCells.Fixed(2), contentPadding PaddingValues(start 16.dp, top 16.dp, end 16.dp, bottom 80.dp), horizontalArrangement Arrangement.spacedBy(12.dp), verticalArrangement Arrangement.spacedBy(12.dp) ) { // items... }contentPadding 的常见用途是页面顶部有 Tab底部有 BottomBar列表内容滚动到底时不能被 BottomBar 挡住于是给 bottom 留出 BottomBar 的高度加一段间距。这样即使滑动到底最后一行 item 也会停在 BottomBar 上方不会出现“内容被遮住”的尴尬。horizontalArrangement 和 verticalArrangement 是网格里用来控制排列方式的参数。最常见的是Arrangement.spacedBy(12.dp)它会在每两个 item 之间插入固定的间距。还有一些不太常用但很有用的排列方式比如Arrangement.SpaceBetween、Arrangement.SpaceEvenly、Arrangement.Center等可以让网格在一行没占满时用不同的方式分配剩余空间。2.4 跨行跨列GridItemSpan 实现“通栏”和“跨列卡片”网格布局里有一种很常见的需求第一行是一个通栏广告条下面才是正常的商品卡片或者某几个 item 需要横跨两列。LazyVerticalGrid 处理这个需求的 API 叫spans它的使用方式非常直接LazyVerticalGrid( columns GridCells.Fixed(2) ) { item(span { GridItemSpan(maxLineSpan) }) { BannerView() } items( items productList, key { it.id } ) { product - ProductCard(product) } }GridItemSpan(maxLineSpan)表示这个 item 占满当前行的所有列也就是“通栏”。在 Fixed(2) 的下GridItemSpan(2)和GridItemSpan(maxLineSpan)效果是一样的。但如果你用了GridCells.Adaptive列数并不固定那就不要写死 2 或 3直接用 maxLineSpan 才能保证永远整行。跨列不会破坏网格的滚动逻辑。GridItemSpan 只影响当前 item 在网格中的占位大小其他 item 依然按列排列。但当跨列的 item 总宽度比一列宽时它的高度会被格子约束住所以如果你发现跨列 item 的高度被压缩了可以在 item 内部用Modifier.height()手动指定一个期望高度。3. 实操示例一个带通栏 Header、跨列卡片和分页加载的网格页面理论讲完直接上代码。我会从环境准备开始逐步实现一个完整可运行的示例。3.1 环境准备与依赖版本先确认你的项目满足基本条件Android Studio 版本建议使用最新稳定版因为 Compose 编译器和 Kotlin 版本的绑定关系比较严格。我这边用的是Kotlin 1.9.22Compose BOM 2024.02.00Android Gradle Plugin 8.2.0minSdk 24targetSdk 34在 app 模块的 build.gradle.kts 里加入android { buildFeatures { compose true } composeOptions { kotlinCompilerExtensionVersion 1.5.8 } } dependencies { implementation(platform(androidx.compose:compose-bom:2024.02.00)) implementation(androidx.compose.ui:ui) implementation(androidx.compose.material3:material3) implementation(androidx.compose.foundation:foundation) implementation(androidx.compose.ui:ui-tooling-preview) debugImplementation(androidx.compose.ui:ui-tooling) }如果你用了图片加载库比如 Coil还需要加上io.coil-kt:coil-compose的依赖。我下面示例里用 Coil 加载网络图片展示卡片封面不做缓存方案的话它默认的内存缓存和磁盘缓存已经够用。3.2 第一版用 Adaptive 网格渲染一个商品列表先做一个最简单的网格商品列表这个版本能跑通“数据渲染”这一层。假设有这样一个数据类data class Product( val id: Int, val title: String, val price: String, val imageUrl: String )然后写一个模拟数据源private fun mockProducts(count: Int): ListProduct List(count) { index - Product( id index, title 商品 $index, price ${9.9 index}, imageUrl https://picsum.photos/id/${100 index}/300/300 ) }核心的 Composable 如下Composable fun ProductGridDemo() { val products remember { mockProducts(50) } LazyVerticalGrid( columns GridCells.Adaptive(minSize 140.dp), modifier Modifier.fillMaxSize(), contentPadding PaddingValues(12.dp), horizontalArrangement Arrangement.spacedBy(12.dp), verticalArrangement Arrangement.spacedBy(12.dp) ) { items( items products, key { it.id } ) { product - ProductCard(product) } } } Composable fun ProductCard(product: Product) { Card( modifier Modifier.fillMaxWidth() ) { Column { AsyncImage( model product.imageUrl, contentDescription null, modifier Modifier .fillMaxWidth() .aspectRatio(1f) ) Text( text product.title, modifier Modifier.padding(horizontal 8.dp, vertical 4.dp), maxLines 1, overflow TextOverflow.Ellipsis ) Text( text product.price, modifier Modifier.padding(horizontal 8.dp, bottom 8.dp), style MaterialTheme.typography.titleSmall, color MaterialTheme.colorScheme.primary ) } } }运行起来后你会看到网格根据屏幕宽度自动排成两到三列滚动手感顺滑。这里有一个细节contentPadding用 12.dp 而不是直接给 modifier 加 padding这样滚动到最后一行时内容会停在距屏幕底部 12.dp 的位置而不是整个列表贴着屏幕边缘。3.3 第二版加入通栏 Header 和分页加载商品列表不可能永远只有 50 条下拉加载更多是标配。我在这里把逻辑拆成三块网格内容、滚动到底监听、加载状态标识。先定义一个可观察的数据容器用mutableStateListOf管理列表val products remember { mutableStateListOfProduct().apply { addAll(mockProducts(30)) } } var isLoading by remember { mutableStateOf(false) } val gridState rememberLazyGridState()然后写一个防重复加载的监听val shouldLoadMore by remember { derivedStateOf { val layoutInfo gridState.layoutInfo val lastVisibleIndex layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 lastVisibleIndex layoutInfo.totalItemsCount - 3 } } LaunchedEffect(shouldLoadMore) { if (shouldLoadMore !isLoading) { isLoading true // 模拟网络请求 delay(800) val newItems mockProducts(20) products.addAll(newItems) isLoading false } }网格内容部分LazyVerticalGrid( columns GridCells.Adaptive(minSize 140.dp), state gridState, modifier Modifier.fillMaxSize(), contentPadding PaddingValues(12.dp), horizontalArrangement Arrangement.spacedBy(12.dp), verticalArrangement Arrangement.spacedBy(12.dp) ) { // 通栏 Header item(span { GridItemSpan(maxLineSpan) }) { PromotionBanner() } items( items products, key { it.id } ) { product - ProductCard(product) } // 底部加载更多指示器 if (isLoading) { item(span { GridItemSpan(maxLineSpan) }) { CircularProgressIndicator( modifier Modifier .padding(16.dp) .size(32.dp) .align(Alignment.CenterHorizontally) ) } } }这段代码里有一个关键细节加载更多指示器本身也是一个网格 item用span { GridItemSpan(maxLineSpan) }通栏居中展示。比起在网格外面套一个 Column再把指示器放在网格下方这种做法的好处是加载更多时会自然地被“计算”进滚动高度里不会出现“网格已经滑到底了但加载更多进度条还在屏幕外面”的情况。3.4 第三版状态保存、item 复用和性能优化网格列表到后期最容易出的问题是用户滑出去再滑回来item 里的状态全丢了或者列表变长后滚动开始掉帧。这两个问题的解法其实都写在 Compose 的“最佳实践”里但很多人会忽略。第一个是状态保存。如果你的 item 里有勾选状态、输入框内容、播放状态等你在items里设置一个稳定的 keyCompose 就能在列表数据变化时尽量复用对应的 item避免无关 item 重组。刚才代码里的key { it.id }就是这个意思。如果 item 里还有需要跨配置变更保存的自定义状态配合rememberSaveable单独保存不要依赖 item 的数据对象。第二个是 contentType。当网格里存在多种类型 item 时设置 contentType 能帮 Compose 复用同一类型的 item 节点减少重组开销。写法很简单items( items products, key { it.id }, contentType { it::class.java.simpleName } ) { product - ProductCard(product) }第三个常见的性能坑是 item 内部的布局开销。比如AsyncImage的占位图、解码尺寸最好根据网格 item 的实际显示尺寸来不要直接加载原图。Coil 里面可以设置size(300)或直接用Size.ORIGINAL避免大图占内存。图片解码本身也会造成滚动时的峰值卡顿尤其是网络图没有本地缓存的时候。这里我给 Coil 配了内存缓存和磁盘缓存之后滚动流畅度提升明显这是我在实际项目里很推荐的优化方向。4. 常见问题与排查技巧滚动异常、性能卡顿与嵌套冲突就算参数都会用了真正上线时还是会遇见奇奇怪怪的问题。这一节我把我这几年遇到的典型坑整理成速查表后面补充详细说明。问题现象常见原因解决方法滑动卡顿、掉帧item 组合太复杂、图片过大、state 读取范围过大用 derivedStateOf 缩小 state 读取范围给图片限制解码尺寸检查 item 内是否有高开销布局网格高度塌陷把 LazyVerticalGrid 放进 Column 或又嵌套了一个 Lazy 组件改用单一 Lazy 网格 多类型 item或用 Modifier.heightIn 限高滚动位置错乱列表数据更新后 item 位置映射漂移设置稳定 key不要在 item 内用数组 index 做状态依据点击事件总是被滑动吞掉手势冲突用 combinedClickable 或自定义手势检测横竖屏切换时 index 越界rememberSaveable 恢复了旧索引恢复后对索引做边界限制跨列 item 高度被压缩GridItemSpan 占位后高度只跟内容相关手动指定高度或使用 Modifier.height下面挑几个具体展开。4.1 滚动卡顿先定位重组范围网格列表卡顿不要一上来就怀疑 Compose 性能不行绝大多数问题是“状态读取范围太大”导致的。比如你直接在 item 的 Composable 函数里读取了一个viewModel的 LiveData那这个 item 每次数据变化都会重组如果列表有几百个 item重组风暴一来神仙也顶不住。排查方法很简单用 Android Studio 自带的 Layout Inspector打开 “Show Recomposition Counts”滑动列表看哪个 item 的重组次数异常高。只要看到某些不相关的 item 也在疯狂重组基本就能定位到问题。我自己的项目里遇到过这种情况商品卡片里展示了一个“剩余库存”字段这个字段来自一个全局的库存 Map滚动时库存变化会引起所有商品 item 重组。后来我把“剩余库存”抽成了一个独立的子 Composable并且用derivedStateOf包装读取逻辑重组范围瞬间缩小滑动又恢复了顺滑。4.2 嵌套 Lazy 组件的“高度塌陷”问题这是一个非常经典的坑把 LazyVerticalGrid 放进 Column或者网格里面再套一个 LazyRow经常会导致运行时崩溃或者布局怪异。原因是 Lazy 组件在测量时只能知道自己是否需要无限滚动它没有办法在“无限高度”的父布局里主动测量出一个确定的高度。有两种常见的解法如果网格内容比较少固定高度可以让网格直接占满 Column 的剩余权重空间或者手动给它Modifier.height(300.dp)。如果网格属于页面的一个分块建议把页面整体的结构设计成“外层一个 LazyVerticalGrid内部通过多类型 item spans 实现不同模块”。不要把“外层竖向滚动”和“内层网格”拆成两个 Lazy 组件去套。至于网格 item 内横向套 LazyRow这个相对安全因为横向滚动不影响竖向测量。但如果 LazyRow 外面再包一层横向的固定高度约束还是有可能出现测量问题建议给 LazyRow 的父布局加上一个明确的Modifier.height()避免内部无限宽。4.3 item 状态丢失与滚动位置跳动你说我没写错代码为什么滑出去再滑回来item 里的文字输入框内容就没了绝大多数原因是你没有给 items 设置 key。当列表数据源发生变化Compose 默认用 item 的索引来做身份标识只要数据增删后面所有 item 都会被当成“新 item”状态自然无法保留。正确做法是给 items 设置独立且稳定的 key。比如key { it.id }id 必须是业务上唯一的字段不要用 index。如果数据源是网络返回且没有 id 字段你可以给数据类添加一个本地生成的 UUID反正原则是保证同一个业务对象在数据更新前后 key 不变。另一种情况是数据加载更多后用户明明停在某个位置列表却跳到了顶部。这个问题常见于没有保留滚动状态或者列表数据源被整体替换导致 item 数量突变。我用的是rememberLazyGridState()它能跨重组保存状态但如果数据源是一个mutableStateListOf注意不要在加载更多时把整个 list 重新赋值用addAll这种增量操作能显著减少滚动位置的跳动。4.4 网格的长按拖拽、点击与滚动的手势冲突网格做可拖拽排序时最容易遇到的手势冲突是“拖动和滚动分不清”。我尝试过几种思路最可靠的做法是给需要拖拽的 item 加上combinedClickable在 onLongClick 里开启拖拽模式拖拽是否开始的判定等 touch slop 过了再决定不要在 onLongClick 里立刻改变列表局部状态否则容易造成抖动。如果你用了detectDragGesturesAfterLongPress这样的自定义 Modifier建议配合pointerInput里的awaitEachGesture手动检测。这个方案写起来略烦但控制力最强。简单场景下直接给 item 加一个显眼的“拖拽手柄”图标只在手柄区域响应拖拽能避开大部分手势冲突。5. 进阶LazyVerticalGrid 与同类滚动容器的对比与选型学到一个组件之后还要知道它跟身边兄弟组件之间的边界在哪里。选错容器后期维护会很难受。5.1 LazyColumn、LazyVerticalGrid、StaggeredGrid 三兄弟对比对比维度LazyColumnLazyVerticalGridLazyVerticalStaggeredGrid布局形态单列竖向滚动多列竖向滚动行内列对齐多列竖向滚动列内错落排列适用场景消息列表、设置页、评论列表商品列表、相册缩略图、宫格入口瀑布流、卡片高度差异大的场景跨行跨列不支持GridItemSpan 支持不支持列数控制无Fixed / Adaptive / FixedSize类似 Grid但 item 高度不受行约束懒加载支持支持支持LazyVerticalStaggeredGrid 适合那种卡片高度不完全一致的瀑布流比如小红书首页。但它和 LazyVerticalGrid 的 item 测量机制不同瀑布流每个 item 高度是独立计算的跨列能力反而没有 LazyVerticalGrid 灵活。5.2 什么时候别用 LazyVerticalGrid网格数量很少且固定时不一定非要用 LazyVerticalGrid。比如页面上只有 4 个功能入口用一个Column Row或者 FlowRow 反而更轻量还少了懒加载组件本身的测量开销。LazyVerticalGrid 的优势在高数据量场景下才明显数据少时优化收益不高代码却会多一层复杂度。另外如果你要做“表格式”的网格比如 Excel 那种行列都要固定头部、横向纵纵向都要滚动的表格LazyVerticalGrid 并不适合。它只支持单一方向滚动不支持行列锁定。这种需求还是得用第三方表格库或者自己基于 Canvas 绘制。5.3 横向滚动方案LazyHorizontalGrid 与 LazyVerticalGrid 的对应关系标题说的是“上下滚动”的网格布局但实际开发中也会有横向滚动网格的场景。Compose 里其实还有LazyHorizontalGrid用法和 LazyVerticalGrid 几乎一致只是滚动方向变成了水平方向原来的行变成列。使用感受上从 LazyVerticalGrid 换成 LazyHorizontalGrid 只需要改组件名和 Arrangement 参数代码结构不用大动LazyHorizontalGrid( rows GridCells.Fixed(2), modifier Modifier.height(200.dp) ) { items(20) { index - Text(Item $index) } }这种横向网格适合做“最近浏览记录”“排行榜”之类的横向滑动列表但要注意它的高度必须明确给定否则在 Column 里会塌陷成一个 0.dp 高度的不可见组件。我自己在实际项目里用得最顺手的一个组合是外层竖向列表用 LazyVerticalGrid 做主要内容的网格展示遇到需要横向滚动的分组时在每一个 item 里再嵌套一个固定高度的 LazyRow。这样既能保证整页的竖向滚动无限加载又在局部实现了横向滑动的灵活性。做之前先想清楚数据结构别把页面拆成很多个“整页滚动容器”否则后续手势和状态管理会非常头疼。最后再分享一个我自己特别喜欢的小技巧当列表数据量很大你希望用户能快速回到顶部时除了用animateScrollToItem(0)还可以在网格上加一个“回到顶部”的悬浮按钮按钮的显示和隐藏用gridState.firstVisibleItemIndex 0配合AnimatedVisibility控制。这个小交互在电商 App 里几乎成了标配但实现起来真的只要十几行代码。网格布局本身不复杂复杂的是把各种滚动状态、数据状态、UI 状态在 Compose 的声明式思维里理顺。从一个小 Demo 开始慢慢加需求、加性能优化你会越来越喜欢这种写列表的方式。