
最近在做KMP项目的瀑布流时踩了不少坑整理成这篇东西希望能让后来的人少走弯路。先说清楚这里的KMP是Kotlin Multiplatform的缩写不是数据结构里那个KMP字符串匹配算法。如果你看到标题第一反应是“求next数组”那说明我们聊的不是一回事你找的是算法题解法我先帮你把偏差纠正过来。瀑布流布局在移动端太常见了小红书、Pinterest、蘑菇街的首页基本都是这个形态。它最核心的特征是多列展示列数固定item宽度一致高度完全自由新item进入时永远排在当前高度最短的那一列下面。这个布局逻辑听起来简单但在KMP场景里落地涉及到的方案选型、依赖管理、图片加载都有一堆值得记下来的点。这篇文章的读者我默认是已经能创建并跑通一个最基础KMP空项目的同学。如果你连KMP工程都还没跑起来建议先花半小时照着官网模板建一个空壳再回来跟这篇实操会顺很多。接下来我会从设计思路、核心方案、代码实现、常见问题几个维度把整个瀑布流实现讲透。1. 先弄清KMP与瀑布流要解决什么1.1 KMP不只是共享业务逻辑UI层也能共享很多开发者对KMP的理解还停留在“共享业务逻辑、各端写自己UI”的旧版本。但如果你已经接触到Compose Multiplatform情况已经完全不一样了。现在的KMP项目业务代码和UI代码都可以放在commonMain里Android端和iOS端共享同一套Compose UI编译器各自生成对应的原生控件。我一开始听到这个概念也很怀疑但实测下来绝大部分界面逻辑真的可以做到两端一致尤其像瀑布流这种强布局、弱交互的页面。这也带来一个新的认知你写的不是一套“给某个平台的UI”而是一套“可编译到多个平台的UI描述”。比如Compose里同一个LazyVerticalStaggeredGrid在Android上映射到RecyclerView体系和对应的布局实现在iOS上映射到SwiftUI或UIKit的包装实现。你不用关心底层映射但你需要理解Compose的布局约束、测量机制这直接决定了瀑布流的性能表现。我在这个项目里体会最深的一点是KMP最值钱的不是少写代码而是少维护一份UI。以前Android和iOS各有一版瀑布流页面交互逻辑经常对不齐产品改一个间距两端要分别排期改代码。现在公共部分合到commonMain里一处修改两端见效。当然这话也不能说得太满真遇到平台强交互的页面还是需要借助expect/actual机制做定制但瀑布流这种内容展示型的页面完全能躺在这种共享红利里。1.2 瀑布流的真实需求拆解瀑布流表面看起来只有“多列”两个字落到真实业务里至少包含这几点列数要能灵活配置。常见的瀑布流都是2列但做电商、图库的内容页3列甚至根据不同屏幕宽度自适应列数都非常常见。每个item高度不能固定。照片的比例千奇百怪你必须提前或动态知道每一项的宽高比。图片加载不能阻塞。瀑布流几乎全是图片卡片几十甚至上百张图片并发加载对ImageLoader和内存缓存的要求很高。滚动时要稳定。如果每加载一张图item高度变动一次整个列表就会跳来跳去。后续基本都躲不掉“下拉刷新”和“触底加载更多”。把这些需求拆清楚再来看实现你会发现技术点其实不复杂复杂的是怎么在Compose的声明式体系里把这些交互都串起来。瀑布流不是单一组件的问题它牵扯到数据模型设计、图片缓存策略、状态管理方式甚至网络请求的分页逻辑。很多初学Compose的人以为只要堆组件就能出来结果写完发现滚动卡顿、图片错位、状态丢失这些坑多数不是因为布局代码写错而是上层设计缺了某一块。2. 实现方案对比为什么最终选了LazyVerticalStaggeredGrid2.1 LazyVerticalGrid的局限性Compose很早就有LazyVerticalGrid很多人一上来就会想直接用Grid不就行了我当时第一版也是这么干的结果跑起来发现根本不是那回事。LazyVerticalGrid的每一行高度是等高的所有item在同一行被强行切成同样的高度。它虽然支持GridItemSpan来做跨列但实现不了“左边高右边矮”的交错效果。打个比方LazyVerticalGrid像是军训队列每排必须站齐个子矮的要么垫脚要么蹲下瀑布流则像是自由排队每个人按自己的实际身高找最短的队伍排队。如果你一定要用Grid硬模拟瀑布流最接近的办法是把所有item都变成一个span占满整行再在里面自己用Row去排两列可这样不但放弃了官方懒加载还得自己管理每列的高度状态性能很吃亏。还有一个隐蔽问题Grid的分页顺序和视觉顺序是固定的它不会因为你第一列已经很高了就把下一个item塞到第二列。因此即使用GridItemSpan去跨列也无法实现自动补位的效果。从结果来看Grid和瀑布流看似是近亲实际上从测量模型上就不是一回事。2.2 自定义Layout与第三方库的取舍既然是Compose很多人第一反应就是自己写一个Layout用measure和place去排列子元素。这确实能实现完全自定义的交错布局我在早期也写过类似版本核心逻辑就是维护每列当前高度逐个测量item并放到最短列。当时写得很爽测试也一切正常但把视野放到KMP多平台后再审视问题就暴露了自己实现Layout意味着要自己处理滚动偏移、item复用和回收这些在LazyVerticalGrid里都是现成的。懒加载列表的自定义Layout实现成本极高你既要管组合计数又要管可视区域裁剪甚至还要处理滚动cache。在KMP的commonMain里写自定义布局你少了Android原生View那一套测量缓存机制纯靠Compose的布局协议去模拟交叉布局性能损耗明摆着。第三方库方面我当时也调研过几个老牌交错网格库比如开源的Android原生StaggeredGridView移植方案还有早期Compose时代社区里的一些非官方组件。结果发现它们大多有几个问题要么不支持KMP只做Android原生要么长时间不更新Compose Multiplatform版本升级后直接编译不过要么过度封装买椟还珠真出问题你可能连源码都看不过来。在当前阶段我的结论非常明确能用官方基础设施完成的就不要自己造轮子。官方组件不仅经过了大量场景测试还会随着Compose版本持续迭代你在上面投入的业务代码才不会在下一次版本升级时变成技术债。2.3 官方交错网格组件参数详解Compose Foundation从1.4版本开始加入了LazyVerticalStaggeredGrid这个组件从名字上就能看出来就是为了交错网格场景设计的。而且关键的是它位于Compose的公共层级里在KMP项目的commonMain里可以直接调用Android和iOS两端通用。基本构造非常简单LazyVerticalStaggeredGrid( columns StaggeredGridCells.Fixed(2), verticalItemSpacing 12.dp, horizontalArrangement Arrangement.spacedBy(12.dp), contentPadding PaddingValues(16.dp), modifier Modifier.fillMaxSize() ) { items(items cardList, key { it.id }) { card - WaterfallCard(card) } }两列用StaggeredGridCells.Fixed(2)三列就改成Fixed(3)。如果想跟随屏幕宽度自动变列数用StaggeredGridCells.Adaptive(minSize 80.dp)系统会根据可用宽度和最小尺寸自动计算列数。我一般建议在窄屏App里直接用Fixed(2)视觉最稳定平板场景才考虑Adaptive。这里有一个很多人都忽略的关键点LazyVerticalStaggeredGrid内部已经实现了官方的交错分配算法。它内部会维护各列当前高度然后把新的item放到最短列。我们不需要自己去算“当前哪列最短”也不用手动维护两个列表的高度状态。这就是官方组件的价值把复杂逻辑收敛掉把业务代码留给开发者。先放一个方案对比方便你做决策实现方案跨平台性懒加载复杂度推荐度LazyVerticalGrid硬模拟好有高不推荐自写Compose Layout好要自己写很高一般第三方老旧瀑布流库差不一定中不推荐LazyVerticalStaggeredGrid好有低强烈推荐3. KMP瀑布流从0到1的完整实操3.1 KMP工程准备与依赖配置我用的工程结构是官方推荐的模板主要模块就两个composeApp放公共代码和资源shared按需拆出来做更底层的逻辑复用。我这个瀑布流Demo业务很轻就没有单独拆shared全部写在composeApp里。创建好之后在composeApp/build.gradle.kts里需要保证下面这组依赖配置是对齐的以近期比较稳的版本组合为例写文章时实测通过plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.androidApplication) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) } kotlin { androidTarget { compilerOptions { jvmTarget.set(JvmTarget.JVM_11) } } listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach { iosTarget - iosTarget.binaries.framework { baseName ComposeApp isStatic true } } sourceSets { commonMain.dependencies { implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material3) implementation(compose.ui) implementation(compose.components.resources) implementation(libs.coil.compose) implementation(libs.coil.network.ktor) } } }这里有一个很容易被忽略的细节瀑布流要加载网络图片coil-compose和coil-network-ktor都是必需的。没有coil-network-ktor你只能加载本地资源网络地址会在运行时直接报错。我最初只在commonMain里加了coil-compose结果Android端能显示图iOS端一片空白后来才发现少加了这个网络层依赖。另外Android侧的AndroidManifest.xml里要记得申请网络权限uses-permission android:nameandroid.permission.INTERNET /别觉得这是老生常谈KMP模板生成的Android工程默认不带网络权限第一次跑网络图片加载失败很多人会去怀疑Coil配置有问题实际就是权限没开。3.2 数据模型图片宽高比决定一切瀑布流能不能稳定很大程度取决于你在数据层就把宽高比算好而不是等图片加载完再临时量尺寸。原因在于Compose在进行布局测量时需要先知道每个item的尺寸。如果一张图片高度初始是0进入布局后图片加载完成突然从0变成200dp列表滚动位置就会被顶走视觉上表现为闪烁、跳动。我用的数据模型非常简单data class WaterfallItem( val id: String, val imageUrl: String, val ratio: Float // width / height )给每个item分配一个ratio比如1.0是正方形0.8表示高度更高1.5表示图片偏宽。这个比例的来源可以有几种后端接口返回、本地预存、或者首次加载时通过Coil读图片尺寸后缓存下来。演示Demo里我就用随机数模拟val mockList List(30) { index - WaterfallItem( id index.toString(), imageUrl https://example.com/image$index.jpg, ratio Random.nextFloat() * 1.2f 0.6f ) }真实项目中一定要推动后端在接口里返回图片宽高或者至少返回宽高比。客户端能提前拿到比例就能提前占据正确尺寸这是图片类列表性能优化的基础。如果后端暂时不配合也可以做一层本地缓存第一次加载原图后把尺寸存下来下次直接复用。3.3 核心UI层代码实现页面主体代码如下这是整个瀑布流页面最核心的部分Composable fun WaterfallScreen(items: ListWaterfallItem) { LazyVerticalStaggeredGrid( columns StaggeredGridCells.Fixed(2), verticalItemSpacing 12.dp, horizontalArrangement Arrangement.spacedBy(12.dp), contentPadding PaddingValues(horizontal 16.dp, vertical 12.dp), modifier Modifier.fillMaxSize() ) { items(items items, key { it.id }) { item - WaterfallCard(item) } } } Composable fun WaterfallCard(item: WaterfallItem) { Card( modifier Modifier.fillMaxWidth(), shape RoundedCornerShape(12.dp), colors CardDefaults.cardColors(containerColor Color.LightGray) ) { AsyncImage( model item.imageUrl, contentDescription null, contentScale ContentScale.Crop, modifier Modifier .fillMaxWidth() .aspectRatio(item.ratio) ) } }key { it.id }这行很多人会忽略。在Grid这种懒加载容器里key的作用是告诉Compose每个item在数据中的唯一身份。没有key时数据更新、滚动复用、执行动画都会把item搞混尤其是图片异步加载的时候可能你滚到第30个item显示的却是第20张图。.aspectRatio(item.ratio)是防抖的关键。有了它在图片真正加载出来之前Compose就已经知道了item的高度占位区域稳定后续图片加载不会引发重排。我见过不少同学在这里忽略掉结果花大量时间调滚动抖动其实问题出在最基础的宽高比上。3.4 触底加载更多与下拉刷新瀑布流的页面基本不会只有一屏数据。加载更多的常规做法是往数据末尾追加数据然后通知列表更新。这里我推荐一个很实用的技巧用LazyVerticalStaggeredGrid的state来监听最后一个可见item。val gridState rememberLazyStaggeredGridState() val shouldLoadMore by remember { derivedStateOf { val lastVisibleItem gridState.layoutInfo.visibleItemsInfo.lastOrNull() val totalCount items.size lastVisibleItem ! null lastVisibleItem.index totalCount - 3 } } LaunchedEffect(shouldLoadMore) { if (shouldLoadMore) { loadMore() } }这样用户滚动到倒数第三项时就会自动触发加载用derivedStateOf包一下避免每次重组都算逻辑。下拉刷新在KMP里推荐直接用Material3的PullToRefreshBox组件如果你的版本还是老API可能会叫PullRefresh区别主要是方法名和一些细节参数。代码如下OptIn(ExperimentalMaterial3Api::class) Composable fun WaterfallScreenWithRefresh(items: ListWaterfallItem, isRefreshing: Boolean, onRefresh: () - Unit) { PullToRefreshBox( isRefreshing isRefreshing, onRefresh onRefresh, modifier Modifier.fillMaxSize() ) { WaterfallScreen(items) } }把这两个交互补齐瀑布流页面就已经具备基本的生产可用性了。3.5 两端运行验证与平台差异处理代码写完很多人的第一反应是在Android上跑一遍然后发现Android没问题就默认iOS也没问题。这个习惯在KMP项目里很危险。我在这个项目中就碰到过Android端用了某张字体资源很正常iOS端却因为命名大小写问题加载不出来。跑iOS端的正规姿势是在Mac上打开工程用Xcode打开生成的iOS工程或者直接用Android Studio里的多平台运行配置选择iosSimulatorArm64作为目标。第一次跑iOS模拟器时Gradle会拉很多Kotlin/Native依赖速度会比较慢属于正常现象。平台差异最常见的坑是图片加载和文件路径。Coil在KMP里跨平台的API已经封装得比较统一但遇到特定网络库适配时仍然可能出现某个平台不生效。排查这种问题我习惯先在commonMain里写一个打印日志的expect/actual函数把实际加载的URL和异常信息打出来再决定是哪一层出了问题。这种“平台差异化调试”的技巧在KMP开发里非常管用。4. 实战中遇到的坑与排查实录4.1 图片闪烁与乱序我在实际测试中最常遇到的现象就是页面快速滚动时某些item显示的是其他位置的图或者图片先闪白再闪图。排查下来核心原因有三个item没有设置key导致复用错位。AsyncImage加载完成时item尺寸发生变化。同一url在不同位置复用图片缓存命中了但item的背景还没替换。针对第一个原因必须设置稳定且唯一的key比如用id字符串而不是下标因为下标复用后语义就错了。针对第二个原因我给所有item都预先算了ratio并且用aspectRatio固定了比例图片加载完成后只是像素内容替换布局尺寸不会变化。针对第三个原因把Card的containerColor设置成占位灰图片加载完成后重绘视觉上自然很多。还有一个我真正遇到过的诡异现象快速滑动时图片会短暂显示成上一张图的样子然后又变成正确的。这其实是AsyncImage的Transition或者Crossfade动画在起作用。图片从内存缓存快速命中时它会先显示旧图再渐变为新图。如果觉得视觉上很怪可以把crossfade关掉或者改成不使用动画直接换图。这个选项没有绝对的好坏完全取决于你的产品调性。4.2 长列表性能调优瀑布流的性能瓶颈通常不在布局而在图片内存。如果我在item里直接写AsyncImage( model item.imageUrl, contentScale ContentScale.Crop, modifier Modifier.fillMaxWidth() )不指定尺寸时Coil会把图片完整解码比如3000x4000的原图占的内存相当可观。30个item同时加载可能内存就爆了。解决办法是给Coil的图片请求加尺寸AsyncImage( model ImageRequest.Builder(LocalPlatformContext.current) .data(item.imageUrl) .size(400, 600) .crossfade(true) .build(), contentScale ContentScale.Crop, modifier Modifier .fillMaxWidth() .aspectRatio(item.ratio) )另一个容易被忽略的点不要把计算逻辑写在items的lambda主体里。items只在创建item时执行但lambda内部的Composable函数体每次重组都会反复执行。如果里面有复杂计算就会拖慢滚动。正确做法是提前在ViewModel或数据层把所有item的尺寸、颜色、标签等计算好UI层只做展示。如果你想更深一层优化可以考虑给AsyncImage使用相同的key并且复用同一个ImageLoader实例。KMP的Coil默认会给每个平台创建全局ImageLoader但在某些场景下手动创建的ImageLoader可以通过缓存策略进一步优化比如内存缓存改成更小的尺寸集合或者磁盘缓存放在特定的目录。4.3 莫名其妙的构建报错说到KMP项目构建报错永远比运行报错更让人头大。我在这个项目初期遇到过“tag number over 30 is not supported”这样看不懂的报错第一反应是搜这段文字结果发现这个错误分散在很多场景里本质往往是工程里的AGP版本、Kotlin版本和Compose版本不一致导致的。后来我把这组版本固定下来基本没有再踩过雷Android StudioLadybug或更高AGP8.5以上Kotlin2.1.0左右Compose Multiplatform1.7.0左右Gradle8.9以上版本组合尽量套用官方模板的libs.versions.toml不要自己随便升级其中一个。尤其是Compose Multiplatform的编译器插件和Kotlin版本是强绑定的你单方面升级Kotlin很可能直接把Compose编译器搞挂。这里列几个容易踩雷的点jvmTarget要一致。Android端和Kotlin/Compose编译的JVM target如果不统一会出现Inconsistent JVM-target compatibility的构建失败。同时开了多个模拟器或设备进行安装时Gradle缓存冲突也会出现各种奇怪错误先执行./gradlew clean再试。iOS端在Windows上没法构建必须在Mac上这个不是错误是平台限制别浪费时间折腾。对Compose Multiplatform的版本升级要格外谨慎最好小步走每升一次编译跑一次两端避免一次性跨大版本导致很多新API变化。4.4 几个容易被忽略的细节除了上面这些主场景问题还有几个我每次做KMP瀑布流都会反复叮嘱自己的小细节。第一个是Preview。Android Studio的Compose Preview现在能直接预览commonMain里的Composable。但如果你直接给WaterfallScreen传items参数Preview会因为拿不到数据而显示空壳。我的做法是给Preview单独一个Composable直接mock一段数据Preview Composable fun WaterfallPreview() { WaterfallScreen( items mockList ) }这样做对调试布局很有帮助改完间距、列数、圆角几秒钟就能看到结果不需要重启App。第二个是AnimatedVisibility和animateItemPlacement。如果你后续要做item增删动画在LazyVerticalStaggeredGrid里可以用Modifier.animateItem()但要注意它在交错网格里对跨列的动画支持有限动画看起来可能会有点生硬。不要把它当成万能药简单淡入淡出反而更稳妥。第三个是数据更新的状态管理。KMP里我用的是StateFlow从ViewModel暴露一个ListWaterfallItem在Compose里用collectAsStateWithLifecycle()收集。如果你直接collect一个普通Flow可能因为没生命周期感知导致状态泄漏。这个在Android原生开发里习惯已经很强了但到了KMP的commonMain很多人为了省事直接collectAsState结果掉进另一个坑。5. 后续扩展建议与我的经验瀑布流只是KMP整个体系里的一个组件级功能。做完了这个页面后面大概率还会遇到点击跳转详情、收藏按钮、分享面板等交互这些在KMP里都有标准的Compose实现路径迁移成本不会比Android原生高多少。根据我自己的经验第一次做KMP瀑布流最省力的路线就是直接用LazyVerticalStaggeredGridCoil 预计算宽高比先把这三角组合跑通。不要一上来就想着自己封装一个跨平台瀑布流Layout哪怕你做出来了性能也未必比官方组件好。真要扩展优先研究自定义StaggeredGrid里的装饰器、占位动画、以及预加载策略把这些做深比重复造轮子有价值得多。最后分享一个我在这个项目里养成的小习惯所有和尺寸相关的参数比如间距、列数、contentPadding都抽成常量或放在一个WaterfallConfig对象里。这样以后产品要改3列变2列你只需要改一行配置而不是满屏找魔法数字。瀑布流本身不复杂真正复杂的是后续的迭代和维护把这些前置工作做好后面会省心很多。