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

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

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

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

scss 复制代码
listOf(
    binding.btnSubmitOrder,
    binding.btnFastTap,
    binding.btnResetChoices,
    binding.btnTintState
).forEach { button -> button.applyPressFeedback() }

成熟团队做法

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

长按:处理完就明确返回 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:它不是按钮,它是偏好

SwitchButton 最大的区别是: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 有 boundsstatealphatintcallback 等运行时状态,直接共享很容易互相影响。

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

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

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

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

下一步

第 4 周进入 ImageView

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

相关推荐
峥嵘life3 小时前
五一南昌第三天游玩记录:梅景寻芳,母校忆旧,摩天轮揽夜
android
qq_452396234 小时前
第三篇:《JMeter断言:验证接口响应正确性》
android·jmeter
aqi004 小时前
一文速览 HarmonyOS 6.0.1 引入的十个新特性
android·华为·harmonyos·鸿蒙·harmony
橙子199110165 小时前
Android 第三方框架 相关
android
赏金术士6 小时前
JetPack Compose 弹窗、菜单、交互组件(五)
android·kotlin·交互·android jetpack·compose
海天鹰6 小时前
高版本安卓老应用下面空白
android
猫的玖月7 小时前
(七)函数
android·数据库·sql
秋97 小时前
java中对操作mysql8.0.46与MySQL9.7.0有什么区别,并举例说明
android·java·adb