第3周:按钮这件小事,真正麻烦的是“点完以后”

0 阅读12分钟

做完 TextViewEditText 之后,页面终于不只是展示和输入了。第 3 周开始处理按钮。

我一开始也容易把按钮想简单:写个 setOnClickListener,点一下,弹个 Toast,事情结束。但真把按钮放进业务里就会发现,问题基本都出在“点完以后”:用户连点两次怎么办?协议没勾能不能提交?配送方式只能选一个,代码里怎么保证?开关状态下次进来还在不在?按钮按下去有没有反馈?

所以这一周我没有单独摆几个控件截图,而是把它们塞进一个接近真实业务的“下单确认”场景里。这个场景不复杂,但足够暴露按钮组件最常见的问题。

先看第 3 周入口。首页里的第 3 周卡片现在已经不是“暂未开放”,点击会直接进入 Week3ButtonActivity

binding.cardWeek3.setOnClickListener {
    startActivity(Intent(this, Week3ButtonActivity::class.java))
}

这只是入口,真正的练习在里面:Button 管提交,CheckBox 管协议和优惠,RadioGroup 管配送方式,SwitchCompat 管通知开关,底部还有状态快照和实践说明。第 3 周不是为了证明“我会用这些控件”,而是为了练一件更重要的事:UI 状态怎么变成可靠的业务状态。

第三周按钮内容

Button:不要让提交按钮裸奔

第一个要处理的是提交按钮。

普通写法很简单:

binding.btnSubmitOrder.setOnClickListener {
    // 执行提交
}

这段代码没错,但它太“裸”了。网络一慢,用户觉得没反应,很容易连续点。提交订单、支付、领券、发布内容这些动作,一旦重复触发,后面就不是 UI 问题了,而是业务事故。

所以 Demo 里提交按钮没有直接用普通点击,而是用了 setSingleClick

binding.btnSubmitOrder.setSingleClick(intervalMillis = 900L) {
    submitCount++
    binding.tvClickState.text = "提交成功:第 $submitCount 次有效点击。900ms 内的重复点击会被拦截。"
    Toast.makeText(this, "已提交,本次点击有效", Toast.LENGTH_SHORT).show()
}

真正的封装在 ButtonClickOptimizer

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

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 很关键。它的意思是:我只是加一点视觉反馈,不抢走默认点击流程。这样 ButtononClick 还能触发,CheckBox 的选中状态还能正常切换,系统默认的按压、涟漪效果也不会被你截断。

如果这里返回 true,就可能出现很诡异的问题:按钮看起来被按了,但点击回调没了;CheckBox 点了没切换;Switch 的开关状态不动。初学时这种 bug 很难查,因为代码“看起来”只是加了个触摸动画。

Demo 里我把几个按钮都接上了这个反馈:

listOf(
    binding.btnSubmitOrder,
    binding.btnFastTap,
    binding.btnResetChoices,
    binding.btnTintState
).forEach { button -> button.applyPressFeedback() }

成熟团队做法

成熟团队做按钮反馈时,不会只管“动起来”,还会确认不会破坏默认事件链。尤其是 CheckBoxRadioButtonSwitch 这类自带状态切换的控件,触摸反馈必须让路给默认行为。

长按:处理完就明确返回 true

按钮除了普通点击,还有长按。长按不是所有业务都需要,但它很适合放一些“说明型”或“危险操作提示”。比如提交按钮长按时,不一定真的提交,可以告诉用户这个按钮会做什么。

Demo 里我给提交按钮加了一个长按提示:

binding.btnSubmitOrder.setOnLongClickListener {
    binding.tvClickState.text = "长按提示:这是高风险按钮,真实业务里可用于展示提交规则或二次确认说明。"
    true
}

这里返回 true,表示长按事件已经处理完了,不需要继续往下传。官方文档里也强调了这个返回值的含义。简单记就是:长按你处理了,就返回 true;只是路过,不想处理,才返回 false。

成熟团队做法

高风险动作不会只靠一个按钮文案撑着。删除账号、取消订单、清空数据这类操作,真实产品里经常会配合二次确认、危险色、后果说明,甚至撤销机制。Demo 里先用长按提示简化展示这个思路。

