用 Method Swizzling 全局防重复点击,以及我漏掉的一个崩溃
用一个运行时 hook 给几百个 UIKit 按钮防重复点击:先写的测试页、重新测试时才发现的崩溃,以及修复方法。
先看一个问题。
你在维护一个老旧的 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。下一个调试响应失效问题的同事,必须正好知道去检查这个逻辑。
这段代码留在了特性分支上,我没在发布分支里找到它。所以,我能分享的只是我解决问题时的思路,而不是它已经在生产环境中运行。