用 Method Swizzling 全局防重复点击,以及我漏掉的一个崩溃

用一个运行时 hook 给几百个 UIKit 按钮防重复点击:先写的测试页、重新测试时才发现的崩溃,以及修复方法。

文章目录
  1. 为什么我选择 hook action 而不是去改按钮
  2. 先写测试页面
  3. 代码实现
  4. 我漏掉的那个崩溃
  5. 这个设计带来的其他问题

先看一个问题。

你在维护一个老旧的 iOS 项目,里面有几百个按钮,很多散落在 xib 和 storyboard 文件中。现在的需求是:任意按钮被点击后,一秒内屏蔽后续点击。项目里同时有 Objective-C 和 Swift 代码,你不想去改动每一个页面。

你会怎么做?

为什么我选择 hook action 而不是去改按钮

在我经手的这个项目里,光是 storyboard 和 xib 就包含了 301 个按钮,还有一些是用代码动态创建的。如果给每个按钮都加防重复点击限制,意味着要修改几百处地方,还要指望后面没人漏掉。

所有的 target-action 点击最终都会走到 UIControl 的 sendAction(_:to:for:) 方法。只要把这个方法 hook 一次,现有的按钮就能直接获得这个行为,不需要做任何修改。这也是我喜欢在老代码库里做低侵入性改动的原因:在 AppDelegate.m 里写一行代码就能启用。

[UIButton enableDebounce];

先写测试页面

在写 hook 之前,我先做一个简单的测试页面:一个按钮,配上一张表格,用来记录每一次点击的序号、时间和距离上一次点击的间隔。只是连点按钮看它有没有出错,看不出多少东西,有一列时间间隔才说得清。

代码实现

防抖逻辑本身是 UIControl 的一个 extension。它会将控件禁用,并在延迟时间过后重新启用。

extension UIControl {
    @objc static var debounceDelay: Double = 1

    @objc func debounce(delay: Double = UIControl.debounceDelay, siblings: [UIControl] = []) {
        let controls = [self] + siblings
        controls.forEach { $0.isEnabled = false }
        DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
            controls.forEach { $0.isEnabled = true }
        }
    }
}

siblings 允许单次点击锁定一组控件:如果两个按钮的功能互斥,点击其中一个就能同时禁用两者。

这个 hook 会交换方法实现,执行防抖逻辑,然后调用原方法。因为方法已经被交换,内部的 debounceSendAction 实际上就是原本的 sendAction。

extension UIButton {
    private static let swizzle: Void = {
        let originalSelector = #selector(UIButton.sendAction(_:to:for:))
        let swizzledSelector = #selector(UIButton.debounceSendAction(_:to:for:))

        guard let original = class_getInstanceMethod(UIButton.self, originalSelector),
              let swizzled = class_getInstanceMethod(UIButton.self, swizzledSelector) else { return }

        method_exchangeImplementations(original, swizzled)
    }()

    @objc func debounceSendAction(_ action: Selector, to target: Any?, for event: UIEvent?) {
        debounce()
        debounceSendAction(action, to: target, for: event)
    }

    @objc static func enableDebounce() {
        _ = swizzle
    }
}

static let 只会初始化一次,因此调用两次 enableDebounce() 不会把方法再次交换回来。

我漏掉的那个崩溃

不要直接复制上面的代码。它有一个 bug,直到我为这篇文章重新测试时才发现。

UIButton 并没有定义 sendAction(_:to:for:),它是从 UIControl 继承来的。因此 class_getInstanceMethod 获取到的是属于 UIControl 的方法,这导致交换操作影响到了所有的控件。接着,UISwitch 也会执行这个新的实现,而该实现中调用的 debounceSendAction 却只存在于 UIButton 中:

-[UISwitch debounceSendAction:to:for:]: unrecognized selector sent to instance

我在模拟器上用一个最简单的程序复现了这个问题,使用了相同的 hook 并配合一个带有 target-action 的 UISwitch。我没有在原来的 App 里重新运行它。我的测试页面只记录了一个按钮的点击,所以测试记录无法暴露出其他控件的问题。

通常的修复方法是先让 UIButton 拥有该方法的一份独立拷贝,然后只交换这个拷贝:

extension UIButton {
    private static let safeSwizzle: Void = {
        let originalSelector = #selector(UIButton.sendAction(_:to:for:))
        let swizzledSelector = #selector(UIButton.debounceSendAction(_:to:for:))

        guard let original = class_getInstanceMethod(UIButton.self, originalSelector),
              let swizzled = class_getInstanceMethod(UIButton.self, swizzledSelector) else { return }

        let didAdd = class_addMethod(
            UIButton.self, originalSelector,
            method_getImplementation(swizzled), method_getTypeEncoding(swizzled)
        )
        if didAdd {
            class_replaceMethod(
                UIButton.self, swizzledSelector,
                method_getImplementation(original), method_getTypeEncoding(original)
            )
        } else {
            method_exchangeImplementations(original, swizzled)
        }
    }()
}

使用这个版本,在相同的极简程序中,防抖逻辑对 UIButton 及其子类生效, UISwitch 和 UISegmentedControl 则不受影响。那个程序并没有运行完整的 App,所以它展示的是哪些控件被 hook 了,而不是真实页面上的完整点击行为。

这个设计带来的其他问题

  • **它会重新启用不该启用的控件。**延迟时间到后总是会执行 isEnabled = true。如果某个页面在这秒内主动禁用了按钮,防抖逻辑会把它重新打开。
  • **按钮会闪烁。**禁用控件会改变其外观,导致每次点击都会让按钮变暗一秒,不管当前操作是否真的需要耗时这么久。
  • **时间不等同于状态。**一个需要三秒才能完成的请求,在一秒后仍然可以再次发送。要解决这个问题,按钮必须知道当前任务还在运行中。
  • **它缺乏可见性。**从调用方完全看不出这里涉及全局 hook。下一个调试响应失效问题的同事,必须正好知道去检查这个逻辑。

这段代码留在了特性分支上,我没在发布分支里找到它。所以,我能分享的只是我解决问题时的思路,而不是它已经在生产环境中运行。