做完 TextView 和 EditText 之后,页面终于不只是展示和输入了。第 3 周开始处理按钮。
我一开始也容易把按钮想简单:写个 setOnClickListener,点一下,弹个 Toast,事情结束。但真把按钮放进业务里就会发现,问题基本都出在"点完以后":用户连点两次怎么办?协议没勾能不能提交?配送方式只能选一个,代码里怎么保证?开关状态下次进来还在不在?按钮按下去有没有反馈?
所以这一周我没有单独摆几个控件截图,而是把它们塞进一个接近真实业务的"下单确认"场景里。这个场景不复杂,但足够暴露按钮组件最常见的问题。
先看第 3 周入口。首页里的第 3 周卡片现在已经不是"暂未开放",点击会直接进入 Week3ButtonActivity:
kotlin
binding.cardWeek3.setOnClickListener {
startActivity(Intent(this, Week3ButtonActivity::class.java))
}
这只是入口,真正的练习在里面:Button 管提交,CheckBox 管协议和优惠,RadioGroup 管配送方式,SwitchCompat 管通知开关,底部还有状态快照和实践说明。第 3 周不是为了证明"我会用这些控件",而是为了练一件更重要的事:UI 状态怎么变成可靠的业务状态。

Button:不要让提交按钮裸奔
第一个要处理的是提交按钮。
普通写法很简单:
arduino
binding.btnSubmitOrder.setOnClickListener {
// 执行提交
}
这段代码没错,但它太"裸"了。网络一慢,用户觉得没反应,很容易连续点。提交订单、支付、领券、发布内容这些动作,一旦重复触发,后面就不是 UI 问题了,而是业务事故。
所以 Demo 里提交按钮没有直接用普通点击,而是用了 setSingleClick:
ini
binding.btnSubmitOrder.setSingleClick(intervalMillis = 900L) {
submitCount++
binding.tvClickState.text = "提交成功:第 $submitCount 次有效点击。900ms 内的重复点击会被拦截。"
Toast.makeText(this, "已提交,本次点击有效", Toast.LENGTH_SHORT).show()
}
真正的封装在 ButtonClickOptimizer:
kotlin
fun View.setSingleClick(intervalMillis: Long = 600L, listener: (View) -> Unit) {
var lastClickTime = 0L
setOnClickListener { view ->
val now = System.currentTimeMillis()
if (now - lastClickTime >= intervalMillis) {
lastClickTime = now
listener(view)
}
}
}
它做的事很直白:记录上一次有效点击时间,下一次点击进来时先比较间隔,间隔够了才执行真正业务。
这就是我这周最想留下的第一个判断:关键按钮不要散落地写防抖,应该尽早封装。 今天只是工具函数,后面项目大了,可以继续下沉到基类、统一组件、注解,甚至 AOP。Demo 不上来就搞 AspectJ,是因为第 3 周的重点还在基础组件,先把"为什么要防"和"最小实现"吃透更重要。
这节对应的成熟团队做法
成熟 App 通常不会允许下单、支付、发布这类关键按钮随便连点。公开资料里能确认的是通用方向:点击防抖会被封装成公共能力,有的项目用工具函数,有的项目用基类,有的项目会进一步用 @SingleClick 注解配合 AOP/AspectJ 织入。
Demo 里保留最小版本:setSingleClick。它不复杂,但已经能展示"把重复点击从业务代码里抽走"的思路。
触摸反馈:onTouch 不是随便返回 true
有个点特别适合放进 Demo:OnTouchListener 的返回值。
如果只是想做按下时缩小、变透明这种触摸反馈,不应该消费事件。也就是说,最后要返回 false。
这次我把它封装成了 applyPressFeedback:
scss
fun View.applyPressFeedback(scale: Float = 0.96f, pressedAlpha: Float = 0.72f) {
setOnTouchListener { view, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> view.animate()
.scaleX(scale)
.scaleY(scale)
.alpha(pressedAlpha)
.setDuration(80L)
.start()
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL -> view.animate()
.scaleX(1f)
.scaleY(1f)
.alpha(1f)
.setDuration(80L)
.start()
}
false
}
}
最后这个 false 很关键。它的意思是:我只是加一点视觉反馈,不抢走默认点击流程。这样 Button 的 onClick 还能触发,CheckBox 的选中状态还能正常切换,系统默认的按压、涟漪效果也不会被你截断。
如果这里返回 true,就可能出现很诡异的问题:按钮看起来被按了,但点击回调没了;CheckBox 点了没切换;Switch 的开关状态不动。初学时这种 bug 很难查,因为代码"看起来"只是加了个触摸动画。
Demo 里我把几个按钮都接上了这个反馈:
scss
listOf(
binding.btnSubmitOrder,
binding.btnFastTap,
binding.btnResetChoices,
binding.btnTintState
).forEach { button -> button.applyPressFeedback() }
成熟团队做法
成熟团队做按钮反馈时,不会只管"动起来",还会确认不会破坏默认事件链。尤其是 CheckBox、RadioButton、Switch 这类自带状态切换的控件,触摸反馈必须让路给默认行为。
长按:处理完就明确返回 true
按钮除了普通点击,还有长按。长按不是所有业务都需要,但它很适合放一些"说明型"或"危险操作提示"。比如提交按钮长按时,不一定真的提交,可以告诉用户这个按钮会做什么。
Demo 里我给提交按钮加了一个长按提示:
arduino
binding.btnSubmitOrder.setOnLongClickListener {
binding.tvClickState.text = "长按提示:这是高风险按钮,真实业务里可用于展示提交规则或二次确认说明。"
true
}
这里返回 true,表示长按事件已经处理完了,不需要继续往下传。官方文档里也强调了这个返回值的含义。简单记就是:长按你处理了,就返回 true;只是路过,不想处理,才返回 false。
成熟团队做法
高风险动作不会只靠一个按钮文案撑着。删除账号、取消订单、清空数据这类操作,真实产品里经常会配合二次确认、危险色、后果说明,甚至撤销机制。Demo 里先用长按提示简化展示这个思路。
CheckBox:多选状态要收口,不要散在回调里
CheckBox 适合多选,比如协议确认、是否使用优惠券、是否订阅提醒。Demo 里就是这三个:
ini
<CheckBox
android:id="@+id/cb_agreement"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="我已阅读并同意服务协议" />
<CheckBox
android:id="@+id/cb_coupon"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="使用优惠券" />
<CheckBox
android:id="@+id/cb_push"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:checked="true"
android:text="订阅订单提醒" />
这里最值得练的是协议勾选。用户没勾协议时,提交按钮不能让他继续。这个状态不是写死在按钮上的,而是由统一快照刷新出来:
kotlin
private fun refreshChoiceSnapshot() {
val canSubmit = binding.cbAgreement.isChecked
binding.btnSubmitOrder.isEnabled = canSubmit
binding.btnSubmitOrder.alpha = if (canSubmit) 1f else 0.48f
binding.tvSubmitGate.text = if (canSubmit) {
"提交门禁:协议已同意,可以提交。"
} else {
"提交门禁:必须先勾选协议,提交按钮暂时禁用。"
}
}
我比较喜欢这个写法,因为它把状态收到了一个地方。页面上不管是协议变化、配送方式变化,还是开关变化,最后都走 refreshChoiceSnapshot()。这样以后要排查"为什么按钮不可点",不用翻一堆零散回调。
批量重置时还有一个小坑:代码里调用 setChecked() 也会触发监听器。Demo 里用了一个小工具,先拆监听,再改状态,最后挂回去:
kotlin
fun CompoundButton.setCheckedWithoutCallback(
checked: Boolean,
listener: CompoundButton.OnCheckedChangeListener
) {
setOnCheckedChangeListener(null)
isChecked = checked
setOnCheckedChangeListener(listener)
}
这不是炫技,是为了减少无意义回调。复杂表单里,这个小处理能少很多状态抖动。
成熟团队做法
结算页、会员页、金融表单里,勾选状态通常不只是 UI,它会影响提交参数、权益计算、风控确认和合规提示。成熟团队更关心的是:这些状态有没有统一来源,提交时读到的值和页面显示是不是一致。
RadioButton:互斥选择交给 RadioGroup
配送方式只能选一个,这类场景不应该自己手写互斥逻辑。Android 已经给了 RadioGroup。
ini
<RadioGroup
android:id="@+id/rg_delivery_mode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:checkedButton="@id/rb_standard"
android:orientation="vertical">
<RadioButton
android:id="@+id/rb_standard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="普通配送" />
<RadioButton
android:id="@+id/rb_express"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="极速配送" />
</RadioGroup>
监听时也不用分别管每一个 RadioButton:
scss
binding.rgDeliveryMode.setOnCheckedChangeListener { _, _ ->
refreshChoiceSnapshot()
}
读取当前选项时,通过 checkedRadioButtonId 找到对应按钮:
scss
val deliveryMode = findViewById<RadioButton>(
binding.rgDeliveryMode.checkedRadioButtonId
)?.text?.toString().orEmpty()
这个写法的好处是清楚。互斥关系由 RadioGroup 管,业务代码只关心"当前选中的是谁"。
成熟团队做法
本地生活、酒旅、出行、支付页里有大量互斥选择:配送方式、支付方式、票种、时间段。选项少时用 RadioGroup 很合适;如果选项变成复杂列表,后面再抽成 RecyclerView 单选模型。
Switch:它不是按钮,它是偏好

