Swift Debouncing & Throttling

在做语音播放时,发现会太频繁调用接口导致体验不太好,这时候加上函数防抖就能很好的解决这个问题。

在Rx中,有现成的debounce和throttle方法:

       let disposeBag = DisposeBag()
        Observable.of(1,2,3)
            .debounce(1, scheduler: MainScheduler.instance)
            .subscribe(onNext: {print($0)})
            .disposed(by: disposeBag)
        
        exitBtn.rx.tap
            .throttle(1, scheduler: MainScheduler.instance)
            .subscribe(onNext: { print($0) })
            .disposed(by: disposeBag)

这两个方法内部调用方法大致相同,使用了DispatchSource的timer.schedule(deadline: deadline, leeway: self.leeway)方法:

    func scheduleRelative<StateType>(_ state: StateType, dueTime: Foundation.TimeInterval, action: @escaping (StateType) -> Disposable) -> Disposable {
        let deadline = DispatchTime.now() + dispatchInterval(dueTime)
        let compositeDisposable = CompositeDisposable()
        let timer = DispatchSource.makeTimerSource(queue: self.queue)
        timer.schedule(deadline: deadline, leeway: self.leeway)
        var timerReference: DispatchSourceTimer? = timer
        let cancelTimer = Disposables.create {
            timerReference?.cancel()
            timerReference = nil
        }
        timer.setEventHandler(handler: {
            if compositeDisposable.isDisposed {
                return
            }
            _ = compositeDisposable.insert(action(state))
            cancelTimer.dispose()
        })
        timer.resume()
        _ = compositeDisposable.insert(cancelTimer)
        return compositeDisposable
    }

区别在于是传入的dueTime是给定的值还是dueTime - timeIntervalSinceLast。这也好理解:

Debouncing 函数防抖 指函数调用一次之后,距离下一次调用时间是固定的,也就是说一个函数执行过一次以后,在一段时间内不能再次执行。比如,一个函数执行完了之后,100毫秒之内不能第二次执行;

Throttling 函数节流 指的是固定时间间隔内,执行的次数固定。

参考这种写法,我们可以使用asyncAfter实现一个Swift版本的debounce:

        //不带参数版本
    func debounce(interval: Int, queue: DispatchQueue, action: @escaping (() -> Void)) -> () -> Void {
        var lastFire = DispatchTime.now()
        let delay = DispatchTimeInterval.milliseconds(interval)
        
        return {
            lastFire = DispatchTime.now()
            let dispatchTime = DispatchTime.now() + delay

            queue.asyncAfter(deadline: dispatchTime) {
                let when = lastFire + delay
                let now = DispatchTime.now()
                if now.rawValue >= when.rawValue {
                    action()
                }
            }
        }
    }
    typealias Debounce<T> = (_ : T) -> Void
        //带参数版本    
    func debounce<T>(interval: Int, queue: DispatchQueue, action: @escaping Debounce<T>) -> Debounce<T> {
        var lastFire = DispatchTime.now()
        let delay = DispatchTimeInterval.milliseconds(interval)
        
        return { param in
            lastFire = DispatchTime.now()
            let dispatchTime = DispatchTime.now() + delay
            
            queue.asyncAfter(deadline: dispatchTime) {
                let when = lastFire + delay
                let now = DispatchTime.now()
                if now.rawValue >= when.rawValue {
                    action(param)
                }
            }
        }
    }

实际上除了使用时间戳判断的方式,还可以使用iOS8之后新增的DispatchWorkItem实现。

DispatchWorkItem encapsulates work that can be performed. A work item can be dispatched onto a DispatchQueue and within a DispatchGroup. A DispatchWorkItem can also be set as a DispatchSource event, registration, or cancel handler.

我们可以将代码封装在 DispatchWorkItem 中,当新的任务进入时,再取消它,是的,取消。

//stackoverflow版本
class Debouncer {
    private let queue = DispatchQueue.main
    private var workItem = DispatchWorkItem(block: {})
    private var interval: TimeInterval

    init(seconds: TimeInterval) {
        self.interval = seconds
    }

    func debounce(action: @escaping (() -> Void)) {
        workItem.cancel()
        workItem = DispatchWorkItem(block: { action() })
        queue.asyncAfter(deadline: .now() + interval, execute: workItem)
    }
}
//改造为线程安全版本的 Debouncing
class Debouncer {
    private let queue: DispatchQueue
    private let interval: TimeInterval
    private let semaphore: DebouncerSemaphore
    private var workItem: DispatchWorkItem?

