KMP跨平台瀑布流实战:基于Compose Multiplatform实现与优化
2026/9/15 13:36:32 网站建设 项目流程

最近在搞一个信息流社区的跨端改造,需求很简单:首页要做成瀑布流,卡片高度参差不齐,两列错落显示,Android 和 iOS 两端视觉和交互必须一致。既然团队已经定了 Kotlin Multiplatform(KMP)技术栈,这个瀑布流就必须在 KMP 里直接落地,不能各端写一套。折腾下来最大的感受是:KMP 比想象中的成熟,但坑也比文档里写的多。这篇就把我从工程搭建到两列瀑布流跑通、再到性能优化的完整过程写出来,给想用 AndroidKMP 做瀑布流的朋友一条可以直接照做的路线。

先说清楚一个容易误会的事:这里说的 KMP 是 Kotlin Multiplatform,不是字符串匹配那个 KMP 算法。很多人一搜“KMP 瀑布流”会搜到算法题,其实在移动开发场景里,KMP 就是指一套代码逻辑跑多个平台。这篇内容围绕 Compose Multiplatform 的跨平台 UI 能力展开,涉及工程结构、依赖选型、瀑布流布局实现、图片加载、分页刷新、多平台适配和性能排查,适合正在评估 KMP 或已经入坑 KMP 的 Android 开发者。

1. 技术选型思考:为什么瀑布流会选择 KMP 来做

1.1 KMP 在现在的跨平台生态里是什么位置

KMP 不是一个新的跨平台 UI 框架,它最开始只做业务逻辑共享,UI 层各端原生写。真正的转折点是 JetBrains 把 Compose Multiplatform 推成熟之后,一套 Compose 代码可以跑到 Android、iOS、桌面和 Web,这才有了“一套代码同时覆盖 UI 和逻辑”的完整闭环。

到 2024 年之后,Compose Multiplatform 对 iOS 的支持已经从实验状态走向稳定,主流版本已经能在 iPad 上跑起来。我实际测试下来,常见布局组件、列表、手势、动画这些基础能力都已经覆盖,性能在真机上也能接受。瀑布流这种需求恰好是 Compose 布局模型的强项,因为 LazyVerticalStaggeredGrid 这类组件就是为不规则列表设计的。

有一点必须强调:KMP 适合的是团队里本来就有 Kotlin 基础、且 Android 和 iOS 两边都想要一致体验的场景。如果团队只熟悉 Swift 和 Objective-C,KMP 的学习成本会比较高,这时候强行上 KMP 反而不是最优解。但从我的实践看,只要跨端 UI 需求确实存在,KMP 的长期收益非常明显,一份布局代码不用维护两遍。

1.2 瀑布流需求对跨平台方案的“隐性要求”

瀑布流看起来简单,但真正做起来比普通列表复杂得多。它的核心特征是每张卡片高度不固定,两列或多列之间高度差始终存在,因此对布局系统、滚动性能、状态恢复、图片异步加载都有硬性要求。

跨平台方案要做到真瀑布流,首先要解决的是渲染一致性问题。WebView 方案最容易踩坑:Android 和 iOS 的 WebView 内核不同,滚动回弹效果、字体渲染、图片缓存策略全都不一样,最终交付时 QA 总会提“两端视觉有偏差”。而 Compose 是自绘 UI,底层自己控制布局和绘制,天然能保证跨端一致性。

其次是长列表性能。瀑布流通常承载大量卡片,如果每个卡片都走独立布局节点,滚动时会把 UI 线程打爆。Compose 的 Lazy 系列组件支持按需组合和回收复用,写对了逻辑才能保证流畅滚动。这也是我坚持用原生 Compose 组件而不是自己写 ScrollView 堆子项的原因。

第三是图片。瀑布流卡片绝大多数都带图,图片尺寸、加载策略、缓存策略直接决定用户体验。KMP 生态里已经有可用的跨平台图片库,选型得当的话,一套逻辑两端复用,不需要各端单独维护图片框架。

1.3 为什么我没有选 Flutter 或 React Native

我不是说 Flutter 或 React Native 不好,而是在当前这个项目里,KMP 更合适。Flutter 的 Dart 语言和现有 Android 团队的技术栈不一致,引入成本大;React Native 的桥接层在复杂联动场景下容易出性能瓶颈,而且对于高度自定义的列表布局,还是得落到原生实现。