Switch 和 Button 最大的区别是:Button 多数时候代表一次动作,Switch 多数时候代表一个持续状态。
比如通知开关、夜间模式、自动同步、隐私设置。这些东西用户不希望每次打开页面都重新选一遍。
Demo 里用的是 SwitchCompat:
ini
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/sw_notification"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:checked="true"
android:text="开启系统通知" />
监听逻辑是:
arduino
binding.swNotification.setOnCheckedChangeListener { _, isChecked ->
binding.tvSwitchState.text = if (isChecked) {
"Switch 状态:已开启通知,按钮状态会同步刷新。"
} else {
"Switch 状态:已关闭通知,避免不必要打扰。"
}
refreshChoiceSnapshot()
}
第 3 周 Demo 只做到了 UI 状态同步。真实项目还要继续往下走:本地保存、远端同步、失败回滚、系统权限检查。比如通知开关很典型,App 内开关打开了,不代表系统通知权限也打开了。
成熟团队做法
IM、内容社区、工具类 App 都有大量偏好开关。成熟做法不会只改 UI,而是把开关状态和本地存储、远端配置、系统权限放在一起校验。第 3 周先用 SwitchCompat 把 UI 层状态跑通,后面到 DataStore 时再补持久化。
水波纹和渐变边框:反馈要有,但主次更要清楚
按钮要让用户知道"我点到了"。Android 常见做法是 ripple,也就是水波纹。
Demo 里的提交按钮用了这个背景:
ini
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#FFF7ED">
<item>
<shape android:shape="rectangle">
<corners android:radius="12dp" />
<gradient
android:angle="0"
android:endColor="#EA580C"
android:startColor="#F97316" />
</shape>
</item>
<item android:id="@android:id/mask">
<shape android:shape="rectangle">
<corners android:radius="12dp" />
<solid android:color="#FFFFFFFF" />
</shape>
</item>
</ripple>
ripple 负责按压反馈,里面的 shape 负责按钮底色和圆角,mask 限制水波纹不要跑出圆角区域。
渐变边框用了 layer-list:
ini
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<corners android:radius="18dp" />
<gradient
android:angle="0"
android:endColor="#FDBA74"
android:startColor="#F97316" />
</shape>
</item>
<item
android:bottom="2dp"
android:left="2dp"
android:right="2dp"
android:top="2dp">
<shape android:shape="rectangle">
<corners android:radius="16dp" />
<solid android:color="#FFFFFBEB" />
</shape>
</item>
</layer-list>
这个技巧很朴素:外层是渐变,内层缩进去一点,视觉上就是一圈渐变边框。
Material Design 里对按钮有一个很重要的提醒:按钮样式是在表达动作优先级,不是越显眼越好。如果一个页面上"提交""取消""重置""返回"全都做成高亮主按钮,用户反而不知道该点哪个。
成熟团队做法
电商、短视频、内容社区会很重视 CTA(主行动按钮)的层级,但不会把所有按钮都做成主按钮。真正成熟的设计是:主按钮明确,次按钮克制,危险操作单独提示。
Drawable 复用:复用的是状态来源,不是同一个实例
按钮状态经常要变色,比如协议没勾时灰色,勾了以后绿色。简单场景下,用 ViewCompat.setBackgroundTintList 就够了:
less
fun View.updateBackgroundTint(@ColorInt color: Int) {
ViewCompat.setBackgroundTintList(this, ColorStateList.valueOf(color))
}
调用时:
kotlin
private fun applyRuntimeTint() {
val enabled = binding.cbAgreement.isChecked
val color = if (enabled) "#16A34A" else "#94A3B8"
binding.btnTintState.updateBackgroundTint(Color.parseColor(color))
}
这比每次重新创建一套背景轻一点,也更适合这种单纯颜色变化。
但如果真的要复用 Drawable,就要小心。Android 的 Drawable.ConstantState 可以作为共享状态来源创建新 Drawable,但不要把同一个 Drawable 实例直接塞给多个 View。因为 Drawable 有 bounds、state、alpha、tint、callback 等运行时状态,直接共享很容易互相影响。
Demo 里保留了一个安全一点的写法:
kotlin
fun cloneDrawable(context: Context, @DrawableRes drawableRes: Int): Drawable? {
val origin = ContextCompat.getDrawable(context, drawableRes) ?: return null
return origin.constantState?.newDrawable()?.mutate() ?: origin.mutate()
}
这里的核心是 newDrawable() 和 mutate()。前者创建新实例,后者避免修改时污染同资源的其他 Drawable。
成熟团队做法
大型 App 里按钮、图标、卡片背景不会每个页面各写一套。更常见的是沉淀到 UI 组件库和统一资源里。资源复用不是为了少写几行 XML,而是为了状态统一、维护成本低、列表滚动时少制造对象。
最容易踩的坑
1. 提交按钮不防重复点击
普通按钮可以先简单处理,提交、支付、发布、领券这类按钮不能裸奔。客户端防抖不能替代服务端幂等,但能明显减少误触和重复请求。
2. onTouch 返回值乱写
只是做按压动画时返回 false。返回 true 可能会吞掉默认点击和选中行为。
3. 多选状态散落在各个回调里
CheckBox 多了以后,最好统一刷新状态快照。否则页面越写越散,最后没人知道提交参数从哪里来的。
4. 手写单选互斥逻辑
选项少、互斥明确时,先用 RadioGroup。不要为了"可控"把简单问题写复杂。
5. 开关只改 UI,不管持久化和权限
第 3 周 Demo 只做 UI,真实项目要继续考虑 DataStore、远端配置、系统通知权限和失败回滚。
6. 直接共享 Drawable 实例
可以复用资源和 ConstantState,不要复用同一个可变 Drawable 实例。要改颜色、透明度、tint 时,记得考虑 mutate()。
这一周真正学到的是什么
第 3 周表面上是 Button、CheckBox、RadioButton、Switch,但真正练的是交互状态治理:
- 按钮不是只负责"点一下",它要防误触、给反馈、表达处理状态;
- 多选和单选不是控件问题,而是业务状态怎么集中管理的问题;
- 开关不是一次点击,而是用户长期偏好的入口;
- 水波纹、按压反馈、渐变边框不是装饰,而是告诉用户"你点到了、这个更重要";
- Drawable 复用不是直接共享实例,而是复用资源描述,同时隔离运行时状态。
这周我觉得最有价值的不是某个 API,而是一个习惯:写按钮时先问一句,这个按钮点下去以后,页面状态、业务状态和用户反馈是不是都对得上?
如果对不上,就算 UI 看起来能点,也还没写完。
下一步
第 4 周进入 ImageView。
图片比按钮更容易暴露性能问题:尺寸太大、内存暴涨、列表卡顿、圆角处理重复、GIF 播放吃资源、网络图没有缓存。第 4 周会从 scaleType、圆角/圆形图片、本地/网络图片开始,再往图片压缩、缓存复用和 OOM 预防走。