    init(seconds: TimeInterval, qos: DispatchQoS = .default) {
        interval = seconds
        semaphore = DebouncerSemaphore(value: 1)
        queue = DispatchQueue(label: "debouncer.queue", qos: qos)
    }
    
    func invoke(_ action: @escaping (() -> Void)) {
        semaphore.sync {
            workItem?.cancel()
            workItem = DispatchWorkItem(block: {
                action()
            })
            if let item = workItem {
                queue.asyncAfter(deadline: .now() + self.interval, execute: item)
            }
        }
    }
}

struct DebouncerSemaphore {
    private let semaphore: DispatchSemaphore
    
    init(value: Int) {
        semaphore = DispatchSemaphore(value: value)
    }
    
    func sync(execute: () -> Void) {
        defer { semaphore.signal() }
        semaphore.wait()
        execute()
    }
}
//对应的Throttling实现
class Throttler {
    private let queue: DispatchQueue
    private let interval: TimeInterval
    private let semaphore: DebouncerSemaphore
    private var workItem: DispatchWorkItem?
    private var lastExecuteTime = Date()
    
    init(seconds: TimeInterval, qos: DispatchQoS = .default) {
        interval = seconds
        semaphore = DebouncerSemaphore(value: 1)
        queue = DispatchQueue(label: "throttler.queue", qos: qos)
    }
    
    func invoke(_ action: @escaping (() -> Void)) {
        semaphore.sync {
            workItem?.cancel()
            workItem = DispatchWorkItem(block: { [weak self] in
                self?.lastExecuteTime = Date()
                action()
            })
            let deadline = Date().timeIntervalSince(lastExecuteTime) > interval ? 0 : interval
            if let item = workItem {
                queue.asyncAfter(deadline: .now() + deadline, execute: item)
            }
        }
    }
}

另外杨萧玉有实现一个OC版本 MessageThrottle

其原理是使用Runtime Hook。
模块拆分成了3个类:

  • MTRule 为消息节流的规则,内部持有target,节流消息的 SEL ,时间的阈值等属性。
  • MTEngine 为调度器,功能包括获取规则,应用规则以及销毁等。
  • MTInvocation则是用于消息转发。
  • MTDealloc ,用于当rule的target释放时相应的SEL移除操作。

在一开始applyRule 时,就会使用objc_setAssociatedObject 将selector 作为key把MTDealloc 绑定上去以便于后续discard废弃时的相关操作;

MTEngine 中则使用弱引用的NSMapTable *targetSELs 作为存储结构,将target作为key,对应的value为其selector集合,这样我们就可以通过target 拿到selectors ,再通过每一个selector拿到MTDealloc 对象,最后拿到mtDealloc 的rule。

applyRule 方法则是首先判断对应rule是否合理,并且没有继承关系,则执行mt_overrideMethod 方法利用消息转发流程 hook 的三个步骤:

  1. 给类添加一个新的方法 fixed_selector,对应实现为 rule.selector 的 IMP。
  2. 利用 Objective-C runtime 消息转发机制,将 rule.selector 对应的 IMP 改成 _objc_msgForward 从而触发调用 forwardInvocation: 方法。
  3. forwardInvocation: 的实现替换为自己实现的 IMP 即mt_forwardInvocation 方法,并在自己实现的逻辑中将 invocation.selector 设为 fixed_selector,并限制 [invocation invoke] 的调用频率。

最后到了mt_handleInvocation 方法,根据MTPerformMode 类型处理执行 NSInvocation ,比如在MTPerformModeDebounce 场景下,由于是异步延时执行invoke,需要调用[invocation retainArguments]; 方法保留参数,它会将传入的所有参数以及target都retain一遍,防止之后被释放,然后再检测durationThreshold 时间到来是否还是上一次的lastInvocation,因为没有变化则表示这段时间内没有新的消息,调用invoke 即可。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 162,710评论 4 376
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 68,839评论 2 308
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 112,295评论 0 255
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,776评论 0 223
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 53,198评论 3 297
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 41,074评论 1 226
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,200评论 2 322
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,986评论 0 214
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,733评论 1 250
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,877评论 2 254
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,348评论 1 265
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,675评论 3 265
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,393评论 3 246
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,209评论 0 9
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,996评论 0 201
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 36,212评论 2 287
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 36,003评论 2 280

推荐阅读更多精彩内容