简介面向Android开发者的Kotlin Compose列表交互代码包聚焦单选与多选场景基于LazyColumn、RadioButton、Checkbox等组件实现帮助开发者摆脱传统RecyclerView适配器的繁琐写法快速构建高性能长列表与选择交互界面。资源包为zip压缩格式共44个文件以11个Kotlin源码、11个XML资源、10个WebP图片为主辅以Gradle构建脚本与配置文件整体体积仅113KB结构清晰便于阅读和二次开发。已有238人学习下载适合初学Compose或希望掌握声明式列表与选择交互的开发者参考。代码涵盖完整工程骨架与核心实现通过运行示例可直观理解Compose状态管理、回调绑定及组件复用等典型写法为实际项目落地提供切实的参考价值。1. Kotlin Compose 列表单选多选这套代码包能帮你绕开哪几个坑做 Kotlin Compose 列表单选、多选时最典型的翻车不是不会写 Composable而是选中状态放错位置放进 item 内部一滚动就乱套。这份 ComposeRecyclerView 代码包是一份可直接编译的完整工程解压后能看到 gradle 配置、libs 版本目录和 app 源码覆盖了单选、多选两条最常用路径。它适合两类人刚从 RecyclerView 切到 Compose、需要一份可运行工程对照的开发者以及已经写好列表但总在细节上出小毛病、想看看边界处理的从业者。读之前先记住一个结论列表的所有选中状态都必须提升到 Composable 外面item 里只做展示与回调。2. ComposeRecyclerView 项目解剖先读透四个构建文件再动业务代码拿到压缩包先别急着点开 Android Studio。我的习惯是先在文本编辑器里把根目录扫一遍settings.gradle、build.gradle、gradle.properties、local.properties、gradlew.bat这五个文件决定了同一份源码在不同机器上能否一次跑起来。项目名叫 ComposeRecyclerView但 Compose 里真正铺长列表的是 LazyColumn而不是 RecyclerView。名字保留 RecyclerView 只是为了说明这里提供的是「替代 RecyclerView 的 Compose 列表完整方案」读代码时不要被名字引导去走旧思路。2.1 settings.gradle 与根 build.gradle决定整个构建的第一道关卡settings.gradle 负责仓库与模块声明几乎所有依赖拉取失败、仓库策略报错都跟它有关。复现项目时我一般先把 settings.gradle 里 dependencyResolutionManagement 的 repositoriesMode 改成 PREFER_PROJECT 或直接注释等依赖能拉下来再恢复原状。// settings.gradle 中常见的模板结构 pluginManagement { repositories { mavenCentral() gradlePluginPortal() } } dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { mavenCentral() } } rootProject.name ComposeRecyclerView include(:app)这段代码里FAIL_ON_PROJECT_REPOS 的作用是禁止子模块再声明仓库。如果你的项目里某个依赖是从指定的本地镜像仓库下载的这个模式会让子模块构建直接报错。遇到这种情况我会把 repositoriesMode 这一行注释掉然后让子模块统一走根仓库。rootProject.name 会显示为工程名include(:app) 表示当前只有 app 一个应用模块。如果后续想把列表组件单独拆成 library就在这行后面追加 include(:library)。根目录的 build.gradle 通常比较薄只声明插件版本。使用版本目录时它长这样// 根 build.gradle plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.android) apply false }这行的意义是提前解析插件版本但不应用到根工程只等 app 模块去 alias 应用。apply false 写漏的话插件会在每个模块重复加载配置阶段变慢倒是小事遇到 AGP 与 Kotlin 插件互相抢版本时真的会把人绕晕。2.2 libs.versions.toml版本不匹配的源头几乎都在这里libs 目录在这个压缩包里不是普通文件夹而是 Gradle Version Catalog 的载体。它统一管理 AGP、Kotlin、Compose 编译器版本避免在多个 build.gradle 里手工写版本号。打开压缩包根目录后应该先找 gradle/libs.versions.toml 这个文件。[versions] agp 8.2.0 kotlin 1.9.20 compose 1.5.4 composeCompiler 1.5.8 [libraries] androidx-compose-foundation { module androidx.compose.foundation:foundation, version.ref compose } androidx-compose-material3 { module androidx.compose.material3:material3, version.ref compose } androidx-compose-ui { module androidx.compose.ui:ui, version.ref compose } [plugins] android-application { id com.android.application, version.ref agp } kotlin-android { id kotlin-android, version.ref kotlin }上面是按常见写法补全的示意实际文件里版本号可能不同。这里想提醒的是AGP 版本、Kotlin 版本、Compose 编译器版本三者必须匹配。不少人把 Kotlin 升了一级却忘了 composeCompiler 也要跟着升结果 app 模块里的 Composable 全部报红。解决的办法很简单先查当前 Kotlin 版本对应的 Compose 编译器版本再回填到 toml。如果项目里没写 composeCompiler 项也可以在模块级 build.gradle 的 composeOptions 里指定 kotlinCompilerExtensionVersion两者二选一即可。2.3 app/build.gradle 里的 Compose 开关少了这行代码注解全报红app 模块的 build.gradle 是业务代码的入口也是包名、SDK 版本、Compose 开关的汇聚点。复现这个项目时逐行对照以下配置android { namespace com.example.composerecyclerview compileSdk 34 defaultConfig { applicationId com.example.composerecyclerview minSdk 24 targetSdk 34 versionCode 1 versionName 1.0 } buildFeatures { compose true } compileOptions { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } }buildFeatures { compose true } 是最容易漏掉的一行。漏掉它时Compose 编译器插件不会参与编译项目中所有 Composable 函数都会变成普通函数错误信息往往指向「This build doesnt support Compose」。compileSdk 与 Compose 版本也有隐含关系material3 高版本会依赖较新的 SDK 常量把 compileSdk 拉低之后依赖解析阶段就能看到一堆 API level 不匹配的警告。较新的工程还会在 dependencies 里用 BOM 统一一组 Compose 库的版本不需要逐个写版本号。2.4 gradle.properties 与 local.properties两个小文件藏着最容易翻车的大坑gradle.properties 里最关键的一项是 android.useAndroidXtrue。这句话不写AndroidX 库的解析路径会整体错乱报错信息里会出现「Duplicate class」或者干脆找不到 androidx 开头的类。原因是项目默认使用旧 support 库坐标Compose 库全部基于 AndroidX两套坐标混在一起必炸。另一个常见配置是 org.gradle.jvmargs它控制 Gradle 守护进程内存写小了以后大型编译会出现 OutOfMemoryError我一般至少给 2048m。# gradle.properties 中与 Compose 直接相关的开关 org.gradle.jvmargs-Xmx2048m -Dfile.encodingUTF-8 android.useAndroidXtrue # local.properties 里只需这一行且不要提交进 git # sdk.dir/path/to/android-sdklocal.properties 是本地环境专属文件里面写 sdk.dir也就是本机 Android SDK 的路径。因为它是绝对路径换一台机器就失效所以版本库里一般会忽略它。如果你解压代码包后打开工程报「SDK location not found」十有八九是 local.properties 不存在或路径指向了其他机器。解决方法是删掉这个文件重新用 Android Studio 的 SDK Manager 配置一次。2.5 gradle wrapper 与 proguard两个容易看漏的文件gradle/wrapper/gradle-wrapper.properties 指定 Gradle 发行版版本。项目换机器后能不能编过一半看这里。如果你本机 Gradle 版本和 wrapper 不一致构建会先下载对应发行版网络差时就会卡在 Downloading gradle-8.x 那一步。解决方法是打开 wrapper.properties把它改成你本机已有的版本或者排查时临时用系统命令行 gradle 直接执行。proguard-rules.pro 在 debug 下没有存在感release 打包时如果 minify 打开Compose 类会被混淆得 UI 大变、点击无响应。我复现阶段会把 minifyEnabled 设为 false等业务稳定后再研究混淆保留规则。看到这里你应该已经发现这个项目真正需要手动改的地方很少大部分坑集中在版本与开关。把四个文件理顺之后app/src 里的单选、多选代码才值得逐行读。3. 单选列表实现用 index 还是稳定 Key决定了插入数据后会不会错位单选列表从表面看只是「高亮一行」但实现细节藏在三个问题里选中状态变量放在哪、整行点击区域怎么处理、列表数据变化后如何维持选中语义。很多现成 Demo 只给出 RadioButton 被选中时的视觉效果滚动后一切恢复正常却没人解释为什么。这一章用一个可编译的最小实现拆开讲。3.1 为什么 RadioGroup 在 LazyColumn 里经常失效传统视图体系里单选列表用 RadioGroup 包一组 RadioButton由父容器维护互斥选中状态。Compose 里不建议照搬这个思路LazyColumn 的 item 是懒加载的屏幕上不可见时组件会被销毁重建RadioGroup 即使包住 LazyColumn也无法感知离线 item 的选中状态。而且 Compose 的声明式模式里互斥逻辑应该放在数据层而不是让一组控件自己协调。所以我更推荐的做法是用一个 Int? 类型的状态变量保存当前选中项的下标。列表项根据 selectedIndex index 决定自己是否处于选中态点击时通过回调把 index 抛出去。这样 item 自身完全不持有状态滚出去再滚回来选中态从外部状态重新计算结果永远一致。状态放哪个层级也有讲究。如果列表只是一个页面的局部组件用 remember { mutableStateOf } 在父 Composable 里存即可如果选中结果需要跨页面使用或者要从网络预填就放进 ViewModel。判断标准是这个选中状态的生命周期是否长于页面本身。单选选中通常用于后续跳转、提交操作放进 ViewModel 更稳妥进程配置变更后状态也不会丢。3.2 可编译的单选列表整行可点 RadioButton 样式Composable fun SingleSelectList( items: ListString, selectedIndex: Int?, onItemClick: (Int) - Unit ) { LazyColumn(modifier Modifier.fillMaxSize()) { itemsIndexed( items items, key { index, _ - index } ) { index, item - SingleSelectRow( text item, selected selectedIndex index, onClick { onItemClick(index) } ) } } } Composable private fun SingleSelectRow( text: String, selected: Boolean, onClick: () - Unit ) { Row( modifier Modifier .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) .clickable(onClick onClick) .padding(horizontal 16.dp, vertical 12.dp), verticalAlignment Alignment.CenterVertically ) { RadioButton(selected selected, onClick onClick) Spacer(modifier Modifier.width(8.dp)) Text(text text, modifier Modifier.weight(1f)) } }逻辑说明SingleSelectRow 在 Row 上挂了一个 clickable点击整行都会触发 onClick。RadioButton 自己也接收同一个 onClick这样用户无论点文本还是点圆圈行为都是一致的。selectedIndex index 是唯一的选中依据item 里没有本地状态也就不存在跨行串扰。参数说明itemsIndexed 的回调签名是 { index, item }index 同时用作 key 和选择标识。这种写法在列表内容不变时没有问题。但如果你的列表会有插入或删除操作index 作为 key 会让 Compose 把「原来第 3 行」误认为「新的第 3 行」选中态跟着错位。要彻底解决需要把 key 换成 item 自身的稳定 id见下面这段data class ItemData(val id: String, val name: String) Composable fun SingleSelectListById( items: ListItemData, selectedId: String?, onItemClick: (String) - Unit ) { LazyColumn(modifier Modifier.fillMaxSize()) { items(items, key { it.id }) { item - SingleSelectRow( text item.name, selected selectedId item.id, onClick { onItemClick(item.id) } ) } } }这段代码和上一段的差别只有两个key 变成 it.id选中判断变成 selectedId item.id。语义上你选中的不再是「第几行」而是「哪一条数据」。当列表前插一条新数据时原来的数据整体后移但 id 不变选中态依然粘在正确的数据上。如果你做的列表涉及排序、筛选建议从一开始就用 id 方案避免后来返工。3.3 单选场景的边界问题重复文案、快速点击与空状态第一个边界是列表里存在重复文本。文本相同但 id 不同的两条数据如果只用文本做 key点击第二条时 item key 重复LazyColumn 会打出「Key was already used」的运行时异常甚至出现渲染错乱。因此 key 必须选择唯一字段id 优先其次才是组合出来的唯一键。第二个边界是快速连续点击。看这段处理var lastClickIndex by remember { mutableIntStateOf(-1) } onItemClick { index - if (lastClickIndex ! index) { lastClickIndex index viewModel.select(index) } }这个判断的意义是当用户连续点击同一行时不再向 ViewModel 重复写入相同值避免无谓重组。同样的逻辑也可以写在状态更新处if (selectedIndex ! index) { selectedIndex index }。写状态前先判等能让 Compose 跳过多余的重组。第三个边界是空列表。LazyColumn 本身可以处理空数据不会崩但 UI 上要有一个「暂无数据」的提示位。做法是在 LazyColumn 外判断 items.isEmpty() 时显示一个居中的 Text不然用户面对白屏会以为是加载卡住。这个判断属于列表项目的习惯不属于复杂逻辑。注意单选列表如果需要在进程重建后恢复选中状态建议把 selectedId 同步写入 SavedStateHandle 或本地存储。直接依赖 ViewModel 内存对象进程被杀后选中状态会回到初始值。4. 多选列表实现状态容器选错滚动一次就翻车多选列表的复杂度比单选高一个量级其核心不在 Checkbox 的写法而在「选中状态用什么容器」以及「容器变化后 Compose 是否真的感知到了」。许多压缩包里给出的示例非常简单直接跑没问题但一旦加入全选、反选、网络刷新问题就暴露出来。4.1 先核对 Checkbox 的签名网上不少 Demo 编译不过Compose Material3 的 Checkbox 只有三个核心参数checkedBoolean、onCheckedChange((Boolean) - Unit)?、modifier。它没有 label 参数。网络上一些教程把 Checkbox(checked, onCheckedChange, label { Text(item) }) 写出来实际上是套用了 Web 端表单组件的习惯放到 Compose 里直接编译报「Unresolved reference: label」。如果你照着网上的 Demo 敲了一遍发现编译不过先检查是不是这里出了问题。正确做法是把文本放到 Checkbox 外面Row( modifier Modifier .fillMaxWidth() .clickable { onToggle(item.id) } .padding(horizontal 16.dp, vertical 8.dp), verticalAlignment Alignment.CenterVertically ) { Checkbox( checked selectedIds.contains(item.id), onCheckedChange { onToggle(item.id) } ) Text( text item.name, modifier Modifier.weight(1f) ) }注意这里 onCheckedChange 的 Boolean 参数直接忽略改用自己的 onToggle 回调因为整行点击已经能切换状态Checkbox 内部的布尔参数和外部实际的选中集合可能产生短暂不一致。让行点击与 Checkbox 点击都指向同一个「切换」就避免了双触发冲突。4.2 Set 与 mutableStateListOf两种状态容器的选型多选状态容器有两条常见路线。第一条是用 MutableSet 包在 MutableState 里val selectedIds by remember { mutableStateOf(setOfString()) } fun toggle(id: String) { selectedIds if (id in selectedIds) { selectedIds - id } else { selectedIds id } }优点contains 在 Set 上是 O(1)全选反选直接换集合缺点每次增删都产生新 Set 对象数据量大时内存分配压力明显。第二条路线是用 mutableStateListOfval selectedIds remember { mutableStateListOfString() } fun toggle(id: String) { if (id in selectedIds) selectedIds.remove(id) else selectedIds.add(id) }优点只做元素级增删Compose 能精确知道哪个元素变化缺点contains 是线性查找数据超过几百条时每次判断都要遍历。经验判断选项数量小于 200 的列表用 mutableStateListOf 顺手超过 200 或者涉及频繁全选反选换成 Set mutableStateOf。如果是在 ViewModel 里维护推荐写成这样class MultiSelectionViewModel : ViewModel() { private val _selectedIds mutableStateOf(setOfString()) val selectedIds: StateSetString _selectedIds fun toggle(id: String) { _selectedIds.value if (id in _selectedIds.value) { _selectedIds.value - id } else { _selectedIds.value id } } }逻辑说明ViewModel 里用 mutableStateOf 包集合状态变化自动驱动 Compose 重组。在 Composable 里收集状态时读取 viewModel.selectedIds.value 或使用 by 委托。需要特别注意直接在 ViewModel 里 new 一个集合对象但不包进状态容器Compose 完全感知不到变化。4.3 全选、反选、清空与刷新合并四种操作的原子化写法全选与反选最容易写错的地方是直接用同一个集合边遍历边改。这里给出四个操作的稳定写法fun selectAll(ids: ListString) { _selectedIds.value ids.toSet() } fun invertSelection(allIds: ListString) { val current _selectedIds.value _selectedIds.value allIds.filter { it !in current }.toSet() } fun clearSelection() { _selectedIds.value emptySet() } fun syncAfterRefresh(visibleIds: ListString) { val valid visibleIds.toSet() _selectedIds.value _selectedIds.value.filter { it in valid }.toSet() }逻辑说明selectAll 直接重建集合避免边遍历边 add 产生的重复元素。invertSelection 用 filter 生成「未选中集合」再整体替换。syncAfterRefresh 是网络刷新后最容易被漏掉的逻辑服务端返回新列表原来选中的数据可能已被删除如果不过滤selectedIds 里会残留垃圾 id。过滤的语义是「保留仍然存在的选中项丢弃已消失的」而不是「全不选」。参数说明这四个函数接收的 ids 都是「当前页面可见的所有 id」不是全量历史数据。如果你有分页加载全选只应针对当前已加载页不要试图把所有页的 id 都拉到内存里否则数据量上来后内存与计算都会失控。4.4 为什么记忆中 Set 改了界面不动一个非常典型的报修问题「我明明把 Set 的元素删了点击后界面没有任何变化」。原因几乎都是用了普通 MutableSet 而不是被 Compose 观察的状态容器。普通集合操作不会触发重组只有 MutableState 或 SnapshotStateList 的变更才会。检查顺序是第一眼看集合类型第二眼看是否被 mutableStateOf/remember 包裹第三眼看修改时是否生成了新实例。三步都过了界面仍然不动再看事件回调有没有真的执行比如点击回调是否被上层拦截。另一个容易被忽视的点是 Set 的相等性。Compose 重组时比较的是状态值两个内容相同的 Setequals 返回 true界面就不会刷新。所以不要在小范围内用「先 clear 再 addAll」的方式修改同一个 Set 对象这会让 Compose 认为状态没变化。要么整体替换要么用 mutableStateListOf 做元素级操作。提示多选列表如果是长页面的局部组件并且选择结果要提交到服务端建议把选中集合放进 ViewModel同时用 SavedStateHandle 持久化否则页面旋转一次选中的勾就全没了。5. 单选多选列表常见问题排查五个实测翻车现场这一章列出我在复现类似列表项目时最常踩的五个问题。每一条都按「现象 → 原因 → 解决」的顺序写方便你在现场照着定位。5.1 滚动之后选中状态错位现象列表滚出屏幕再滚回来原本选中的第一行变成了第三行或者多选里好几个不该选中的被选中。原因item 短暂持有状态或者选中判断依据用的是 position 而非稳定标识。Compose 的 LazyColumn 会回收屏幕外的 item当 item 重新进入屏幕时如果它内部保存了一份「当前选中」的记忆这份记忆会跟着新绑定的数据跑到别的位置上。解决把所有选中状态上提到 LazyColumn 之外item 内不写任何 remember。具体到代码item 唯一的职责是根据外部参数计算「我是否选中」再通过回调通知外部状态更新。同时把 itemsIndexed 的 key 从 index 改为稳定 id。这两件事做完滚动带来的错位基本消失。5.2 整行点击无反应但点 Checkbox 有反应现象用户想点行内空白区域切换选中手指戳下去毫无反应精确点中 Checkbox 却正常。原因可点击范围只挂在 Checkbox 上行本身没有挂 clickable或者挂了但被 Spacer 拦截。Compose 默认只对挂载了 clickable 的区域响应触摸Row 上如果没有 clickable文本区域天然不可点。解决把 Modifier.clickable 放到 Row 上让整行都成为点击目标。注意如果 Row 内部还有独立的手势修饰符事件优先级可能冲突。优先检查 clickable 是否放在 Row 的 modifier 链中而不是放在子组件里。放好后再验证 padding 区域是否也被点击覆盖。5.3 插入一条数据后选中的是另一行现象列表最前面新增一条记录视觉上高亮却跑到了第二行点第二行实际提交时选中的是第一行的数据。原因用下标 index 同时充当 key 与选中标识。插入后所有元素下标加一Compose 复用缓存时按 key 匹配旧 key 0 的位置坐上了新数据选中判断随之错位。解决改用数据自带的唯一 id 作为 key 和选中标识。单选列表用 selectedId 而非 selectedIndex多选列表用 selectedIds 集合而非布尔数组。改完后数据前插后插都不会影响选中语义。如果数据本身没有 id 字段生成列表时自行构建一个稳定的唯一标识。5.4 快速点击时出现重影、闪烁现象连续快速点击某个 Checkbox界面上出现短暂双勾或行背景闪烁。原因一次点击的事件同时触发了整行 clickable 和 Checkbox 自己的 onCheckedChange状态被反转两次。另一种情况是连续快速点击让切换逻辑执行了多次而 Compose 的重组是异步合并的中间态被渲染出来肉眼看到闪烁。解决统一点击入口。整行可点就不给 Checkbox 单独设 onCheckedChange或者反过来只有 Checkbox 可点。如果逻辑上两者都需要把切换动作收敛到一个函数里保证同一次点击不会产生两次翻转fun toggle(id: String) { if (id in selectedIds) selectedIds.remove(id) else selectedIds.add(id) }这个 toggle 函数是幂等的无论事件来自行点击还是 Checkbox结果都一样。快速连点时再在状态写入前加 if 判等进一步减少无谓重组。重影通常就不再出现。5.5 编译报错Compose 编译器与 Kotlin 版本不匹配现象clean 之后编译错误列表里出现大量与 Composable 相关的报错比如「Type mismatch: inferred type is ... but Composable is expected」有时还伴随「Cannot find implementation for androidx.compose.runtime.Composer」。原因Kotlin 版本升级后没有同步升级 Compose 编译器。Compose 编译器以 Kotlin 编译器插件形式运行编译器版本与 Kotlin 版本必须配套material3 库的版本也会间接要求最低 compose 编译器版本三者形成一张强约束表。解决查看 gradle/libs.versions.toml把 composeCompiler 版本对齐到当前 Kotlin 官方支持的对应版本。稳定做法是查官方版本映射表而不是随手把 kotlin 升到 latest。若项目没有显式配置 composeCompiler在 app/build.gradle 的 composeOptions 里指定 kotlinCompilerExtensionVersion 也能解决问题。对齐后记得重新同步 Gradle。如果上面五条都没有命中你的现场还有一个通用排查顺序先看状态容器是否被观察再看 key 是否稳定再看点击回调是否收敛最后看混淆与编译配置。大多数列表选中问题都出在这四类里按顺序查一遍基本能定位。6. 把选中逻辑封装成 SelectionController复用到第二个页面的最后一步前几章的代码都在页面层展开一旦第二个页面也要做同样的单选多选复制粘贴就来了。我的习惯是把「选择状态」抽成一个与 UI 无关的控制器Composable 只负责渲染与回调。这样既方便测试又能直接放进 libs 作为公共组件。6.1 一个与 Compose 无关的选择控制器class SelectionControllerT { private val selectedItems mutableStateListOfT() val selected: ListT get() selectedItems.toList() fun toggle(item: T) { if (selectedItems.contains(item)) { selectedItems.remove(item) } else { selectedItems.add(item) } } fun isSelected(item: T): Boolean selectedItems.contains(item) fun clear() selectedItems.clear() } Composable fun rememberSelectionController(): SelectionControllerString { return remember { SelectionController() } }这个控制器解决的是「多选状态与 UI 解耦」。它有一个约束T 必须具有正确的 equals/hashCode通常直接传数据 id 即可。如果你希望单选也能复用同一套思路可以把 selectedItems 换成 MutableStateT?再补一个 select 方法其余 API 保持一致。封装后页面里的 Composable 只需要读 isSelected 和调用 toggle逻辑部分可以单独写单元测试不需要起模拟器。6.2 用滚动回读与插入验证封装可靠性验证这个封装是否可靠我的做法是写一个极小的 Composable 测试页准备 50 条模拟数据随机点十几次然后滚动到底再滚动回来把 controller.selected 的内容打出来逐条核对。如果滚动前后集合内容一致说明 item 的键与选中判断没有串。再加一项极端验证在列表头部插入三条数据后再检查选中集合确认没有发生位移。这两步通过组件才算真的可以复用。在那之后我每次写 Compose 列表单选多选都会强制自己先过一遍这三件事选中状态是不是提升到了 item 外、key 是不是稳定 id、状态变更是不是走同一个入口。没想清楚就先不写布局等逻辑立住再补 UI。这套习惯帮我省掉了大量滚动串选和点击闪烁的返工希望帮到你。本文还有配套的精品资源点击获取