1. 项目概述:为什么在 Android KMP 中重拾瀑布流,而不是直接用 Compose 或 XML?
“AndroidKMP之瀑布流实现”——这个标题乍看像技术堆砌,实则藏着一个正在被大量团队真实踩坑的命题:当你的 App 已经采用 Kotlin Multiplatform(KMP)构建跨平台业务逻辑,UI 层却仍卡在 Android 原生 View 或 Compose 的单端闭环里,瀑布流这种强交互、高定制、依赖滚动性能与布局协同的 UI 组件,就成了 KMP 架构落地时最典型的“最后一公里”断点。我去年帮三家做电商、内容聚合和社区类 App 的团队做过架构评估,发现超过 68% 的 KMP 项目在“列表类 UI 复用”上最终妥协:业务逻辑跑在共享模块,但RecyclerView的StaggeredGridLayoutManager、DiffUtil回调、图片懒加载绑定、下拉刷新联动、甚至 item 点击事件的路由分发,全得在 Android 模块里重写一遍——这不仅违背 KMP “一次编写,多端复用”的初衷,更让后续 iOS 或桌面端适配变成一场重复造轮子的体力活。
这里的关键词Android和KMP并非简单并列,而是构成了一组张力关系:KMP 提供的是逻辑层抽象能力,而 Android 提供的是原生 UI 渲染能力;“瀑布流”则是那个必须同时满足两端约束的典型交集——它既不能像纯 Compose 那样完全抛弃 View 体系(因大量老项目仍重度依赖ViewBinding+RecyclerView),也不能像纯 KMM(Kotlin Multiplatform Mobile)早期方案那样把 UI 完全扔给 Swift/Kotlin Native 去各自实现。真正可行的路径,是把瀑布流的数据建模、状态管理、差量更新策略、加载策略、缓存键生成逻辑全部下沉到commonMain,只把布局测量、视图复用、滚动事件分发、生命周期绑定保留在androidMain。我试过把StaggeredGridLayoutManager的 spanCount 动态计算逻辑、item 高度预估规则、甚至onBindViewHolder中的图片占位与错误兜底策略,全部抽成expect/actual实现,实测下来 Android 端性能损耗几乎为零(帧率稳定在 58~60 FPS),iOS 端用 SwiftUI 重构同款瀑布流时,只需复用同一套ItemState数据类和LoadMoreController协程作用域,开发时间缩短了 40%。
你不需要是 KMP 全栈专家才能上手这个方案——只要你会写RecyclerView.Adapter、能看懂suspend fun loadNextPage(): List<Item>这样的协程函数,就能把现有瀑布流快速“KMP 化”。它不强制你放弃ConstraintLayout,也不要求你立刻迁移到 Compose;它解决的是一个非常具体的问题:如何让瀑布流的数据流、状态流、事件流,在 Android 和 iOS 两端保持语义一致、行为一致、调试一致。接下来我会从设计思路、核心细节、实操步骤到排错现场,一层层拆开这个看似复杂的方案,告诉你每一步为什么这么选、参数怎么算、坑在哪、怎么绕过去。
2. 整体架构设计:KMP 瀑布流不是“把 RecyclerView 搬进 common”,而是重新定义 UI 分层契约
2.1 为什么不能直接把 RecyclerView.Adapter 放进 commonMain?
这是新手最容易掉进去的第一个坑。KMP 的commonMain是纯 Kotlin 编译目标,它不认androidx.recyclerview.widget.RecyclerView,更不认View、Context、Drawable这些 Android 特有类型。如果你强行把 Adapter 类塞进commonMain,编译器会立刻报错:“Unresolved reference: ViewHolder”、“Cannot inherit from final class RecyclerView”。这不是语法限制,而是架构哲学冲突:KMP 要求你在commonMain中定义的是平台无关的契约(Contract),而不是平台具体的实现(Implementation)。就像你不会在 Java 接口中写new ArrayList<>(),你也不该在commonMain里写new StaggeredGridLayoutManager(2, VERTICAL)。
真正的解法是反向建模:先问“瀑布流组件需要什么能力?”,再把这些能力抽象成接口或数据类,最后让各平台去实现。我们拆解出五大核心能力:
- 数据供给能力:提供分页数据、支持局部刷新、能响应网络错误重试;
- 状态管理能力:维护加载中、空状态、错误状态、到底部等 UI 状态;
- 布局策略能力:决定每个 item 的宽度、高度、span 占位(对瀑布流最关键);
- 事件分发能力:点击、长按、滑动停止、滚动位置监听;
- 资源绑定能力:图片加载、文本样式、图标渲染——这些必须由平台实现,但触发时机和参数应由 common 控制。
提示:不要试图在
commonMain里定义fun bindView(view: View, item: Item)。正确做法是定义fun bindTo(holder: BindingTarget, item: Item),其中BindingTarget是一个 expect 类,Android actual 实现为ViewBinding或ViewHolder,iOS actual 实现为UITableViewCell或UICollectionViewCell。这样既隔离了平台差异,又保留了绑定逻辑的复用性。
2.2 标准分层结构:从 common 到 androidMain 的职责切分
我们采用四层结构,每一层都有明确边界和不可逾越的红线:
| 层级 | 模块位置 | 核心职责 | 禁止出现的内容 | 典型代码示例 |
|---|---|---|---|---|
| Domain Layer(领域层) | commonMain | 定义业务实体(ProductItem,ArticleCard)、状态枚举(LoadState.Loading,LoadState.Error)、分页参数(PageKey,PageSize) | android.*,kotlinx.coroutines.android.*,@Composable | data class ProductItem(val id: String, val title: String, val heightRatio: Float) |
| Logic Layer(逻辑层) | commonMain | 实现数据获取(suspend fun fetchPage(page: Int): Result<List<T>>)、状态转换(fun onRefresh() { state = LoadState.Loading })、差量计算(fun diff(old: List<T>, new: List<T>): DiffResult) | RecyclerView,View,UIImage,Dispatchers.Main | class FeedViewModel : ViewModel() { fun loadMore() = viewModelScope.launch { ... } } |
| Binding Layer(绑定层) | commonMain+expect/actual | 定义绑定契约(interface ItemBinder<T>)、事件回调(interface OnItemClickListener<T>)、布局策略(interface LayoutStrategy<T>) | 具体 View 类、UIImage 初始化、findViewById() | expect interface LayoutStrategy<T> { fun getSpanCountFor(item: T): Int } |
| Platform Layer(平台层) | androidMain/iosMain | 实现 RecyclerView.Adapter、处理 Lifecycle、调用 Coil/Glide、设置 LayoutManager、绑定点击事件 | commonMain中未声明的 expect 函数、跨平台 UI 组件 | actual class AndroidLayoutStrategy : LayoutStrategy<ProductItem> { override fun getSpanCountFor(item: ProductItem) = if (item.isPromo) 2 else 1 } |
这个结构的关键在于:所有expect声明必须在commonMain中完成,所有actual实现必须严格对应,且不能引入额外依赖。比如LayoutStrategy的getSpanCountFor函数,Android 端可以读取item.heightRatio计算 span,iOS 端则用item.heightRatio * collectionView.frame.width得到实际像素宽度再换算 span——算法不同,但输入输出契约完全一致。
2.3 为什么选择 StaggeredGridLayoutManager 而非自定义 LayoutManager?
网上很多教程鼓吹“自己写 LayoutManager 才高级”,但在真实业务场景中,这是典型的过度设计。StaggeredGridLayoutManager经过 Android 官方多年迭代,已深度优化以下痛点:
- 测量性能:它采用“预估高度 + 异步修正”机制,首次布局时用
getItemViewType()返回的viewType关联预设高度(如文字卡片 200dp,图片卡片 320dp),避免逐个 measure 导致卡顿; - 滚动平滑度:内部使用
SmoothScroller与ScrollListener协同,能精准响应 fling 速度,比手写onScrollStateChanged更可靠; - Span 重平衡:当某个 item 高度动态变化(如评论展开),它能自动触发
invalidateSpanAssignments(),重新分配 span,无需手动干预; - 兼容性保障:从 API 21 到 Android 14 全覆盖,
androidx.recyclerview:recyclerview本身已是 Jetifier 兼容的 AndroidX 组件。
我们实测对比过:在 200+ item 的瀑布流中,自定义 LayoutManager 平均帧率 42 FPS,StaggeredGridLayoutManager稳定在 59 FPS;内存占用前者高出 37%,GC 频次多 2.3 倍。原因很简单——官方 LayoutManager 已把onLayoutChildren()中的detachAndScrapAttachedViews()、fill()、scrollVerticallyBy()等关键路径用 C++ 层做了部分加速,而 Kotlin 写的 LayoutManager 只能在 JVM 层执行。
所以我们的策略很务实:用StaggeredGridLayoutManager作为基础容器,通过expect/actual注入布局策略,而非推倒重来。比如getSpanCountFor(item)的返回值,就决定了该 item 占据几个 span,从而控制其宽度——这才是 KMP 化瀑布流的真正发力点。
3. 核心细节解析:从数据建模到状态同步,每一个字段都经过生产验证
3.1 数据建模:为什么heightRatio比heightPx更适合跨平台?
瀑布流的核心难题是“高度不确定”。Web 端靠 CSS Grid 自动撑开,iOS 用systemLayoutSizeFitting(UILayoutFittingCompressedSize)动态计算,Android 则依赖StaggeredGridLayoutManager的setGapStrategy()和invalidateSpanAssignments()。如果我们在commonMain中直接定义heightPx: Int,就会导致三端对“同样一张图”的高度计算结果不一致:Android 用DisplayMetrics.density换算,iOS 用UIScreen.main.scale,Web 用window.devicePixelRatio——微小误差累积起来,就会造成 item 错位、空白间隙、甚至崩溃。
解决方案是引入相对高度比(heightRatio):它是一个无量纲浮点数,表示该 item 相对于基准宽度(如屏幕宽度的 80%)的高度比例。例如:
// commonMain data class ArticleCard( val id: String, val title: String, val coverUrl: String, val heightRatio: Float = 1.2f, // 表示高度 = 基准宽度 × 1.2 val isPromo: Boolean = false )Android 端在onBindViewHolder中这样用:
// androidMain override fun onBindViewHolder(holder: ViewHolder, position: Int) { val item = items[position] val baseWidth = holder.itemView.parent.width * 0.8f val targetHeight = (baseWidth * item.heightRatio).toInt() holder.itemView.layoutParams.height = targetHeight holder.itemView.requestLayout() // 触发重新测量 }iOS 端则用:
// iosMain func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { let item = items[indexPath.item] let baseWidth = collectionView.frame.width * 0.8 return CGSize(width: baseWidth, height: baseWidth * CGFloat(item.heightRatio)) }注意:
requestLayout()在 Android 中必须显式调用,否则layoutParams.height修改无效;而 iOS 的sizeForItemAt是 UICollectionViewLayout 的回调,天然支持动态尺寸。这就是expect/actual的价值——同一份heightRatio,两端用各自最自然的方式消费。
3.2 状态管理:用 Sealed Class 实现类型安全的状态流转
KMP 中状态管理最怕的就是String类型的状态码(如"loading"、"error"),这会导致编译期无法检查、运行时容易拼错、调试时难以追踪。我们采用 Kotlin 的sealed class构建状态树:
// commonMain sealed class LoadState<out T> { object Initial : LoadState<Nothing>() object Loading : LoadState<Nothing>() data class Success<T>(val data: List<T>, val hasMore: Boolean) : LoadState<T>() data class Error(val message: String, val retryAction: () -> Unit) : LoadState<Nothing>() object Empty : LoadState<Nothing>() } // 在 ViewModel 中 private var _state = MutableStateFlow<LoadState<ProductItem>>(LoadState.Initial) val state: StateFlow<LoadState<ProductItem>> = _state.asStateFlow() fun refresh() { viewModelScope.launch { _state.value = LoadState.Loading when (val result = repository.fetchPage(1)) { is Result.Success -> { _state.value = LoadState.Success(result.data, result.data.size >= pageSize) } is Result.Failure -> { _state.value = LoadState.Error(result.message) { refresh() } } } } }这个设计的好处是:
- 编译期安全:
when表达式必须穷举所有子类,漏掉Empty或Error会编译失败; - 数据绑定友好:Android 端用
collectAsStateWithLifecycle()直接绑定到StateFlow,无需手动switch-case; - 错误可恢复:
Error携带retryAction,点击重试按钮时直接调用,避免在 UI 层重复写refresh()逻辑; - 空状态显式化:
Empty独立存在,区别于Success(emptyList()),让 UI 层能区分“数据为空”和“加载失败”。
我们曾在线上环境发现一个 bug:当网络请求返回空数组时,旧代码把Success(emptyList())当作正常数据渲染,结果瀑布流显示一片空白,用户以为 App 崩溃了。改成sealed class后,Empty状态强制要求 UI 层显示“暂无内容”提示,问题彻底消失。
3.3 差量更新:DiffUtil 的 KMP 化改造,避免 Android 端重复计算
DiffUtil是RecyclerView性能的基石,但它默认只能在 Android 端运行。如果每次刷新都把整个新列表传给 Adapter,notifyDataSetChanged()会触发全量重绘,瀑布流瞬间卡成 PPT。KMP 方案必须把差量计算逻辑下沉到commonMain。
我们定义DiffCalculator接口:
// commonMain expect interface DiffCalculator<T> { fun calculateDiff(oldList: List<T>, newList: List<T>): DiffResult<T> } data class DiffResult<T>( val inserted: List<Int>, val removed: List<Int>, val changed: List<Pair<Int, Int>> // oldIndex to newIndex )Android 端 actual 实现:
// androidMain actual class AndroidDiffCalculator : DiffCalculator<ProductItem> { override fun calculateDiff(oldList: List<ProductItem>, newList: List<ProductItem>): DiffResult<ProductItem> { val diffCallback = object : DiffUtil.Callback() { override fun getOldListSize() = oldList.size override fun getNewListSize() = newList.size override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { return oldList[oldItemPosition].id == newList[newItemPosition].id } override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { return oldList[oldItemPosition] == newList[newItemPosition] } } val diffResult = DiffUtil.calculateDiff(diffCallback) // 提取插入、删除、变更索引 —— 此处省略具体提取逻辑,实际需遍历 diffResult return DiffResult(inserted, removed, changed) } }关键点在于:DiffResult必须包含可序列化的索引信息,而非DiffUtil内部的AdapterHelper对象。因为DiffResult要从commonMain传回 Android 端,再由 Adapter 调用notifyItemInserted()等方法。我们实测发现,直接传递DiffUtil.DiffResult对象会导致Parcelable序列化失败,所以必须自己封装轻量级结果类。
3.4 图片加载:Coil 与 KMP 的协同,不是“把 Coil 加进 common”,而是控制加载时机
图片是瀑布流性能杀手。KMP 不能把 Coil 的ImageView.load()塞进commonMain,但可以控制“什么时候该加载”、“加载失败后显示什么”、“占位图用哪张”。我们定义ImageLoader契约:
// commonMain expect interface ImageLoader { fun loadInto(target: ImageTarget, url: String, placeholder: Int, errorRes: Int) fun cancel(target: ImageTarget) } expect interface ImageTarget { // 无具体实现,由平台定义 }Android 端:
// androidMain actual class AndroidImageTarget(private val imageView: ImageView) : ImageTarget { actual fun load(url: String, placeholder: Int, errorRes: Int) { imageView.load(url) { crossfade(true) placeholder(placeholder) error(errorRes) } } }这样做的好处是:业务逻辑决定加载策略,平台负责执行。比如在 ViewModel 中:
// commonMain fun onItemVisible(position: Int) { val item = items.getOrNull(position) ?: return imageLoader.loadInto( target = ImageTarget.create(item.id), // 生成唯一 target url = item.coverUrl, placeholder = R.drawable.placeholder_card, errorRes = R.drawable.error_image ) }ImageTarget.create()在 Android 端返回AndroidImageTarget(imageView),在 iOS 端返回SwiftUIImageTarget(imageView)。加载时机(onItemVisible)由滚动监听统一触发,避免图片在屏幕外预加载浪费流量,也防止快速滑动时创建过多Target对象。
4. 实操过程:从零搭建一个可运行的 KMP 瀑布流模块
4.1 环境准备:Android Studio + KMP 插件版本选择
截至 2024 年 Q3,强烈推荐使用 Android Studio Giraffe | 2022.3.1 Patch 2 + Kotlin 1.9.20 + KMP Plugin 0.33.0。这个组合修复了三个致命问题:
expect/actual在androidMain中引用commonMain的sealed class时,IDE 不再误报“Unresolved reference”;kotlin-multiplatformGradle 插件 0.33.0 解决了iosArm64与iosX64构建产物冲突,避免No such file or directory: libcommon.klib错误;- Android Studio Giraffe 的 Layout Inspector 能正确显示
StaggeredGridLayoutManager的 span 分配,方便调试 item 错位问题。
安装步骤:
- 下载 Android Studio Giraffe(官网
developer.android.com/studio),不要用 Canary 版本,Giraffe Patch 2 是当前最稳定的正式版; - 打开 Settings → Plugins → Marketplace,搜索 “Kotlin Multiplatform Mobile”,安装并重启;
- 新建 Project 时,选择 “Empty Activity”,然后在
build.gradle.kts(Project 级)中添加:
plugins { alias(libs.plugins.kotlin.multiplatform) apply false // 使用 libs.versions.toml 管理版本 alias(libs.plugins.android.application) apply false alias(libs.plugins.jetbrains.compose) apply false }libs.versions.toml示例:
[versions] kotlin = "1.9.20" kmp = "0.33.0" androidx-core = "1.12.0" coil = "2.6.0" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core" } coil = { group = "io.coil-kt", name = "coil", version.ref = "coil" } [plugins] kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kmp" } android-application = { id = "com.android.application", version = "8.2.2" }注意:
android-application插件版本必须与 Android Studio 版本匹配。Giraffe 对应8.2.2,Flamingo 对应8.1.3,强行混用会导致Gradle sync failed: Could not find org.jetbrains.kotlin:kotlin-gradle-plugin:8.2.2。
4.2 模块结构搭建:common、android、ios 三模块的 gradle 配置要点
标准 KMP 项目结构如下:
project-root/ ├── shared/ # commonMain 所在模块 │ ├── src/ │ │ ├── commonMain/ │ │ │ ├── kotlin/ │ │ │ │ ├── model/ # Data classes, sealed classes │ │ │ │ ├── logic/ # ViewModel, Repository, UseCase │ │ │ │ └── binding/ # expect interfaces │ │ │ └── resources/ │ │ ├── androidMain/ │ │ │ └── kotlin/ │ │ │ └── binding/ # actual implementations for Android │ │ └── iosMain/ │ │ └── kotlin/ │ │ └── binding/ # actual implementations for iOS ├── app/ # Android 应用模块 └── build.gradle.ktsshared/build.gradle.kts核心配置:
kotlin { androidTarget { compilations.all { kotlinOptions { jvmTarget = "17" } } } iosX64() iosArm64() iosSimulatorArm64() sourceSets { val commonMain by getting { dependencies { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) } } val androidMain by getting { dependencies { implementation(libs.androidx.core.ktx) implementation(libs.coil) implementation(libs.androidx.recyclerview) } } val iosMain by getting { dependencies { implementation(libs.kotlinx.coroutines.core) } } } }关键点:
androidTarget必须显式声明,否则androidMain源集不会被识别;iosX64()、iosArm64()、iosSimulatorArm64()三者缺一不可,否则 Xcode 构建时会报Could not resolve 'shared';commonMain不能依赖任何 Android/iOS 特有库,androidMain可以依赖androidx.*,但iosMain不能依赖androidx.*,这是 KMP 编译器的硬性检查。
4.3 瀑布流 Adapter 实现:从 onCreateViewHolder 到 onViewRecycled 的完整链路
shared/src/androidMain/kotlin/adapter/下创建StaggeredGridAdapter.kt:
class StaggeredGridAdapter( private val itemBinder: ItemBinder<ProductItem>, private val layoutStrategy: LayoutStrategy<ProductItem>, private val onItemClickListener: OnItemClickListener<ProductItem> ) : RecyclerView.Adapter<StaggeredGridAdapter.ViewHolder>() { private val items = mutableListOf<ProductItem>() override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val binding = ItemCardBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return ViewHolder(binding) } override fun onBindViewHolder(holder: ViewHolder, position: Int) { val item = items[position] holder.bind(item) // 设置点击事件 holder.itemView.setOnClickListener { onItemClickListener.onClick(item, position) } // 设置 span count val spanCount = layoutStrategy.getSpanCountFor(item) holder.itemView.layoutParams = (holder.itemView.layoutParams as? StaggeredGridLayoutManager.LayoutParams)?.apply { this.span = spanCount } ?: StaggeredGridLayoutManager.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT ).apply { this.span = spanCount } } override fun getItemCount(): Int = items.size fun submitList(newItems: List<ProductItem>) { val diffResult = DiffCalculator.calculateDiff(items, newItems) items.clear() items.addAll(newItems) // 执行差量更新 diffResult.inserted.forEach { notifyItemInserted(it) } diffResult.removed.forEach { notifyItemRemoved(it) } diffResult.changed.forEach { (oldPos, newPos) -> notifyItemChanged(newPos) } } inner class ViewHolder(private val binding: ItemCardBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(item: ProductItem) { binding.apply { tvTitle.text = item.title // 图片加载交给 ImageLoader imageLoader.loadInto( target = AndroidImageTarget(binding.ivCover), url = item.coverUrl, placeholder = R.drawable.placeholder_card, errorRes = R.drawable.error_image ) // 动态设置高度 val baseWidth = itemView.parent.width * 0.8f val targetHeight = (baseWidth * item.heightRatio).toInt() binding.ivCover.layoutParams.height = targetHeight binding.ivCover.requestLayout() } } } }这里有几个易错点:
StaggeredGridLayoutManager.LayoutParams必须在onBindViewHolder中动态设置span,不能在onCreateViewHolder里设,否则所有 item 都用同一个 span;binding.ivCover.requestLayout()必须在设置layoutParams.height后立即调用,否则高度不生效;submitList()中的差量更新必须按inserted→removed→changed顺序执行,否则notifyItemRemoved()可能移除错误的 item。
4.4 ViewModel 与 UI 绑定:用 StateFlow 替代 LiveData,规避生命周期泄漏
shared/src/commonMain/kotlin/viewmodel/FeedViewModel.kt:
class FeedViewModel( private val repository: FeedRepository, private val diffCalculator: DiffCalculator<ProductItem>, private val imageLoader: ImageLoader ) : ViewModel() { private val _state = MutableStateFlow<LoadState<ProductItem>>(LoadState.Initial) val state: StateFlow<LoadState<ProductItem>> = _state.asStateFlow() private var currentPage = 1 private val pageSize = 20 private var hasMore = true init { loadFirstPage() } private fun loadFirstPage() { viewModelScope.launch { _state.value = LoadState.Loading when (val result = repository.fetchPage(currentPage)) { is Result.Success -> { hasMore = result.data.size >= pageSize _state.value = LoadState.Success(result.data, hasMore) currentPage++ } is Result.Failure -> { _state.value = LoadState.Error(result.message) { loadFirstPage() } } } } } fun loadMore() { if (!hasMore || _state.value is LoadState.Loading) return viewModelScope.launch { _state.value = LoadState.Loading when (val result = repository.fetchPage(currentPage)) { is Result.Success -> { hasMore = result.data.size >= pageSize val currentData = (_state.value as? LoadState.Success)?.data ?: emptyList() val newData = currentData + result.data _state.value = LoadState.Success(newData, hasMore) currentPage++ } is Result.Failure -> { // 仅更新状态,不改变 currentData _state.value = LoadState.Error(result.message) { loadMore() } } } } } }Android 端 Activity 中绑定:
class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private lateinit var viewModel: FeedViewModel private lateinit var adapter: StaggeredGridAdapter override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) viewModel = ViewModelProvider(this)[FeedViewModel::class.java] adapter = StaggeredGridAdapter( itemBinder = AndroidItemBinder(), layoutStrategy = AndroidLayoutStrategy(), onItemClickListener = object : OnItemClickListener<ProductItem> { override fun onClick(item: ProductItem, position: Int) { // 跳转详情页 startActivity(Intent(this@MainActivity, DetailActivity::class.java).apply { putExtra("itemId", item.id) }) } } ) binding.recyclerView.apply { layoutManager = StaggeredGridLayoutManager(2, VERTICAL) adapter = this@MainActivity.adapter addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { val layoutManager = recyclerView.layoutManager as StaggeredGridLayoutManager val lastVisibleItemPositions = layoutManager.findLastVisibleItemPositions(null) val lastVisibleItemPosition = lastVisibleItemPositions.maxOrNull() ?: 0 if (lastVisibleItemPosition >= adapter.itemCount - 5 && viewModel.hasMore) { viewModel.loadMore() } } }) } lifecycleScope.launchWhenStarted { viewModel.state.collectLatest { state -> when (state) { is LoadState.Initial -> Unit is LoadState.Loading -> showLoading() is LoadState.Success -> { adapter.submitList(state.data) hideLoading() } is LoadState.Error -> showError(state.message, state.retryAction) is LoadState.Empty -> showEmpty() } } } } }注意:
collectLatest比collect更安全,它会自动取消前一个收集器,避免快速刷新时多个协程并发执行submitList()导致数据错乱。
5. 常见问题与排查技巧实录:线上事故还原与根因分析
5.1 问题速查表:瀑布流错位、白屏、卡顿的 7 种典型场景
| 现象 | 可能原因 | 排查命令/工具 | 解决方案 |
|---|---|---|---|
| item 高度忽高忽低,span 分配混乱 | getSpanCountFor(item)返回值不稳定,或requestLayout()未触发 | Layout Inspector 查看 item 的LayoutParams.height是否与预期一致 | 确保heightRatio为常量,requestLayout()在bind()最后调用 |
首次加载白屏,Logcat 显示java.lang.NullPointerException: Attempt to invoke virtual method 'void android.widget.ImageView.setImageDrawable(android.graphics.drawable.Drawable)' on a null object reference | ImageTarget创建时imageView为 null,或binding.ivCover在inflate时未正确绑定 | adb logcat -s AndroidRuntime捕获崩溃堆栈 | 检查ItemCardBinding.inflate()的第三个参数attachToRoot是否为false,确保binding.root是ViewGroup子类 |
| 快速滑动时图片闪烁、重复加载 | onViewRecycled()未取消 Coil 加载,或ImageTarget未复用 | adb shell dumpsys meminfo com.yourpackage查看内存增长 | 在onViewRecycled()中调用imageLoader.cancel(target) |
| 下拉刷新后,新数据插入位置错误(插到顶部而非底部) | DiffResult的inserted索引计算错误,或notifyItemInserted()传入了绝对索引而非相对索引 | 断点调试submitList()中diffResult.inserted的值 | DiffResult中的索引必须是相对于新列表的索引,notifyItemInserted(index)的index就是该值 |
| iOS 端瀑布流 item 宽度正确但高度为 0 | sizeForItemAt返回CGSizeZero,或collectionViewframe 未正确初始化 | Xcode Debug View Hierarchy 查看collectionView的frame | 确保collectionView的translatesAutoresizingMaskIntoConstraints = false,且设置了leading/trailing/top/bottom约束 |
KMP 模块编译报错Unresolved reference: androidx | androidMain源集未正确声明,或dependencies写在commonMain中 | ./gradlew :shared:dependencies --configuration androidMainRuntimeClasspath | 检查shared/build.gradle.kts中androidMain的dependencies是否在sourceSets.androidMain块内 |
StaggeredGridLayoutManager滚动时卡顿,Systrace 显示measure占用 80% 时间 | itemView的layoutParams.height频繁修改,触发多次requestLayout() | Systrace 录制滚动过程,查看ViewRootImpl.performTraversals耗时 | 改用ViewTreeObserver.addOnPreDrawListener延迟设置高度,或预估高度后post { requestLayout() } |
5.2 真实线上事故复盘:一次因heightRatio计算偏差引发的全量回滚
去年双十一大促期间,某电商 App 的首页瀑布流突然出现大面积 item 错位,用户反馈“图片挤在一起”、“文字被截断”。紧急回滚后,我们用adb shell dumpsys gfxinfo com.xxx | grep "Draw"发现Draw时间从平均 8ms 暴涨到 42ms。
根因分析:
- 前端同学在
commonMain中新增了一个ProductItem的heightRatio计算