KMP 最大的优势是“渐进式迁移”:现有 Android 工程的 Kotlin 代码能直接复用,Compose 代码又能同时编译到 iOS。如果团队里已经有 Android 的 Compose 开发经验,那切到 KMP 的适应成本就非常低。从公司角度看,用 KMP 做跨端方案,意味着服务端 SDK、数据层、导航逻辑都能用一套代码维护,长期人效提升明显。

2. 环境与工程准备:先把 KMP 项目搭起来

2.1 开发环境配置:Android Studio、JDK 与 Kotlin 版本

开始之前,先把开发环境捋一遍。我用的是 Android Studio 的最新稳定版,配合 JDK 17,Kotlin 版本当前稳定版是 2.x,Compose Multiplatform 插件版本需要和 Kotlin 版本严格对齐,否则编译期会出现各种稀奇古怪的错误。

说个和热搜词相关的点,很多人搜索“android studio 怎么设置中文”“android studio 汉化”,如果你是初学者,汉化没问题,但看 KMP 报错信息时建议切回英文,因为网上绝大多数解决方案都是针对英文报错的,用中文界面去搜英文错误信息会多绕一圈。

具体版本建议在工程里用版本目录(Version Catalog)统一管理。我当时的版本组合是:

组件版本
Kotlin2.0.21
Compose Multiplatform1.6.10
Android Gradle Plugin8.5.2
Gradle8.7

这套组合在当时跑了几个示例项目都稳定,如果你的版本更新,以官方文档为准。有个小技巧:新建 KMP 工程时,直接用 JetBrains 的 KMP 项目模板向导生成基础结构,比手动改 Gradle 配置要省事很多。

2.2 KMP 工程结构:共享模块怎么划分

KMP 的标准工程一般包含 composeApp 和 iosApp 两个顶层模块。composeApp 里的 src/commonMain 装着共享的 UI 和业务逻辑代码,src/androidMain 放 Android 平台相关实现,src/iosMain 放 iOS 平台相关实现。

首次接触 KMP 的人容易犯一个错:把所有代码都塞进 commonMain,包括平台相关的 API 调用。实际上,像状态栏高度、安全区、系统字体这类东西,各平台行为不一样,必须在 expect/actual 机制里分别处理。我这次做瀑布流就把平台差异集中封装在了一个 Platform 文件里,UI 层尽量不感知平台差异。

依赖管理方面,推荐用 Gradle Version Catalog 统一维护版本号,避免各模块版本飘移。尤其是 Compose 相关的依赖特别多,BOM 不一致会导致编译错误,统一管理后整个工程清爽很多。

2.3 依赖引入:Compose Multiplatform 的库怎么选

瀑布流需要的基础依赖包括 Compose UI、Foundation、Material3、ViewModel 和图片加载库。我的 build.gradle.kts 核心依赖是这样的:

kotlin { sourceSets { commonMain.dependencies { implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material3) implementation(compose.ui) implementation(compose.components.resources) implementation(libs.kamel.image) } } }

这里的 Kamel 是跨平台图片加载库,支持 KMP 的 commonMain 直接调用。当然你也可以用 Coil 3,它对 Compose Multiplatform 的支持也很成熟。我选 Kamel 是因为它轻量、API 简洁,而且网络层可以直接对接 Ktor Client,少一层适配。具体对比我后面会在图片加载章节展开。

有一点要注意:Material3 是 Material Design 3 的组件库,瀑布流卡片里的按钮、卡片容器、进度条直接用 Material3 就行,不需要额外引入 Material2,两套混用容易出现主题风格不统一的问题。

3. 核心实现:两列瀑布流从 0 到 1

3.1 数据模型与差异化卡片设计

瀑布流要做出错落感,卡片高度必须有差异。我这里模拟的是一个社区分享 Feed,每条数据包含用户名、头像、标题、配图 URL 和点赞数。数据模型定义在 commonMain:

data class FeedItem( val id: String, val author: String, val avatarUrl: String, val title: String, val imageUrl: String, val imageHeight: Int, val likeCount: Int )

关键在设计 imageHeight 字段。瀑布流的“参差感”主要靠图片高度制造,但真实项目里服务端可能不返回图片的宽高比,这时就需要客户端在拿到图片后主动解析,或者默认给一个宽高比再让布局动态调整。

我在 demo 里采用了一个简单方案:图片高度按固定宽度比例计算,两张相邻卡片的高度差控制在 40dp 到 200dp 之间,保证视觉效果自然、又不至于让某一列空出太大缝隙。真实项目中如果要精确控制,可以在图片加载完成后把真实尺寸回调给 ViewModel,再触发一次重组更新高度,这样能获得最真实的瀑布流错落效果。

