最近在搞一个信息流社区的跨端改造需求很简单首页要做成瀑布流卡片高度参差不齐两列错落显示Android 和 iOS 两端视觉和交互必须一致。既然团队已经定了 Kotlin MultiplatformKMP技术栈这个瀑布流就必须在 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-CKMP 的学习成本会比较高这时候强行上 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 17Kotlin 版本当前稳定版是 2.xCompose Multiplatform 插件版本需要和 Kotlin 版本严格对齐否则编译期会出现各种稀奇古怪的错误。说个和热搜词相关的点很多人搜索“android studio 怎么设置中文”“android studio 汉化”如果你是初学者汉化没问题但看 KMP 报错信息时建议切回英文因为网上绝大多数解决方案都是针对英文报错的用中文界面去搜英文错误信息会多绕一圈。具体版本建议在工程里用版本目录Version Catalog统一管理。我当时的版本组合是组件版本Kotlin2.0.21Compose Multiplatform1.6.10Android Gradle Plugin8.5.2Gradle8.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 到 13.1 数据模型与差异化卡片设计瀑布流要做出错落感卡片高度必须有差异。我这里模拟的是一个社区分享 Feed每条数据包含用户名、头像、标题、配图 URL 和点赞数。数据模型定义在 commonMaindata 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: ListFeedItem, 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: ListFeedItem, 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 获取系统 insetAndroid 上加载更多触发太快预加载阈值设得太大把阈值调小如 3~5 个移动端图片解码内存暴涨未做图片降采样在图片库配置宽高上限使用内存缓存6.4 调试技巧两个平台同时验证最后分享一个调试习惯每次改完布局或逻辑我都在 Android 模拟器和 iOS 模拟器上各跑一遍及时对比两端差异。KMP 的坑往往是“一边正常一边不正常”这种问题如果等到后期才处理定位成本会翻倍。iOS 端调试用的还是 Android Studio 自带的 KMP 插件可以直接选择 iosApp 的 target 运行。真机调试需要连 Mac 环境模拟器调试相对方便日常迭代足够。另外我习惯在 commonMain 里写一个 Debug 用的滚动日志开关打开后能实时查看当前布局的 totalItemsCount、visibleItemsInfo 长度、缓存池状态。这个信息对定位复用错乱和预加载问题是决定性的比打印 item 内容更直观。踩过几次坑之后我的体会是KMP 的瀑布流实现核心不是“写出来”而是“写对”。如果你正准备在自己的项目里尝试 AndroidKMP可以先把 demo 跑通然后重点盯住复用 key、安全区、图片缓存这三个方面那些 90% 的坑都能提前避掉。
