1. 从一个被低估的控件说起
做 Android 开发这些年,如果要我列一个"被新手低估、被老手忽视"的控件排行榜,RadioButton绝对能排进前三。很多人第一次接触它,就是在布局文件里拖几个出来,配个RadioGroup,跑起来能选中、能切换,然后就觉得"这玩意儿我会了",转头去研究 RecyclerView 和 Compose 了。结果真到了项目里,产品经理甩过来一张设计稿——圆角胶囊状的选项卡、带图标的分段选择器、选中时边框渐变、未选中时灰色描边——瞬间就懵了,回头翻文档发现RadioButton原生样式根本撑不起这个需求。
这篇内容就是冲着这个痛点来的。我会把RadioButton从最基础的用法一路讲到高级样式的定制,包括button属性、selector状态选择器、自定义Drawable、RadioGroup的OnCheckedChangeListener与OnClickListener的区别、动态添加与移除、以及几个我在实际项目里踩过的坑。适合刚入门 Android 一两周的新手,也适合做了两三年但一直没系统整理过这个控件的朋友。看完之后,你应该能独立做出设计稿里 90% 以上的单选类 UI,而不是每次都去网上抄一段看不懂的 XML。
先说清楚一个概念,RadioButton本质上是CompoundButton的子类,和CheckBox是亲兄弟,区别在于它默认和RadioGroup配合使用时具备互斥特性——同一组里只能选一个。这个"互斥"不是RadioButton自己实现的,而是RadioGroup在背后做管理。理解这一点非常关键,后面讲动态添加、讲监听器差异的时候,全都绕不开它。
2. 基础用法与核心机制拆解
2.1 最简可用的一套写法
先给一个能直接跑起来的最小示例,布局文件里这样写:
<RadioGroup android:id="@+id/rg_gender" android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="horizontal"> <RadioButton android:id="@+id/rb_male" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="男" /> <RadioButton android:id="@+id/rb_female" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="女" /> </RadioGroup>代码里获取选中值:
RadioGroup rgGender = findViewById(R.id.rg_gender); rgGender.setOnCheckedChangeListener((group, checkedId) -> { if (checkedId == R.id.rb_male) { // 选中了男 } else if (checkedId == R.id.rb_female) { // 选中了女 } });这段代码没什么难度,但有几个细节新手经常忽略。第一,RadioGroup的orientation默认是vertical,如果你想要横排必须显式写horizontal。第二,RadioButton的id必须设置,否则checkedId拿不到有效值,监听器里判断会失效。第三,如果你在 XML 里给某个RadioButton设置了android:checked="true",那么它所在的RadioGroup会以它作为初始选中项,但如果你给多个都设了true,只有最后一个生效——这个行为在不同版本上表现略有差异,稳妥做法是只给一个设true,或者干脆在代码里用check()方法指定。
2.2 RadioGroup 到底做了什么
很多人以为互斥是RadioButton自带的,其实不是。RadioGroup继承自LinearLayout,它重写了addView方法,在子 View 被添加时检查是不是RadioButton,如果是就给它设置一个内部的OnCheckedChangeListener。当某个子按钮被选中时,RadioGroup会遍历所有子按钮,把其他的checked状态置为false,从而实现互斥。
这个机制带来两个实际影响。一是如果你把RadioButton放在RadioGroup外面,它就不会互斥,可以同时选中多个,这时候它表现得就像个CheckBox。二是如果你动态往RadioGroup里addView一个RadioButton,互斥逻辑依然生效,因为addView被重写了。但如果你用removeView移除当前选中的那个,RadioGroup的checkedId会变成 -1,监听器会回调一次checkedId == -1,这个坑后面会细说。
还有一个容易混淆的点:RadioGroup的check()方法会触发OnCheckedChangeListener,而clearCheck()也会触发,回调的checkedId是 -1。如果你在监听器里做了网络请求或者页面跳转,一定要先判断checkedId != -1,否则会出现莫名其妙的空指针或者重复请求。
2.3 两种监听器的本质区别
这是新手最容易搞混的地方,我单独拎出来讲。RadioGroup有两个监听器接口:OnCheckedChangeListener和OnClickListener(继承自 View)。它们的触发时机完全不同。
OnCheckedChangeListener只在选中状态发生变化时触发。也就是说,如果某个按钮已经是选中状态,用户再点它一次,这个回调不会触发。而OnClickListener是每次点击都触发,不管状态有没有变。
这个差异在什么场景下重要?举个例子,你做了一个"性别选择",用户已经选了"男",又点了一次"男",如果你用OnCheckedChangeListener,什么都不会发生;如果你用OnClickListener,会再触发一次。再比如你做的是一个"确认提交"类的单选,用户重复点击同一个选项可能意味着"我确认这个选择",那你就需要OnClickListener。
实际项目里我的建议是:单纯获取选中值用OnCheckedChangeListener,需要响应每次点击(比如埋点、动画、二次确认)用OnClickListener。两个可以同时设置,互不干扰。
3. 高级样式定制的完整路径
3.1 为什么原生样式不够用
原生RadioButton在 Material 主题下长这样:左边一个圆形图标,选中时填充主题色,未选中时灰色描边,右边是文字。这个样式在大多数场景下够用,但设计稿往往不满足于此。常见的定制需求有这么几类:
- 把圆形图标换成自定义图标,比如对勾、星星、心形
- 选中和未选中用不同的背景色或边框
- 整个按钮变成一个胶囊状的选项卡,图标和文字居中
- 去掉原生图标,只用背景色区分选中状态
- 选中时文字颜色也跟着变
这些需求的核心都指向同一个技术点:状态选择器(StateListDrawable)。Android 的Drawable体系支持根据 View 的状态(state_checked、state_pressed、state_enabled等)自动切换不同的资源,RadioButton的android:button和android:background都支持这种选择器。
3.2 用 selector 替换默认圆点
先做最简单的:把默认圆点换成一个自定义的对勾图标。在res/drawable/下新建rb_check_selector.xml:
<?xml version="1.0" encoding="utf-8"?> <selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:state_checked="true" android:drawable="@drawable/ic_check_on" /> <item android:state_checked="false" android:drawable="@drawable/ic_check_off" /> </selector>然后在布局里:
<RadioButton android:id="@+id/rb_option" android:layout_width="wrap_content" android:layout_height="wrap_content" android:button="@drawable/rb_check_selector" android:text="选项一" />这里有个细节要注意:android:button控制的是左侧那个图标,android:background控制的是整个按钮的背景。很多人第一次改样式会误以为改background就能换掉圆点,结果发现圆点还在,背景多了一层,就是这个原因。
另外,state_checked的顺序很重要。selector是从上往下匹配的,一旦匹配到就停止。所以state_checked="true"必须写在state_checked="false"前面,否则永远匹配不到选中状态。这个规则对所有状态选择器都适用,包括state_pressed、state_selected等。
3.3 胶囊选项卡样式的完整实现
这是设计稿里出现频率最高的一种样式,我把它拆成三步来做。
第一步,准备两个背景 Drawable。选中态bg_tab_selected.xml:
<?xml version="1.0" encoding="utf-8"?> <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> <solid android:color="#3F7EF7" /> <corners android:radius="24dp" /> </shape>未选中态bg_tab_normal.xml:
<?xml version="1.0" encoding="utf-8"?> <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> <solid android:color="#F2F3F5" /> <corners android:radius="24dp" /> </shape>第二步,写背景选择器bg_tab_selector.xml:
<?xml version="1.0" encoding="utf-8"?> <selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:state_checked="true" android:drawable="@drawable/bg_tab_selected" /> <item android:drawable="@drawable/bg_tab_normal" /> </selector>注意这里第二个item没有写state_checked="false",这是有意为之——作为兜底项,任何未匹配到的状态都用它。这种写法比显式写false更稳妥,因为RadioButton还有state_pressed、state_focused等状态,显式写false可能导致按下时背景闪一下。
第三步,文字颜色也要跟着变。在res/color/下新建text_tab_selector.xml:
<?xml version="1.0" encoding="utf-8"?> <selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:state_checked="true" android:color="#FFFFFF" /> <item android:color="#333333" /> </selector>布局里这样用:
<RadioButton android:id="@+id/rb_tab_a" android:layout_width="0dp" android:layout_height="48dp" android:layout_weight="1" android:background="@drawable/bg_tab_selector" android:button="@null" android:gravity="center" android:text="选项A" android:textColor="@color/text_tab_selector" />关键点在于android:button="@null",这一句把原生圆点彻底去掉。如果不写这句,圆点会叠在背景上,非常难看。另外android:gravity="center"让文字居中,配合layout_weight="1"实现等分宽度,这是做选项卡的标准套路。
3.4 带图标的分段选择器
有些设计稿要求选项里既有图标又有文字,选中时图标和文字都变色。这种需求比纯文字复杂一点,因为 Android 原生不支持直接给drawableStart设置颜色选择器。我的做法是用drawableTint(API 23+)配合textColor选择器。
<RadioButton android:id="@+id/rb_with_icon" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:background="@drawable/bg_tab_selector" android:button="@null" android:drawableStart="@drawable/ic_star" android:drawablePadding="6dp" android:drawableTint="@color/text_tab_selector" android:gravity="center" android:paddingVertical="10dp" android:text="收藏" android:textColor="@color/text_tab_selector" />drawableTint直接复用文字颜色的选择器,这样图标和文字会同步变色,省去单独写一套图标选择器的麻烦。如果你的minSdk低于 23,需要用AppCompatRadioButton配合app:drawableTint,或者用DrawableCompat.setTintList()在代码里处理。
提示:
drawableTint在部分国产 ROM 的低版本系统上有兼容性问题,如果发现图标不变色,优先检查是否用了AppCompatRadioButton,以及主题是否继承自Theme.AppCompat或Theme.MaterialComponents。
4. 动态管理与实战场景
4.1 动态添加 RadioButton 的正确姿势
实际项目里,选项往往来自接口返回,数量不固定,这时候就需要动态创建。标准写法是这样:
RadioGroup rg = findViewById(R.id.rg_dynamic); rg.removeAllViews(); for (int i = 0; i < dataList.size(); i++) { RadioButton rb = new RadioButton(this); rb.setId(View.generateViewId()); rb.setText(dataList.get(i).getName()); rb.setTag(dataList.get(i).getId()); RadioGroup.LayoutParams lp = new RadioGroup.LayoutParams( RadioGroup.LayoutParams.MATCH_PARENT, RadioGroup.LayoutParams.WRAP_CONTENT); lp.bottomMargin = dp2px(8); rb.setLayoutParams(lp); rg.addView(rb); }这里有几个坑必须说清楚。第一,setId一定要用View.generateViewId(),不要用循环变量i,因为i从 0 开始,而 0 是View.NO_ID,会导致checkedId判断异常。第二,LayoutParams要用RadioGroup.LayoutParams,用LinearLayout.LayoutParams在大多数情况下也能跑,但类型不匹配可能在某些定制 ROM 上出问题。第三,如果数据是复用的(比如列表滚动),记得先removeAllViews(),否则会越加越多。
4.2 动态添加后如何恢复选中状态
这是动态场景里最容易被忽略的一步。假设用户选了第二项,然后列表滚动导致 View 复用,或者页面重建,选中状态就丢了。解决办法是在添加完所有按钮后,根据保存的数据手动check:
for (int i = 0; i < rg.getChildCount(); i++) { View child = rg.getChildAt(i); if (child instanceof RadioButton) { Object tag = child.getTag(); if (tag != null && tag.equals(selectedId)) { ((RadioButton) child).setChecked(true); break; } } }注意这里用的是setChecked(true)而不是rg.check(id)。两者都能选中,但rg.check()会触发OnCheckedChangeListener,而setChecked()在RadioGroup内部也会触发。如果你在监听器里做了业务逻辑,恢复状态时可能会误触发一次。稳妥做法是加一个标志位:
private boolean isRestoring = false; rg.setOnCheckedChangeListener((group, checkedId) -> { if (isRestoring) return; // 正常业务逻辑 }); // 恢复时 isRestoring = true; rb.setChecked(true); isRestoring = false;这个模式我在多个项目里用过,非常稳。标志位的命名建议统一,方便团队协作时一眼看懂。
4.3 移除选中项引发的空指针
前面提过,removeView移除当前选中的RadioButton时,RadioGroup的checkedId会变成 -1,监听器会回调。如果你的监听器里直接拿checkedId去查数据,就会拿到 null。处理方式很简单:
rg.setOnCheckedChangeListener((group, checkedId) -> { if (checkedId == View.NO_ID) { // 当前没有选中项,直接返回 return; } // 正常逻辑 });View.NO_ID的值就是 -1,用常量比直接写 -1 更规范。这个判断建议作为模板代码,每次写OnCheckedChangeListener都加上,能省掉很多排查时间。
4.4 一个完整的"收货地址选择"实战
我把上面这些点串起来,做一个模拟的收货地址选择场景。需求是:地址列表从接口获取,单选,选中项高亮,支持动态刷新。
布局部分:
<RadioGroup android:id="@+id/rg_address" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" />代码部分:
private void renderAddressList(List<Address> list, String selectedId) { RadioGroup rg = findViewById(R.id.rg_address); rg.setOnCheckedChangeListener(null); // 先解绑,避免刷新时误触发 rg.removeAllViews(); for (Address addr : list) { RadioButton rb = new RadioButton(this); rb.setId(View.generateViewId()); rb.setTag(addr.getId()); rb.setText(addr.getName() + " " + addr.getPhone()); rb.setButton(null); rb.setBackgroundResource(R.drawable.bg_address_selector); rb.setPadding(dp2px(16), dp2px(12), dp2px(16), dp2px(12)); RadioGroup.LayoutParams lp = new RadioGroup.LayoutParams( RadioGroup.LayoutParams.MATCH_PARENT, RadioGroup.LayoutParams.WRAP_CONTENT); lp.bottomMargin = dp2px(10); rb.setLayoutParams(lp); if (addr.getId().equals(selectedId)) { rb.setChecked(true); } rg.addView(rb); } rg.setOnCheckedChangeListener((group, checkedId) -> { if (checkedId == View.NO_ID) return; RadioButton checked = group.findViewById(checkedId); if (checked != null && checked.getTag() != null) { String id = checked.getTag().toString(); // 处理选中逻辑 } }); }这段代码里有三个我特意加的处理。第一,刷新前先setOnCheckedChangeListener(null),避免removeAllViews触发回调。第二,setButton(null)去掉原生圆点,用背景选择器区分选中态。第三,setTag存业务 id,比用checkedId反查更可靠,因为checkedId是动态生成的,每次刷新都变。
5. 常见问题与排查速查
5.1 高频问题对照表
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 多个 RadioButton 能同时选中 | 没有放在 RadioGroup 里 | 用 RadioGroup 包裹 |
| 监听器不触发 | id 未设置或重复 | 每个按钮设置唯一 id |
| 选中状态丢失 | View 复用或页面重建 | 手动恢复 checked 状态 |
| 圆点去不掉 | 只改了 background | 设置android:button="@null" |
| 文字颜色不变 | 用了 color 而非 color selector | 用 selector 引用 |
| 动态添加后越加越多 | 未 removeAllViews | 添加前先清空 |
| 移除选中项后崩溃 | checkedId 为 -1 未判断 | 加View.NO_ID判断 |
| 图标不变色 | 未用 AppCompat 或 tint 不兼容 | 用AppCompatRadioButton |
5.2 几个文档里不会写的经验
第一个经验是关于padding和minHeight的。原生RadioButton有一个默认的minHeight和minWidth,大约是 48dp,这是为了满足触摸目标的最小尺寸要求。如果你做的是紧凑型选项卡,设置layout_height="36dp"会发现实际高度还是 48dp,原因就在这里。解决办法是设置android:minHeight="0dp"和android:minWidth="0dp",但要注意触摸区域会变小,用户体验上要权衡。
第二个经验是关于RadioGroup嵌套的。RadioGroup继承自LinearLayout,理论上可以嵌套,但嵌套后互斥逻辑只在各自的组内生效。如果你想要"一级分类 + 二级分类"的联动选择,不要用嵌套RadioGroup,而是用两个独立的RadioGroup,在代码里手动清空另一个。嵌套RadioGroup在测量和布局上容易出问题,尤其是配合ScrollView使用时。
第三个经验是关于性能的。如果一个页面里有几十个RadioButton,每个都配了复杂的selector和shape,首次渲染会明显卡顿。优化方式是把shape的corners和solid尽量简化,避免使用gradient,因为渐变在低端机上绘制开销较大。如果选项超过 20 个,建议改用RecyclerView配合单选逻辑,而不是全部塞进RadioGroup。
5.3 关于状态选择器的顺序陷阱
再强调一次selector的匹配顺序,因为这个问题我见过太多次了。假设你写了这样一个选择器:
<selector> <item android:state_pressed="true" android:drawable="@drawable/bg_pressed" /> <item android:state_checked="true" android:drawable="@drawable/bg_checked" /> <item android:drawable="@drawable/bg_normal" /> </selector>用户按下已选中的按钮时,会匹配到state_pressed,显示按下态,这是对的。但如果顺序反过来,state_checked写在前面,那么按下已选中的按钮时,会直接匹配state_checked,按下态永远不显示。所以顺序原则是:越具体的状态越靠前,越通用的状态越靠后。state_pressed通常比state_checked更具体,所以放前面。
6. 写在最后的一点个人习惯
做 Android 这些年,我养成了一个习惯:每做一个自定义样式的控件,就把它的 selector、shape、color 三件套整理到一个独立的目录或者命名前缀下,比如rb_开头。这样下次遇到类似需求,直接复制改改就能用,不用重新写一遍。RadioButton的样式定制尤其如此,因为它的状态组合就那么几种,整理一次能复用很久。
另外,如果你的项目已经迁移到 Material Components,可以试试MaterialRadioButton和TabLayout的组合。TabLayout本质上就是一个高级版的单选组,很多原本用RadioButton硬做的选项卡,用TabLayout会更省事,自带滑动指示器、水波纹、图标文字联动。但TabLayout的定制自由度不如手写RadioButton,具体用哪个,看设计稿的复杂度和团队的技术栈。
最后分享一个小技巧:调试selector的时候,可以在item里临时加一个显眼的颜色,比如亮红色,跑起来看哪个状态匹配到了,比对着代码猜快得多。调完再换回正式颜色。这个方法我在调state_pressed、state_focused、state_enabled组合的时候用过无数次,屡试不爽。