3.2 核心代码:LazyVerticalStaggeredGrid 的落地细节

Compose Multiplatform 的 Foundation 库提供了 LazyVerticalStaggeredGrid,这是实现瀑布流最关键的基础组件。它和 LazyVerticalGrid 的区别是:Grid 的每一项都是等高的网格单元,StaggeredGrid 则允许每一项高度不同,这正是瀑布流需要的。

完整代码我贴在下面,这段代码可以直接运行验证效果:

@Composable fun WaterfallFeed( items: List<FeedItem>, modifier: Modifier = Modifier ) { LazyVerticalStaggeredGrid( columns = StaggeredGridCells.Fixed(2), modifier = modifier, contentPadding = PaddingValues(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalItemArrangement = Arrangement.spacedBy(12.dp) ) { items(items, key = { it.id }) { item -> FeedCard(item) } } }

几个需要重点解释的参数:

StaggeredGridCells.Fixed(2) 表示固定两列,你也可以用 Adaptive(minSize = 150.dp) 让系统根据屏幕宽度自适应列数,在平板和横屏场景下用这个更合理。

key 参数必须传唯一 id。如果不传 key,列表滚动时会按位置复用 Composable,一旦数据顺序变化或者删除某条,会出现卡片内容错乱的问题。传 key 之后,Compose 能精确追踪每个 item 的状态,保证复用正确。

还有一个细节:horizontalArrangement 控制列间距,verticalItemArrangement 控制行间距。如果这两个间距不一致,瀑布流的缝隙会显得很乱,建议两端都保持一致。

3.3 卡片布局:标题、图片、作者信息这样摆

卡片是瀑布流的最小单元。我实现的 FeedCard 包含图片、标题、作者头像和点赞数。整体结构用 Column 垂直排列,图片在上方,标题在中间,作者信息在底部。

@Composable fun FeedCard(item: FeedItem) { Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) ) { Column { AsyncImage( model = item.imageUrl, contentDescription = item.title, modifier = Modifier .fillMaxWidth() .height(item.imageHeight.dp) ) Text( text = item.title, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), maxLines = 2, overflow = TextOverflow.Ellipsis ) Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text(item.author, style = MaterialTheme.typography.bodySmall) Text(item.likeCount.toString(), style = MaterialTheme.typography.bodySmall) } } } }

这里 Card 自带 Material 主题样式,圆角和阴影效果都是默认的,不需要额外画背景。AsyncImage 来自图片加载库,传入网络 URL 就能加载。标题加 maxLines 和 overflow,是为了防止不同长度的标题把卡片撑得太高或超出边界,实测下来这个组合对长文本特别有效。

有一点必须提醒:图片的宽度和高度不要写死。瀑布流里卡片的宽度是由列数决定的,如果写死宽度,到了大屏设备就会出问题。正确做法是图片宽 fillMaxWidth,高度由数据里的 imageHeight 控制,这样在任何屏幕宽度下都能自适应。

3.4 图片加载:Kamel 还是 Coil 3,我的选择思路

KMP 生态里跨平台图片库主要有两个选择:Kamel 和 Coil 3。Kamel 由 JetBrains 社区维护,专门为 Compose Multiplatform 设计,API 风格和 Compose 非常契合。Coil 3 则是 Android 上 Coil 的跨平台版本,官方支持 Kotlin Multiplatform,潜力很大。

我的选择是 Kamel。理由包括:

  • Kamel 直接支持从 Ktor 引擎加载图片,网络层可以跟项目里已有的 Ktor Client 通用;
  • 它自带内存缓存和磁盘缓存,不需要额外配置;
  • 配置项简单,核心代码几行就能跑起来。

当然 Coil 3 也值得关注,尤其是你未来可能要用到更复杂的 SVG、GIF 支持时。Coil 的生态更丰富,文档更全,但在纯 KMP 项目里,Kamel 的上手成本更低。

Kamel 的加载用法很简单:

AsyncImage( model = item.imageUrl, contentDescription = item.title, modifier = Modifier .fillMaxWidth() .height(item.imageHeight.dp) )

工程里记得在 Ktor 引擎配置好 HTTP 客户端。Android 端默认用 OkHttp 引擎,iOS 端用 Darwin 引擎,这些在 KMP 模板里通常已经配置好了,只需要确认三个平台的网络权限都给了。

4. 交互补全:下拉刷新、加载更多与点击事件

4.1 下拉刷新:PullRefresh 在不同平台的行为差异

瀑布流基本都要配下拉刷新。Compose Material3 提供了 PullToRefreshBox 组件,在 commonMain 里直接用就行。

