news 2026/9/15 15:52:26

KMP跨平台瀑布流实战:基于Compose Multiplatform的LazyVerticalStaggeredGrid

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
KMP跨平台瀑布流实战:基于Compose Multiplatform的LazyVerticalStaggeredGrid

最近在做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. 实现方案对比,为什么最终选了LazyVerticalStaggeredGrid

2.1 LazyVerticalGrid的局限性

Compose很早就有LazyVerticalGrid,很多人一上来就会想,直接用Grid不就行了?我当时第一版也是这么干的,结果跑起来发现根本不是那回事。LazyVerticalGrid的每一行高度是等高的,所有item在同一行被强行切成同样的高度。它虽然支持GridItemSpan来做跨列,但实现不了“左边高右边矮”的交错效果。

打个比方,LazyVerticalGrid像是军训队列,每排必须站齐,个子矮的要么垫脚要么蹲下;瀑布流则像是自由排队,每个人按自己的实际身高找最短的队伍排队。如果你一定要用Grid硬模拟瀑布流,最接近的办法是把所有item都变成一个span占满整行,再在里面自己用Row去排两列,可这样不但放弃了官方懒加载,还得自己管理每列的高度状态,性能很吃亏。

还有一个隐蔽问题:Grid的分页顺序和视觉顺序是固定的,它不会因为你第一列已经很高了,就把下一个item塞到第二列。因此即使用GridItemSpan去跨列,也无法实现自动补位的效果。从结果来看,Grid和瀑布流看似是近亲,实际上从测量模型上就不是一回事。

2.2 自定义Layout与第三方库的取舍

既然是Compose,很多人第一反应就是自己写一个Layout,用measureplace去排列子元素。这确实能实现完全自定义的交错布局,我在早期也写过类似版本,核心逻辑就是维护每列当前高度,逐个测量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-composecoil-network-ktor都是必需的。没有coil-network-ktor,你只能加载本地资源,网络地址会在运行时直接报错。我最初只在commonMain里加了coil-compose,结果Android端能显示图,iOS端一片空白,后来才发现少加了这个网络层依赖。

另外,Android侧的AndroidManifest.xml里要记得申请网络权限:

<uses-permission android:name="android.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: List<WaterfallItem>) { 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 触底加载更多与下拉刷新

瀑布流的页面基本不会只有一屏数据。加载更多的常规做法是往数据末尾追加数据,然后通知列表更新。这里我推荐一个很实用的技巧:用LazyVerticalStaggeredGridstate来监听最后一个可见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: List<WaterfallItem>, 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显示的是其他位置的图,或者图片先闪白再闪图。排查下来核心原因有三个:

  1. item没有设置key,导致复用错位。
  2. AsyncImage加载完成时item尺寸发生变化。
  3. 同一url在不同位置复用,图片缓存命中了但item的背景还没替换。

针对第一个原因,必须设置稳定且唯一的key,比如用id字符串而不是下标,因为下标复用后语义就错了。针对第二个原因,我给所有item都预先算了ratio,并且用aspectRatio固定了比例,图片加载完成后只是像素内容替换,布局尺寸不会变化。针对第三个原因,把Card的containerColor设置成占位灰,图片加载完成后重绘,视觉上自然很多。

还有一个我真正遇到过的诡异现象:快速滑动时图片会短暂显示成上一张图的样子,然后又变成正确的。这其实是AsyncImageTransition或者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 Studio:Ladybug或更高
  • AGP:8.5以上
  • Kotlin:2.1.0左右
  • Compose Multiplatform:1.7.0左右
  • Gradle:8.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。但如果你直接给WaterfallScreenitems参数,Preview会因为拿不到数据而显示空壳。我的做法是给Preview单独一个Composable,直接mock一段数据:

@Preview @Composable fun WaterfallPreview() { WaterfallScreen( items = mockList ) }

这样做对调试布局很有帮助,改完间距、列数、圆角,几秒钟就能看到结果,不需要重启App。

第二个是AnimatedVisibilityanimateItemPlacement。如果你后续要做item增删动画,在LazyVerticalStaggeredGrid里可以用Modifier.animateItem(),但要注意它在交错网格里对跨列的动画支持有限,动画看起来可能会有点生硬。不要把它当成万能药,简单淡入淡出反而更稳妥。

第三个是数据更新的状态管理。KMP里我用的是StateFlow,从ViewModel暴露一个List<WaterfallItem>,在Compose里用collectAsStateWithLifecycle()收集。如果你直接collect一个普通Flow,可能因为没生命周期感知导致状态泄漏。这个在Android原生开发里习惯已经很强了,但到了KMP的commonMain,很多人为了省事直接collectAsState,结果掉进另一个坑。

5. 后续扩展建议与我的经验

瀑布流只是KMP整个体系里的一个组件级功能。做完了这个页面,后面大概率还会遇到点击跳转详情、收藏按钮、分享面板等交互,这些在KMP里都有标准的Compose实现路径,迁移成本不会比Android原生高多少。

根据我自己的经验,第一次做KMP瀑布流,最省力的路线就是直接用LazyVerticalStaggeredGrid+Coil+ 预计算宽高比,先把这三角组合跑通。不要一上来就想着自己封装一个跨平台瀑布流Layout,哪怕你做出来了,性能也未必比官方组件好。真要扩展,优先研究自定义StaggeredGrid里的装饰器、占位动画、以及预加载策略,把这些做深,比重复造轮子有价值得多。

最后分享一个我在这个项目里养成的小习惯:所有和尺寸相关的参数,比如间距、列数、contentPadding,都抽成常量或放在一个WaterfallConfig对象里。这样以后产品要改3列变2列,你只需要改一行配置,而不是满屏找魔法数字。瀑布流本身不复杂,真正复杂的是后续的迭代和维护,把这些前置工作做好,后面会省心很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!