
1. 为什么单选组件值得单独拿出来写一篇先说个背景。Jetpack Compose 从 2021 年稳定到现在很多团队已经用它重写了业务页面但每次我看到网上流传的 Material 3 单选示例十个里有八个还把RadioButton当“半成品按钮”在拼——selected 状态自己管、点击事件只挂在图标上、颜色写死成主题蓝色。真到了线上项目里这套用法跑通没问题但离“能用”和“好用”之间还差着一大截。RadioButton在 Material 3 里是个典型的“小控件大讲究”。它不管理自己的选中状态这意味着你要彻底理解状态提升它的点击区域默认只有圆形图标那一小块这意味着你要自己处理整行点击它的颜色体系绑定了MaterialTheme.colorScheme这意味着你只要用错配色层界面就会显得脏。这篇文章不打算给你堆 API 文档而是从实际业务出发把单选组件的正确姿势、定制技巧和踩过的坑一次讲清楚。适合谁看刚接触 Compose 想搞懂基础用法的初学者以及已经写了半年一年 Compose、想把交互细节打磨得更专业的进阶开发者。读完你至少能解决三个问题怎么正确管理单选状态、怎么让整行点击都生效、怎么在保持 Material 3 规范的前提下做视觉定制。2. 基础用法先搞清楚 RadioButton 的“无状态”设计2.1 为什么官方要把状态交给外部管理先看最原始的例子。你直接写RadioButton(selected true, onClick {})页面上会出现一个选中的圆形按钮点击它却毫无反应。这第一次接触时确实让人懵怎么按钮点了不动这不是 bug而是 Compose 的刻意设计。RadioButton是一个无状态stateless组件它只负责“展示当前状态”和“通知外部有点击发生”至于状态要不要改变、变成什么样完全由调用方决定。换句话说它像一张投票选票只负责勾选项展示选谁、改不改选票都是你的事。这种设计的好处是你拥有绝对控制权你可以做“点击后三秒内不允许再切换”的限流可以做“切换前弹确认框”的二次校验也可以做“来自服务器的状态不允许本地直接改”的权限控制。如果组件把状态藏在内部这些逻辑全都塞不进去。对应的写法是这样的var selected by remember { mutableStateOf(false) } RadioButton( selected selected, onClick { selected true } )用一个mutableStateOf作为唯一数据源组件只负责读取和回调状态流转的主动权始终在外部。这个模式在 Compose 里叫“状态提升”不光是RadioButtonCheckbox、Switch也都遵循同样的设计逻辑。一旦你接受了这套思路后面所有单选联动都顺理成章。2.2 最简示例三个选项的单选列表业务里很少只有一个孤零零的RadioButton更多的是一组互斥选项比如性别选择、物流方式选择、支付方式选择。这时的核心问题是怎么保证“选 A 时 B 自动取消”。实操上讲你需要一个String或枚举类型的状态变量来记录“当前选中项”而不是给每个RadioButton单独配一个Boolean。后者是最典型的初学者错误——三个选项三个变量同步逻辑全靠手动置 false选项一多代码又臭又容易漏。正确做法是“单状态 双等号判等”val options listOf(顺丰速运, 中通快递, 圆通速递) var selectedOption by remember { mutableStateOf(options[0]) } Column { options.forEach { option - Row( verticalAlignment Alignment.CenterVertically, modifier Modifier .fillMaxWidth() .selectable( selected option selectedOption, onClick { selectedOption option } ) .padding(horizontal 16.dp, vertical 12.dp) ) { RadioButton( selected option selectedOption, onClick null ) Text( text option, style MaterialTheme.typography.bodyLarge, modifier Modifier.padding(start 8.dp) ) } } }注意这里有两个关键手法第一RadioButton的onClick传的是null不是空 lambda。onClick为 null 时组件处于纯展示模式不再消费任何点击事件整行的点击完全交给Row.exectable()处理。这样既保证了点击热区覆盖整行又避免事件重复触发。第二选中状态用option selectedOption这个表达式驱动而不是在forEach里写if (option selectedOption) true else false。后者逻辑没错但代码冗余程度高前者是 Kotlin 里最自然的写法也是个“一眼就能看懂”的惯用法。多说一句Modifier.selectable是 Material 组件里专门为列表项设计的修饰符它内部集成了indication点击涟漪效果和selectableGroup语义比在Row上手动加clickable更规范。如果你用的是旧版 Compose1.4 之前的 Material 库没有selection重载版本那就只能退回clickable 手动RadioButton(onClick null)的方案效果差不多但语义支持差一些。2.3 remember 和 rememberSaveable 怎么选上面的例子用了remember但实际项目中我建议默认用rememberSaveablevar selectedOption by rememberSaveable { mutableStateOf(options[0]) }区别在于屏幕旋转、系统语言切换、进入后台被系统回收时remember保存的状态会全部丢失页面回退到初始值rememberSaveable会把状态写入Bundle配置变更后自动恢复。对单选这种“用户可能已经选到第五项”的交互丢失状态是非常恶劣的体验。尤其是支付方式选择这种关键路径用户转个屏就要重选投诉都来了。有个小坑rememberSaveable要求状态值可放入Bundle。对于自定义枚举默认情况下mutableStateOf(MyEnum.A)是没法直接自动存入 Bundle 的因为SaverKt不认识你的枚举。解决办法两种要么用rememberSaveable(stateSaver Saver(...))自定义 Saver要么干脆在界面层用一个可序列化的String记录枚举的name展示时再enumValueOf转回来。var selectedMethod by rememberSaveable { mutableStateOf(PayMethod.ALIPAY.name) } Row( modifier Modifier .fillMaxWidth() .selectable( selected PayMethod.ALIPAY.name selectedMethod, onClick { selectedMethod PayMethod.ALIPAY.name } ) ) { RadioButton(selected PayMethod.ALIPAY.name selectedMethod, onClick null) Text(支付宝) }这样既避免了写 Saver 的麻烦也保证了状态可恢复。在大项目里我还会把选中值换成key存储在 ViewModel 里配合 Flow 使用那是另一个话题但这里的思路是一致的选中的“唯一事实来源”必须稳定、持久、单一。3. 进阶定制从 Material 3 规范到真实业务改版3.1 RadioButton 的 API 参数逐个拆解Material 3 的RadioButton签名长这样不同版本参数略有增减但核心就这些Composable fun RadioButton( selected: Boolean, onClick: (() - Unit)?, modifier: Modifier Modifier, enabled: Boolean true, colors: RadioButtonColors RadioButtonDefaults.colors(), interactionSource: MutableInteractionSource? null )逐项看selected——布尔值控制圆圈内部是否出现圆点也就是“选中态”的视觉表达。onClick——刚才说过的点null 表示纯展示不消费点击非 null 时点击会触发回调。这里有个细节非 null 时组件自带点击涟漪效果但如果外层已经有selectable或clickable两层涟漪会叠出奇怪的双水波纹。这就是为什么我强烈建议“外层用 selectable 处理整行点击内层 onClick 传 null”。enabled——禁用态。false 时按钮变灰不响应任何点击。会连带触发disabled系列的配色。colors——控制四态配色等下专门展开讲。interactionSource——很多时候被忽略。它是MutableInteractionSource负责收集按压、悬停、聚焦等交互事件。如果你要自己在组件外做“按压变色动画”就必须传入这个引用val interactionSource remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() RadioButton( selected selected, onClick onClick, interactionSource interactionSource ) // 外部可以根据 isPressed 做额外的视觉反馈 if (isPressed) { ... }要注意的是同一个interactionSource不能同时挂在多个组件上也不能把一个已经被collectIsPressedAsState观察的 source 随意重建否则交互状态会丢失。3.2 四态配色别再用写死的颜色了Material 3 的RadioButtonDefaults.colors()是整套组件配色的入口包含四个维度状态参数名说明选中 可用selectedColor默认取colorScheme.primary未选中 可用unselectedColor默认取colorScheme.onSurfaceVariant选中 禁用disabledSelectedColor默认是onSurface加 0.38 透明未选中 禁用disabledUnselectedColor默认是onSurface加 0.38 透明这组默认值放在一起看其实是成套的正常时用主色和次强调色区分主次禁用时统一 38% 透明让用户一眼识别“这个选项不能点了”。Material 3 的透明度规范里禁用态普遍使用 38% 这个数值——按钮、文本、组件通用保持整屏视觉一致性。业务定制时最关键的一条不要直接写Color(0xFF6750A4)这类的硬编码而是从MaterialTheme.colorScheme里取。原因是主题切暗色、换肤、品牌色调整时所有页面自动跟随变化不会出现“换肤后某几个页面颜色还是旧的”的尴尬RadioButtonDefaults.colors( selectedColor MaterialTheme.colorScheme.primary, unselectedColor MaterialTheme.colorScheme.onSurfaceVariant, disabledSelectedColor MaterialTheme.colorScheme.onSurface.copy(alpha 0.38f), disabledUnselectedColor MaterialTheme.colorScheme.onSurface.copy(alpha 0.38f) )如果你只想替换选中色保留其他默认用copy比较省事RadioButtonDefaults.colors( selectedColor MaterialTheme.colorScheme.tertiary )RadioButtonDefaults.colors()是一个带默认参数的函数只覆盖传入项其余保持主题默认。这是 Material 3 组件设计里非常统一的模式通信其他组件时也要记住——CheckboxDefaults.colors()、SwitchDefaults.colors()都遵循同样的覆盖逻辑。3.3 尺寸调整的两个层次RadioButton没有直接暴露 size 参数这是很多从 View 系统跑来的人不习惯的点。在 Compose 里调整尺寸有两个层次的手段第一层用Modifier.scale做整体缩放。这最粗暴视觉上按钮变大但占用的布局空间还是原来的旁边文字间距可能对不齐。适合做“图标整体放大”的纯视觉需求RadioButton( selected selected, onClick onClick, modifier Modifier.scale(1.5f) )第二层用Modifier.size直接指定尺寸。RadioButton内部是圆形 Canvas 绘制外部用size约束时圆会撑满指定区域。比如 32dp 的圆形图标RadioButton( selected selected, onClick onClick, modifier Modifier.size(32.dp) )注意底色问题直接size后按钮的背景圆会跟着变化但涟漪范围、语义点击区域是否同步放大不同版本表现不太一样。建议如果是 Tablet 大屏适配把整行的高度统一拉高而不是只调图标Row( verticalAlignment Alignment.CenterVertically, modifier Modifier .fillMaxWidth() .defaultMinSize(minHeight 56.dp) .selectable( selected selected, onClick onClick ) .padding(horizontal 16.dp) ) { RadioButton(selected selected, onClick null) Text(...) }Android 的 Material 规范里可点击列表项最小高度为 48dp紧凑布局时才允许降到 40dp。用defaultMinSize保证高度达标比单独调整 RadioButton 尺寸更符合规范。3.4 圆角涟漪与形状细节的坑Material 3 的RadioButton涟漪效果默认是圆形。如果你在列表项上用了Clip(RoundedCornerShape(...))或clipToBounds()涟漪会被裁剪看起来像“水波纹被切了一刀”。这种情况其实不是组件的 bug而是父级裁剪导致。解决方案是在RadioButton自身层级控制涟漪范围不要依赖外层裁剪Modifier .clip(RoundedCornerShape(28.dp)) .selectable(...)把裁剪放到selectable之前的修饰链上涟漪就被约束在圆角矩形内视觉上顺滑许多。另一个小细节是RadioButton的选中圆点与圆形边框之间Material 3 规范里有个固定比例关系圆点直径约等于整个组件直径的 45% 左右。这些比例内部已经写死除非你完全自定义绘制否则不需要也不应该手动调整。4. 语义与无障碍让 TalkBack 正确播报单选组4.1 selectableGroup把一组 RadioButton 绑成一个语义整体很多开发者不知道单选列表在屏幕阅读器TalkBack里有个特殊语义整组应该被识别为一个“单选群”用户听到的焦点逻辑是“选项 1已选中单选组共 3 项”而不是三个独立的普通按钮。Compose 提供Modifier.selectableGroup()来完成这件事加在承载选项组的父容器上Column( modifier Modifier.selectableGroup() ) { options.forEach { option - Row( modifier Modifier .fillMaxWidth() .selectable( selected option selectedOption, onClick { selectedOption option } ) ) { RadioButton(selected option selectedOption, onClick null) Text(option) } } }注意这里Row上的Modifier.selectable(...)本身就提供了Selected语义再配合父级的selectableGroup()TalkBack 就能正确宣布“单选组里的第几项被选中”。如果你用的是自己写死点击的版本比如clickable 手动状态语义会丢失TalkBack 会把每个选项当成普通按钮读用户根本不知道这是单选还是多选。4.2 自定义语义朗读文本实际项目中会发现一个问题朗读出来的内容可能是“已选择顺丰速运”这类拼接语序不自然。这时可以用Modifier.semantics自定义Row( modifier Modifier .fillMaxWidth() .selectable( selected option selectedOption, onClick { selectedOption option }, role Role.RadioButton ) .semantics { stateDescription if (option selectedOption) 当前选中 else 未选中 } ) { RadioButton(selected option selectedOption, onClick null) Text(顺丰速运) }stateDescription是 TalkBack 在播报时附加的状态描述你写成“当前选中”或“未选中”比系统默认拼接自然多了。role Role.RadioButton则强制告诉无障碍服务“这是一个单选按钮”避免某些版本把它识别成普通按钮。另外如果选项文字本身已经能表达含义比如“顺丰速运”那就不需要额外设置contentDescription直接让文本参与朗读即可。如果选项里只有图标没有文字才需要给 Row 手动加contentDescription。4.3 焦点与键盘导航的潜在问题Compose 里Modifier.selectable()自带焦点头韵DPad 方向键或 Tab 键可以上下移动焦点。但要注意如果某个RadioButton还挂着onClick {}非 null它自己也会变成一个可聚焦控件与 Row 的焦点产生冲突。轻则键盘导航停在一个选项上进退两难重则 TalkBack 焦点顺序错乱。最稳妥的做法就是我前面反复强调的列表项用selectable统一承载点击与焦点内层RadioButton一律onClick null。这一条同时解决点击热区、涟漪重叠、无障碍歧义三个问题。5. 实战踩坑记录与排查清单再说几个我在项目里真实遇到过的坑这节我当“值班笔记”写以后排查时可以对着翻。5.1 点击 Row 没反应事件的“消费顺序”陷阱有过一次线上反馈“快递方式选项整行点了没反应必须精准点中圆圈才行”。排查后确认问题出在Row上挂了一个带clickable的圆形头像或图标它的点击处理把事件消费掉冒泡不到Row.selectable上。Compose 的点击事件遵循“从子到父”的交互捕获流程子组件如果声明了clickable且返回了消费状态父级就不再收到事件。解决思路是列表项内不要放多个可点击区域。图标若必须点击比如快递详情跳转就把整个选项拆成两行或加一个明显的“图标区”而不是让整行和图标同时可点。排查方法很笨但有效逐个注释子组件找到哪个子组件抢走了事件或者给行内元素统一去掉clickable只保留行级selectable。5.2 rememberSaveable 在自定义数据类上的崩溃前面提到枚举的问题实际上如果直接rememberSaveable { mutableStateOf(PayMethodData(...)) }运行中触发保存时会直接IllegalArgumentException崩溃报错信息显示“cannot be saved”。这是因为普通数据类没有实现Parcelable也没有自定义 Saver。线上项目里我吃过一次亏用户旋转屏幕直接 crash。后来统一规范为ViewModel StateFlow存业务状态rememberSaveable只存 UI 临时状态如当前滚动位置、展开收起标识。单选状态这种半业务性的数据简单场景用String存 name 就可以了。5.3 在 LazyColumn 里用 RadioButton 的状态粘连在LazyColumn中组合RadioButton时如果状态直接写在 item 的 composable 内部并用remember { mutableStateOf(...) }管理列表滚动回收时会出现“选 A 滚走再回来变成选 B”的错乱。原因很简单remember跟组合生命周期走item 回收后状态丢了。解决方案同样是状态提升到列表外或 ViewModelvar selectedId by rememberSaveable { mutableStateOfString?(null) } LazyColumn { items(options, key { it.id }) { option - Row( modifier Modifier.selectable( selected option.id selectedId, onClick { selectedId option.id } ) ) { RadioButton(selected option.id selectedId, onClick null) Text(option.name) } } }key { it.id }也建议加上它让列表项在数据变化时稳定对应到组合避免索引错位导致的状态漂移。5.4 涟漪动画卡顿interactionSource 的重建问题有个少见但很气人的问题RadioButton点击后涟漪动画不出现或者出现一半就消失。检查时发现外层列表的Composable里每次重组都remember { MutableInteractionSource() }看似 remember 了但包在if分支里状态切换时组件被重建了interactionSource 也随之重建涟漪动画自然就断了。interactionSource必须稳定存在于整个交互过程。如果你在RadioButton和外部collectIsPressedAsState之间传了它记得用remember在组件外单独持有val interactionSource remember { MutableInteractionSource() } RadioButton( selected selected, onClick onClick, interactionSource interactionSource )不要把它放进RadioButton的参数默认表达式里重新创建。5.5 常见问题速查表现象原因排查方向点击圆圈变灰但选中态不变state 没有被正确更新检查onClick是否真的改变了外部状态变量整行点不中只有圆圈能点Row 上没有selectable/clickable把点击事件提升到 Row 层级点击后出现双重涟漪内外两层都在消费点击内层RadioButton传onClick null屏幕旋转后选中项重置用的是remember换成rememberSaveableTalkBack 读不出“单选组”概念缺少selectableGroup在父容器加Modifier.selectableGroup()列表滚动后选中态错乱状态存在 item 内部提升状态至列表外部或 ViewModel涟漪动画断断续续interactionSource 被重建用remember稳定持有6. 给我的几个习惯用法做个小结最后分享一点我自己的编码习惯。写单选列表时我基本固定用一个封装函数把所有坑都埋进去团队里其他人拿到后基本零学习成本Composable fun T SingleChoiceRow( option: T, selected: Boolean, label: String, onSelect: () - Unit, modifier: Modifier Modifier ) { Row( verticalAlignment Alignment.CenterVertically, modifier modifier .fillMaxWidth() .selectable( selected selected, onClick onSelect, role Role.RadioButton ) .padding(horizontal 16.dp, vertical 12.dp) ) { RadioButton(selected selected, onClick null) Text( text label, style MaterialTheme.typography.bodyLarge, modifier Modifier.padding(start 12.dp) ) } }这个封装把“整行点击、语义完整、无双重事件消费”全部默认做好业务侧剩下的只有“传入选项、对比状态、更新状态”三件事。我之前走过一段弯路总觉得RadioButton是入门级控件不值得花时间研究。直到它在一个支付选择页面上接二连三出问题——点击热区太小被 PM 质疑、TalkBack 读不出分组信息被无障碍测试打回、横竖屏切换选中的支付方式丢失被用户投诉——才彻底意识到越基础的组件越值得把交互细节磨透。如果你正在做单选列表相关的功能不妨按照这篇文章的路径先检查一下状态是不是提升到了合适的位置点击热区是不是覆盖到了整行语义是不是完整。把这三个地方理顺了你的单选组件就已经超过大多数项目的实现质量了。