@OptIn(ExperimentalMaterial3Api::class) @Composable fun RefreshableWaterfallFeed( items: List<FeedItem>, isRefreshing: Boolean, onRefresh: () -> Unit, modifier: Modifier = Modifier ) { PullToRefreshBox( isRefreshing = isRefreshing, onRefresh = onRefresh, modifier = modifier ) { WaterfallFeed(items) } }

一个容易忽略的点:iOS 上默认没有下拉刷新的交互范式,用户可能习惯从顶部下拉触发。PullToRefreshBox 在 iOS 上也能正常响应,但视觉风格和原生 iOS 的 UIRefreshControl 不完全一致。如果产品对平台原生体验要求高,可以考虑在 iOS 端用 expect/actual 接入原生刷新控件。

Android 端的刷新指示器默认是 Material 风格的圆形进度,颜色会跟随主题色。如果你的品牌色比较特别,可以传 Indicator 参数做自定义。这个组件在 Material3 中还是实验性 API,使用时记得加 @OptIn 注解。

4.2 上拉加载更多:滚动到底部的监听与分页状态

加载更多是分页列表的核心。我的方案是监听 LazyVerticalStaggeredGrid 的滚动状态,当最后一个可见项接近数据末尾时自动触发加载下一页。

实现要点:

val listState = rememberLazyStaggeredGridState() LaunchedEffect(listState) { snapshotFlow { val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index val totalCount = listState.layoutInfo.totalItemsCount lastVisibleItem to totalCount } .distinctUntilChanged() .collect { (lastVisible, total) -> if (total > 0 && lastVisible >= total - 5) { onLoadMore() } } }

这里设置了一个“离底部还剩 5 个 item 时预加载”的阈值。这种预加载策略能保证用户在划到页面底部之前,新数据已经准备好,体验上几乎没有等待感。如果等用户划到底部才触发加载,分页状态下很容易看到空白或闪烁。

分页状态我放在了 ViewModel 里维护:加载中、加载成功、没有更多数据、加载失败四种状态。UI 层根据状态在底部显示加载中按钮、没有更多数据的提示条、或错误重试入口。

4.3 点击事件:卡片复用时的状态保留问题

卡片点击跳转详情是常规操作,但瀑布流这种复用组件,如果不加 key 处理,点击事件很容易拿到错误的 item。我的做法是给每个卡片绑定稳定的 item.id,并把点击回调放在 Card 的 onClick 参数里。

Card( onClick = { onItemClick(item) }, ... )

值得注意的一点是,如果卡片内部还有“点赞”这种需要本地状态的操作,点赞状态最好不要只保存在 Composition 内部的 remember 里。因为列表滚动后 item 可能被回收,状态会丢失。正确做法是把点赞状态提升到 ViewModel,通过状态管理统一驱动。

5. 多平台适配:Android、iOS 与鸿蒙的差异点

5.1 Android 与 iOS 的系统差异处理

KMP 虽然共享代码,但平台差异是无法完全屏蔽的。我这次遇到最多的是安全区问题:iPhone 的刘海屏底部有一条 home indicator,内容如果不避开就会被手势条遮挡。Android 的全面屏也有类似问题,但处理方式不一样。

我的处理思路是封装了一个 expect/actual 的 PlatformInsets 对象:

// commonMain expect fun getBottomInset(): Dp // androidMain actual fun getBottomInset(): Dp { return WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() } // iosMain actual fun getBottomInset(): Dp { // 通过安全区域获取底部 inset }

然后把瀑布流的内容 padding 与系统 inset 合并。这样两端展示出来的内容都不会被手势区域遮住。

还有一个容易被忽略的差异:触摸反馈。Material 默认的点击水波纹在 Android 上有原生效果,在 iOS 上表现不太一样。如果产品对两端一致性要求高,建议统一自定义点击反馈效果,不要让视觉在这种细节上出现差异。

5.2 鸿蒙适配的前置准备

很多人在搜“kmp 鸿蒙适配”,说明 KMP 社区也关注鸿蒙生态。如果未来要让同一套代码跑到鸿蒙上,有几个点需要提前注意:

  • 鸿蒙 NEXT 引入了自己的 UI 框架,同时它对 OpenHarmony 的兼容性越来越被重视。JetBrains 和社区一直在推进 Kotlin/Native 对鸿蒙设备的支持,但目前主流 KMP 工程还不能直接在鸿蒙上“一键运行”。
  • 如果只是轻量级数据层共享,鸿蒙侧用 Kotlin 调用 shared 模块是可行的;但共享 UI 层暂时没有官方稳定方案。
  • 选择依赖时,尽量选抽象良好的库,避免依赖特定平台的系统服务。

