做 AndroidKMP 项目的时候,一旦 UI 层开始共享,瀑布流几乎是躲不掉的场景。我自己的社区类 App 从 Android 单端迁移到 Kotlin Multiplatform + Compose Multiplatform 共享 UI 时,第一个卡住的就是瀑布流——Android 端 RecyclerView 的 StaggeredGridLayoutManager 用得顺风顺水,切到 Compose 之后发现 API 完全不一样,而且当时 StaggeredGrid 还带着 Experimental 标记,网上资料又少,踩坑全靠自己翻源码。这篇文章把我在 AndroidKMP 里实现瀑布流的完整思路和能直接跑的代码整理出来,从官方 LazyVerticalStaggeredGrid 到手写 Layout 方案都会讲到,适合准备把界面层迁到 Compose Multiplatform、或者已经在 KMP 里被瀑布流卡住的团队参考。
1. 先搞清楚:KMP 里的瀑布流和 Android 单端的瀑布流不是一回事
1.1 需求场景没变,但承载方式变了
瀑布流这种布局,说白了就是宽度一致、高度参差的卡片,按"最短列优先"的原则从上往下排,最终形成锯齿状的视觉效果。它在电商、社区、内容分发类 App 里是标配,因为同样面积的屏幕能塞下更多信息,同时每一张卡片都保留了自己内容的完整性。
在 Android 单端时代,这个需求几乎不需要思考——RecyclerView 的StaggeredGridLayoutManager就是为它量身定做的,设置两列、三列,然后正常写 Adapter 就行。但到了 AndroidKMP 的场景里,事情就变了。如果只共享业务逻辑、不共享 UI,那 iOS 端还得用 UICollectionView 的UICollectionViewFlowLayout重写一遍瀑布流,两套代码的交互细节、加载策略、图片缓存逻辑都要各自维护。而如果走 Compose Multiplatform 的共享 UI 路线,瀑布流就必须在 Compose 的世界里重新落地,Android 的经验只能作为参考,不能直接搬运。
我见过不少团队在这里栽跟头:他们以为 KMP 只是把 ViewModel 和网络层抽出来共享,UI 层各端写各端的。结果业务逻辑越共享越多,界面状态也跟着往共享层挪,最后 iOS 端的 UI 代码里塞满了从共享层抄过来的状态映射逻辑,维护成本比不迁移还高。瀑布流这种"看起来很 UI"的需求,实际上和数据结构、图片加载策略、状态保持深度绑定,非常适合作为判断一个团队是否应该上共享 UI 的试金石。
1.2 Compose Multiplatform 里瀑布流官方支持的真实状态
先说结论:现在 Compose Multiplatform 里做瀑布流,最省力的路就是官方LazyVerticalStaggeredGrid,在 Compose 1.5.0 之后这套 API 已经去掉了 Experimental 标记的大部分限制,在 CMP 1.6.x / 1.7.x 的稳定版本上可以直接使用,iOS 端也能保持接近完全一致的行为。
但有一个容易混淆的点:很多人以为LazyVerticalStaggeredGrid和 Android 的StaggeredGridLayoutManager一样,只要设置了列数,它就会自动把每个 item 安排到最短列。这个理解大方向对,但有一个关键机制必须清楚:Compose 的 StaggeredGrid 不负责测量 item 的内部内容,它只负责拿到每个 item 的高度然后做排布。也就是说,item 的高度由你写的内容自行撑开,图片加载完成前后高度变化、文本换行带来的高度变化,都会影响最终布局。这既是它灵活的地方,也是坑最多的地方。
另外,早期很多文章里会提到要用@OptIn(ExperimentalFoundationApi::class)或者@OptIn(ExperimentalLayoutApi::class)才能使用 StaggeredGrid 系列,这个信息现在不完全对了。较新版本里部分 API 已经转正,但不同版本之间表现不一致。最稳妥的做法是:如果你用的 Compose 版本比较老,看到编译报错要求 OptIn,就加上对应注解,不影响使用;如果版本比较新,不加也能过。下面我会具体讲到版本组合。
2. 两种实现路线:官方 StaggeredGrid 和手写 Layout 分别解决什么问题
2.1 LazyVerticalStaggeredGrid 的能力边界
先给结论:绝大多数实际业务场景,用LazyVerticalStaggeredGrid就够了。它有几个关键能力是手写方案很难替代的。
第一是惰性加载。它和LazyColumn一样,只组合当前可见区域的 item,滑出屏幕的组合项会被回收。这个能力对手写 Layout 方案来说是最大的门槛,因为 Compose 的Layout本身不提供任何回收机制,如果你往里面塞几百个 item,性能会直接吃满内存。
第二是滚动状态管理。rememberLazyStaggeredGridState()会帮你维护滚动位置、item 的可见状态,在配置变更或者跨页面返回时恢复到原来的位置。这个看起来理所当然的能力,手写方案里要自己用rememberSaveable配合 scrollState 做,麻烦不少。
第三是跨列 Span。StaggeredGridItemSpan.FullLine可以让你在瀑布流里插入一个横跨所有列的广告位、头条卡片或加载更多占位。这个需求在线上的信息流场景里非常常见,手写方案里实现起来要额外维护跨列 item 的测量逻辑,复杂度翻倍。
但它的边界也很清晰:如果列表里的 item 数量不多(比如几十个)、且你希望瀑布流作为整个页面的一部分嵌入一个更大的Column或自定义滚动容器里,这时候LazyVerticalStaggeredGrid反而显得笨重——嵌套滚动会产生测量冲突、惯性滚动冲突,比手写 Layout 还要难调。
2.2 手写 Layout 的不可替代场景
手写 Layout 的核心优势是轻量和可控。
轻量体现在:它不是惰性组件,不依赖 remember 状态系统,也没有滚动逻辑。你只需要告诉 Compose 怎么测量子元素、怎么摆放子元素,剩下的都交给上层容器。这意味着它可以很自然地嵌在ScrollableColumn、BoxWithConstraints或者任何自定义布局里,不需要担心嵌套滚动冲突。
可控体现在:你可以实现自己想做的任何排列策略。比如某些场景要求第一列和第二列数量严格均衡、某些 item 需要强制放左边、或者要做拖拽重排,这些在官方 StaggeredGrid 里要么不支持,要么需要大量 workaround,但手写 Layout 全都游刃有余。
我实际遇到过一个场景:首页有一个"灵感瀑布流"模块,它只展示 8 个精选内容,外面套着一个可以上下滑动的大LazyColumn(因为页面还有 Banner、分类入口、榜单等模块)。如果在这里再嵌套一个LazyVerticalStaggeredGrid,滑动时经常出现 item 突然不显示或者白屏闪烁的情况,排查下来是嵌套滚动里的测量冲突。后来我直接手写了一个简单 Layout 塞进去,问题彻底消失,而且因为只有 8 个 item,性能完全没问题。
所以这两条路线不是替代关系,而是互补关系。下面我分别展开两条路线的完整实现和坑点。
3. LazyVerticalStaggeredGrid 完整实现:从骨架到细节
3.1 基础骨架与版本组合
先列一套我在生产环境验证过的版本组合,这套组合在 Android 和 iOS 上都能正常跑通 StaggeredGrid,回调行为也基本一致:
| 组件 | 版本 |
|---|---|
| Kotlin | 2.0.21 |
| Compose Multiplatform | 1.7.0 |
| Compose Foundation | 1.7.4(通常跟随 CMP 自动依赖) |
| Coil 3 | 3.0.4 |
| Ktor | 3.0.1(Coil 3 在不同平台需要对应网络引擎) |
依赖添加的要点:在commonMain里加 Coil 的 compose 依赖,在androidMain和iosMain里分别加网络引擎依赖。注意io.coil-kt.coil3:coil-network-okhttp用于 Android,coil-network-ktor3加 Ktor 后还需要在 iOS 侧加ktor-client-darwin引擎。
基础骨架代码长这样:
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.items @Composable fun WaterfallList( data: List<WaterfallItem>, modifier: Modifier = Modifier ) { LazyVerticalStaggeredGrid( columns = StaggeredGridCells.Fixed(2), modifier = modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), verticalItemAlignment = Alignment.Top ) { items( items = data, key = { it.id } ) { item -> WaterfallCard(item) } } }有几个参数值得展开说。
StaggeredGridCells.Fixed(2)是固定两列的写法。还有一个很实用的StaggeredGridCells.Adaptive(minSize = 140.dp),它表示"在给定宽度下,保证每列宽度不小于 minSize,自动计算列数"。这个在平板和手机之间自适应特别好用,但注意它有一个隐蔽特性:同一屏里列数不会因为滚动变化,只有宽度变化时才会重新计算。如果是窗口尺寸需要实时切换(比如视频悬浮窗缩放),你要同时改key强制重组。
verticalItemAlignment这个参数:默认是Alignment.Top,大部分场景用默认值就够了。如果你追求 Pinterest 那种底部对齐的视觉效果,可以改成Alignment.Bottom,但建议只在 item 高度方差比较小的时候用,否则会有一大段空白区,观感反而更差。
3.2 高度不固定的 Item 如何计算:核心中的核心
StaggeredGrid 最大的坑就在这里。官方组件不会替你测量内容高度,它只是把你给的 Composable 组合出来然后测量其固有高度。一旦 item 的高度在加载过程中发生变化,后面的布局全部要重新排,就会出现明显的跳动。
我总结了一套稳定的"卡片高度控制策略",分三档处理:
第一档:纯文本卡片。文本内容天生就是固定宽高,Compose 直接测量就行。但要注意 iOS 和 Android 的字体 metrics 不一样,同样字号、同样行的中文文本,在 iOS 上渲染出来的高度可能比 Android 上多 2~4 像素。如果卡片之间有verticalArrangement的间距,这点偏差肉眼基本无感,但如果你的卡片有固定高度的背景色或者圆角边框,建议在底部预留 2.dp 的 padding,或者用heightIn(min = xx.dp)控制最小高度。
第二档:图片 + 文本卡片。这是信息流最常见的形态。最稳的做法是提前知道图片的宽高比,然后用Modifier.aspectRatio(ratio)把图片区高度固定下来。网络层可以在接口里直接返回图片宽高字段;如果图片来自第三方 CDN 没有宽高字段,就在数据解析时用ImageLoader的预读取能力拿尺寸,或者通过Coil3支持的ImageRequest在拿到图片后先解码尺寸再设置到 UI 状态里。
// 假设接口给到了宽高字段 @Composable fun ImageCard(url: String, width: Int, height: Int) { val ratio = remember(width, height) { if (height == 0) 1f else width.toFloat() / height.toFloat() } AsyncImage( model = url, contentDescription = null, contentScale = ContentScale.FillWidth, modifier = Modifier .fillMaxWidth() .aspectRatio(ratio) ) }第三档:纯图片且宽高未知。这种情况最棘手。我的方案是快速用SubcomposeAsyncImage(Coil 3 里叫SubcomposeAsyncImage依然可用)加一个占位高度,图片加载完成后再切换到真实比例。切换时因为测量高度变了,StaggeredGrid 里的后续 item 位置会全部重算,视觉上会产生一次跳动。为了减少跳动幅度,可以给占位高度设一个和图片区域最接近的值,比如在内容流里,缩略图多集中在 0.8~1.2 的比例区间,那占位高度就按 1.0 处理。
如果你的场景对跳动零容忍,还有一个"土办法":在列表加载前,先统一从网络拉取所有图片的尺寸,存到一个Map<url, Ratio>里,然后等这笔数据到位后再渲染列表。这个方案适合图片总数少、可控的场景。图片多了以后预取开销太大,不推荐。
3.3 图片加载与占位策略
Coil 3 已经成为 KMP 场景下的事实标准,它解决了 Compose Multiplatform 没有统一图片加载库的问题。在 AndroidKMP 的 commonMain 里写AsyncImage,Android 端用 OkHttp 网络栈,iOS 端用 Ktor Darwin 引擎,两端的行为差异被框架抹平了。
但占位策略需要你自己处理。这里我给一个我常用的卡片模板,你在自己的WaterfallCard里可以照抄:
@Composable fun WaterfallCard(item: WaterfallItem) { SubcomposeAsyncImage( model = ImageRequest.Builder(LocalPlatformContext.current) .data(item.imageUrl) .crossfade(true) .build(), contentDescription = item.title, contentScale = ContentScale.Crop, modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) .aspectRatio(item.ratio), loading = { Box( modifier = Modifier .fillMaxSize() .background(Color(0xFFF0F0F0)) ) }, error = { Box( modifier = Modifier .fillMaxSize() .background(Color(0xFFE0E0E0)) ) } ) Text( text = item.title, modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis ) }注意LocalPlatformContext.current这个 API。Coil 3 里为了让 common 代码能拿到图片加载的上下文,引入了这个跨平台 context。实际使用中如果你忘了传它,在 Android 上可能默认能跑,但在 iOS 上会直接崩或者图片完全不加载,这是 KMP 迁移时最常见的一个坑。
关于ContentScale.Crop和ContentScale.FillWidth的选择:如果你用的是aspectRatio固定了图片区域,用Crop可以保证图片填满,但会裁掉部分内容;用FillWidth不会裁切但可能会在高度方向留白。信息流卡片一般用Crop更美观,但要注意Crop和aspectRatio同时用时,如果 ratio 和图片实际比例差距过大,视觉上会明显失真。建议裁剪前先判断 ratio 在合理范围,偏离超过 30% 就降级成Fit或者换一个占位封面。
3.4 滚动状态保持与列表更新细节
瀑布流的滚动状态保持,在 KMP 里比 Android 单端要更谨慎。Android 端有 ViewModel 和Parcelable体系帮忙,跨配置变更恢复状态是系统级的。CMP 里用的是 Compose 的rememberSaveable和RememberSaveableStateHolder,配合LazyStaggeredGridState。
我踩过的坑是:在 CMP 里直接rememberLazyStaggeredGridState()拿到 state 之后,如果页面因为数据刷新发生了 item 的删除或插入,滚动位置会偶尔跳到顶部。原因是 StaggeredGrid 恢复位置依赖 item 的 key,如果你的key = { it.id }对应的 id 在刷新前后不稳定(比如从网络接口返回的 id 顺序变了),框架就找不到对应的 item,只能回退到起始位置。
解决方案:一是确保key的稳定性和唯一性;二是如果数据刷新前后 item 大部分相同,用 DiffUtil 或者 Compose 的snapshotFlow做增量更新,不要让整个列表整体重建。还有一个偏门但有效的方法:在刷新前记录state.firstVisibleItemIndex和state.firstVisibleItemScrollOffset,刷新后手动scrollToItem恢复。这个方法虽然笨,但对一些无法从根上保证 key 稳定的第三方数据源非常有效。
val gridState = rememberLazyStaggeredGridState() LaunchedEffect(dataList) { val index = gridState.firstVisibleItemIndex val offset = gridState.firstVisibleItemScrollOffset // 等新列表组合完成后再恢复位置 withFrameNanos { gridState.scrollToItem(index, offset) } }但withFrameNanos里直接恢复位置还是容易闪一下。更优雅的方式是把数据刷新本身放在snapshot里做,并在刷新前后保持 item 的 identity 稳定。如果你的刷新是通过state { }管理的,尽量让dataList在内存中复用同一个 List 对象,只在局部变更时触发更新。
4. 手写瀑布流 Layout:轻量方案的完整代码与调优
4.1 核心测量与布局逻辑拆解
有些场景不适合用 LazyVerticalStaggeredGrid,比如前面提到的"页内模块"。这种时候自己写一个Layout反而是最优解。Compose 的Layout非常直白:测量所有子项,计算它们的位置,然后在一张画布上绘制。
我们先明确瀑布流的排布规则:每个子项宽度固定为 列宽;每来一个新子项,找到当前高度最小的列,放在那列底部。这就是"最短列优先"。
@Composable fun WaterfallLayout( modifier: Modifier = Modifier, columns: Int = 2, horizontalSpacing: Dp = 12.dp, verticalSpacing: Dp = 12.dp, content: @Composable () -> Unit ) { Layout( modifier = modifier, content = content ) { measurables, constraints -> val spacingX = horizontalSpacing.roundToPx() val spacingY = verticalSpacing.roundToPx() val columnWidth = (constraints.maxWidth - spacingX * (columns - 1)) / columns val itemConstraints = constraints.copy(minWidth = columnWidth, maxWidth = columnWidth) val placeables = measurables.map { measurable -> measurable.measure(itemConstraints) } val columnHeights = IntArray(columns) val columnIndexes = IntArray(placeables.size) placeables.forEachIndexed { index, placeable -> var minIndex = 0 for (col in 1 until columns) { if (columnHeights[col] < columnHeights[minIndex]) { minIndex = col } } columnIndexes[index] = minIndex columnHeights[minIndex] += placeable.height + spacingY } val totalHeight = (columnHeights.maxOrNull() ?: 0) - spacingY layout(constraints.maxWidth, totalHeight.coerceAtLeast(0)) { val columnOffsets = IntArray(columns) placeables.forEachIndexed { index, placeable -> val col = columnIndexes[index] val x = col * (columnWidth + spacingX) val y = columnOffsets[col] placeable.placeRelative(x, y) columnOffsets[col] = y + placeable.height + spacingY } } } }这段代码里有一个容易被忽略的细节:columnHeights数组里每次加高度时都加上了spacingY,所以最后计算totalHeight时要减掉一个spacingY,否则底部会多出一截空间距。同理,放置每个 item 时的y是从 0 开始,所以 item 间距只出现在 item 之间,底部不会出现悬挂的空白。
4.2 最短列算法与间距处理
最短列算法本身不复杂,但有几个细节值得注意。
细节一:列高相等时选哪列。上面的实现里columnHeights[col] < columnHeights[minIndex]用的是严格小于,所以当两列高度相等时,会一直选择第一列(minIndex 初始为 0 且只在更矮时才更新)。这个行为在视觉上会让左边一列普遍偏高,偏"满",右边偏"空"。如果你希望均匀分布,可以在相等时按轮询或者按 index 取模来交替选列。但实际体验下来,严格小于的写法在内容长度随机性足够的情况下,两列高度差不会特别离谱,而且实现最简洁。
细节二:item 的测量约束。注意我把itemConstraints设成了minWidth = columnWidth, maxWidth = columnWidth。如果不限制minWidth,某些子内容宽度不足时会在列内左对齐,视觉上出现参差不齐的缩进。强制固定宽度可以让卡片内容宽度统一,后续如果子内容要自己控制内边距,也方便。
细节三:placeRelative和place的区别。在多语言或者 RTL 场景下,place会根据LayoutDirection自动镜像坐标,placeRelative则不会(它会按原始方向放置)。瀑布流一般用place更合适,因为 RTL 布局时列顺序也应从右往左。但我这里为了可读性用了placeRelative,在实际项目中建议改成place。
4.3 与 LazyList 结合时的性能与嵌套坑
手写 Layout 不适合直接塞进LazyColumn的 item 里然后让整个大列表滚动,那样一旦模块内部 item 数量超过 30 个,滑出屏幕的组合项依然会保留在组合树里,内存和绘制开销都会持续累积。
我的经验是给"手写 Layout + 瀑布流模块"设定一个硬边界:模块内部 item 数量超过 20 个,就有被 LazyColumn 嵌套性能问题反噬的风险。如果内容确实多,就回到官方LazyVerticalStaggeredGrid,然后想办法解决嵌套滚动冲突。
嵌套滚动冲突的典型表现是:外层 LazyColumn 滑动时,内层 StaggeredGrid 会拦截事件,导致滑动卡顿或者只能滚动内层、外层永远滚不动。解法有两种:
- 给内层
LazyVerticalStaggeredGrid加Modifier.nestedScroll(connection),在onPreScroll里优先消费垂直方向的滚动,让内层高度刚好等于内容高度时把事件还给外层。这个方案写起来容易,但状态同步逻辑很脆弱,个人不推荐在线上用。 - 把页面结构整体改成"单个 LazyColumn,通过
item把各种模块(Banner、入口、瀑布流)都作为惰性列表的一项"。瀑布流模块内部如果不需要懒加载,就可以用手写 Layout 直接嵌进去,外层滚动回收的是"整个模块"而不是模块里的单个 item。这种方案逻辑最简单,性能也可控,也是我最终采用的方案。
如果你真的需要在 LazyColumn 里嵌套 LazyVerticalStaggeredGrid,我唯一的建议是:给内层设置固定的高度(比如height(600.dp)),让内层自己滚动。但这在手机上很反直觉,一般来说说明你的页面结构设计出问题了。
5. 从 Android 到 iOS 的跨平台避坑清单
5.1 渲染差异:同样的代码,不同的高度
Compose Multiplatform 在 iOS 上的渲染后端和 Android 不一样,Android 用的是 Android 系统自带的 Skia 加硬件加速管线,iOS 上则是 Skiko(基于 Skia 的 Kotlin 封装)。大多数情况下行为一致,但字体渲染是最容易露出差异的地方。
我在 iOS 上测试时经常发现:同样字号MaterialTheme.typography.bodyMedium,中文文本在 iOS 上比 Android 高 2~3 dp。而在瀑布流里,相邻 item 的高度会互相影响排布,所以一个 item 在 iOS 上比 Android 高一点,可能导致整个瀑布流的图片位置和 Android 上完全不同。
这里有两个处理经验:
- 卡片内文本区域不要设置固定高度,让它自由撑开。瀑布流天然就是"不同 item 高度不同"的布局,文案长短差异本来就大,不需要为几个像素的字体差异焦虑。
- 但如果卡片设计了统一高度的底部信息区(比如作者、头像、互动按钮),那在 iOS 上一定要用
heightIn(min = xx.dp)而不是height(xx.dp)。这样即使字体高一点,也只是信息区内的内容向下挤,不会撑破卡片整体结构。
5.2 网络图片加载的两端配置
Coil 3 在 KMP 里虽然统一了 API,但平台网络栈的配置不能少。Android 端默认依赖 OkHttp,iOS 端要额外加 Ktor Darwin 引擎,并且在 AndroidManifest 和 iOS 的 Info.plist 里都要做对应配置。
Android 端的网络权限是必须的:
<uses-permission android:name="android.permission.INTERNET" />iOS 端如果图片地址是 HTTPS 且证书正规,ATS 不会拦。但开发阶段如果用了 HTTP 的本地服务,记得在Info.plist里配置 ATS 例外,否则图片会静默加载失败,而且控制台里的报错往往被 Compose 吞掉,很难排查。
Coil 3 的另一个坑是:commonMain 里的ImageRequest需要传LocalPlatformContext.current,如果不传,在 iOS 上可能崩。我在两个不同版本的 CMP 项目里都踩过,表现不一致:一个直接白屏,一个到点击图片时才崩溃。排查到后面才发现是这个 context 没有传对。所以代码模板里一定养成顺手带上这个参数的习惯。
5.3 屏幕宽度与列数自适应
手机和平板共用一套代码时,列数应该跟着宽度走。推荐用StaggeredGridCells.Adaptive,它会根据minSize自动算列数。但要注意一个细节:Adaptive 模式的列数变化会触发 item 重新测量,如果你用的是rememberLazyStaggeredGridState,返回之前的滚动位置时可能找不到对应的 item key,导致列表头回到顶部。
我的处理方式是给整个列表组件加一个基于列数的key:
@Composable fun AdaptiveWaterfall(data: List<WaterfallItem>) { val gridState = rememberLazyStaggeredGridState() val adaptiveKey = remember { "adaptive" } LazyVerticalStaggeredGrid( columns = StaggeredGridCells.Adaptive(minSize = 150.dp), state = gridState, modifier = Modifier.key(adaptiveKey) .fillMaxSize() ) { items(data, key = { it.id }) { item -> WaterfallCard(item) } } }用Modifier.key(adaptiveKey)其实不能强制改变列数后的重组策略,真正稳妥的方式是检测到列数变化后,重置gridState的滚动偏移。不过大多数线上场景里,用户不会在滑动过程中突然翻转屏幕或者把窗口缩放到跨过一个临界宽度,所以这个问题出现的概率不高。但如果你的 App 支持桌面端窗口自由缩放,一定要处理。
6. 写在最后的几个实践建议
瀑布流在 KMP 里做了两轮迭代之后,我最大的感受是:能用官方LazyVerticalStaggeredGrid就不要手写,除非你明确知道官方组件解决不了你的布局需求。官方的惰性加载、滚动状态、跨列 span,每一个都是经过大规模验证的能力,手写要付出的维护成本远比看起来高。
如果你正在评估自己的项目选哪条路,我建议先看两个条件:一是 item 数量是否可能超过 50,二是是否需要插入整行广告等跨列模块。任意一个为"是",就选官方 StaggeredGrid;两个都为"否",且瀑布流只是页面里的一个小模块,再考虑手写 Layout。这个决策逻辑我到现在都在用,也帮团队避过几次开发延期。
最后分享一个小经验:在 KMP 共享 UI 的早期阶段,不要急着把所有页面都迁到 Compose Multiplatform,先把瀑布流这种"数据驱动、依赖图片加载、跨端差异明显"的模块抽出来做试点。它能把 CMP 在状态管理、图片库、平台适配上的所有坑都暴露一遍,等这些坑填平了,再推其他页面,整体推进反而更快。