CheckBox:多选状态要收口,不要散在回调里

CheckBox 适合多选,比如协议确认、是否使用优惠券、是否订阅提醒。Demo 里就是这三个:

<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="订阅订单提醒" />

这里最值得练的是协议勾选。用户没勾协议时,提交按钮不能让他继续。这个状态不是写死在按钮上的,而是由统一快照刷新出来:

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 里用了一个小工具,先拆监听,再改状态,最后挂回去:

fun CompoundButton.setCheckedWithoutCallback(
    checked: Boolean,
    listener: CompoundButton.OnCheckedChangeListener
) {
    setOnCheckedChangeListener(null)
    isChecked = checked
    setOnCheckedChangeListener(listener)
}

这不是炫技,是为了减少无意义回调。复杂表单里,这个小处理能少很多状态抖动。

成熟团队做法

结算页、会员页、金融表单里,勾选状态通常不只是 UI,它会影响提交参数、权益计算、风控确认和合规提示。成熟团队更关心的是:这些状态有没有统一来源,提交时读到的值和页面显示是不是一致。

RadioButton:互斥选择交给 RadioGroup

配送方式只能选一个,这类场景不应该自己手写互斥逻辑。Android 已经给了 RadioGroup

<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

binding.rgDeliveryMode.setOnCheckedChangeListener { _, _ ->
    refreshChoiceSnapshot()
}

读取当前选项时,通过 checkedRadioButtonId 找到对应按钮:

val deliveryMode = findViewById<RadioButton>(
    binding.rgDeliveryMode.checkedRadioButtonId
)?.text?.toString().orEmpty()

这个写法的好处是清楚。互斥关系由 RadioGroup 管,业务代码只关心“当前选中的是谁”。

成熟团队做法

本地生活、酒旅、出行、支付页里有大量互斥选择:配送方式、支付方式、票种、时间段。选项少时用 RadioGroup 很合适;如果选项变成复杂列表,后面再抽成 RecyclerView 单选模型。

Switch:它不是按钮,它是偏好

image-20260510121013848

SwitchButton 最大的区别是:Button 多数时候代表一次动作,Switch 多数时候代表一个持续状态。

比如通知开关、夜间模式、自动同步、隐私设置。这些东西用户不希望每次打开页面都重新选一遍。

Demo 里用的是 SwitchCompat

<androidx.appcompat.widget.SwitchCompat
    android:id="@+id/sw_notification"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:checked="true"
    android:text="开启系统通知" />

监听逻辑是:

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 里的提交按钮用了这个背景:

<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

<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 就够了:

fun View.updateBackgroundTint(@ColorInt color: Int) {
    ViewCompat.setBackgroundTintList(this, ColorStateList.valueOf(color))
}

调用时:

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 有 boundsstatealphatintcallback 等运行时状态,直接共享很容易互相影响。

Demo 里保留了一个安全一点的写法:

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 周表面上是 ButtonCheckBoxRadioButtonSwitch,但真正练的是交互状态治理:

  • 按钮不是只负责“点一下”,它要防误触、给反馈、表达处理状态;
  • 多选和单选不是控件问题,而是业务状态怎么集中管理的问题;
  • 开关不是一次点击,而是用户长期偏好的入口;
  • 水波纹、按压反馈、渐变边框不是装饰,而是告诉用户“你点到了、这个更重要”;
  • Drawable 复用不是直接共享实例,而是复用资源描述,同时隔离运行时状态。

这周我觉得最有价值的不是某个 API,而是一个习惯:写按钮时先问一句,这个按钮点下去以后,页面状态、业务状态和用户反馈是不是都对得上?

如果对不上,就算 UI 看起来能点,也还没写完。

下一步

第 4 周进入 ImageView

图片比按钮更容易暴露性能问题:尺寸太大、内存暴涨、列表卡顿、圆角处理重复、GIF 播放吃资源、网络图没有缓存。第 4 周会从 scaleType、圆角/圆形图片、本地/网络图片开始,再往图片压缩、缓存复用和 OOM 预防走。