从实操角度,现阶段可以先保证代码里不写死 Android API,所有平台相关逻辑都收敛到 expect/actual 里。这样未来鸿蒙适配时,只需要增加一个鸿蒙的 actual 实现,主体逻辑不用动。

5.3 与原生代码互通的边界

KMP 不是封闭的,必要时要和原生代码互通。我在这个项目里遇到一个场景:瀑布流里的短视频卡片需要嵌原生播放器,Compose 里可以用 AndroidView 和 UIViewControllerRepresentable 分别包装原生播放器。

但这里要把握一个度:如果频繁调用原生代码,跨语言通信的开销会明显影响滚动性能。我的建议是,把原生播放器封装成独立的组件,一次创建、复用实例,不要在每次重组时都重新创建。

6. 性能优化与踩坑实录

6.1 滑动卡顿的排查思路

瀑布流滚动卡顿是最容易遇到的问题,也是最难排查的。我踩过的坑主要有三种:

第一种是 item 组合过重。如果你在卡片里放了太多嵌套布局,滚动时会不断触发重组,导致 UI 线程负载过高。解决办法是尽量展平布局层级,用 Modifier 完成间距和样式设置,避免多层 Column 嵌套。

第二种是图片加载引起的卡顿。很多图片库默认用内存缓存,但如果缓存策略设置不当,图片会反复解码,导致滑动时掉帧。解决办法是在图片库配置里设置合理的缓存策略,同时对超大图做降采样处理。

第三种是垃圾回收导致的时间抖动。频繁创建临时对象会让 GC 频繁触发,时间抖动变成肉眼可见的卡顿。这个问题在 KMP 里调试比较麻烦,我一般用 Profiler 看 GC 频率,然后优化循环里的对象创建。

6.2 图片错乱复用问题

瀑布流里图片错乱是最典型的复用问题。症状是:快速滑动时,某张卡片短暂显示了之前卡片的图片,随后才变成正确图片。

这个问题的根源是图片异步加载没有和 item 绑定。当 item 复用时,旧的异步任务还在执行,返回后把图片设置到了新 item 上。解决办法是使用图片库自带的占位图和可以取消的请求。Kamel 的 AsyncImage 在 item 销毁时会自动取消加载请求,但你要确保没有自己手动去设置图片。

如果遇到这种问题,第一件事是给 LazyVerticalStaggeredGrid 设置 key,第二件事是确认图片库的加载生命周期与 Composable 绑定,尽量不要在 LaunchedEffect 里手动加载图片。

6.3 常见问题排查速查表

现象原因解决方案
列表滑动时闪一下空白LazyVerticalStaggeredGrid 没有设置 key在 items 里传 key = { it.id }
图片显示了上一个 item 的内容异步加载任务没有取消改用图片库内置 AsyncImage,不要在 LaunchedEffect 中手动加载
下拉刷新手势不灵敏PullToRefreshBox 和滚动冲突检查 Modifier 顺序,确保手势节点层级正确
iOS 上状态栏遮挡内容没有处理安全区封装 expect/actual 获取系统 inset
Android 上加载更多触发太快预加载阈值设得太大把阈值调小(如 3~5 个)
移动端图片解码内存暴涨未做图片降采样在图片库配置宽高上限,使用内存缓存

6.4 调试技巧:两个平台同时验证

最后分享一个调试习惯:每次改完布局或逻辑,我都在 Android 模拟器和 iOS 模拟器上各跑一遍,及时对比两端差异。KMP 的坑往往是“一边正常一边不正常”,这种问题如果等到后期才处理,定位成本会翻倍。

iOS 端调试用的还是 Android Studio 自带的 KMP 插件,可以直接选择 iosApp 的 target 运行。真机调试需要连 Mac 环境,模拟器调试相对方便,日常迭代足够。

另外我习惯在 commonMain 里写一个 Debug 用的滚动日志开关,打开后能实时查看当前布局的 totalItemsCount、visibleItemsInfo 长度、缓存池状态。这个信息对定位复用错乱和预加载问题是决定性的,比打印 item 内容更直观。

踩过几次坑之后,我的体会是:KMP 的瀑布流实现,核心不是“写出来”,而是“写对”。如果你正准备在自己的项目里尝试 AndroidKMP,可以先把 demo 跑通,然后重点盯住复用 key、安全区、图片缓存这三个方面,那些 90% 的坑都能提前避掉。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询