iOS 上的函数防抖与节流

8,949 阅读6分钟

前言

函数防抖与节流不是新概念,在前端领域很常见,也是面试中的常客,搜索"前端 函数防抖"能看到很多文章。

相反,在 iOS 上却看不到很多介绍。

第一次知道 函数防抖与节流,是在 2018 年做交易所项目:

当时的场景,是实时更新交易数据。

交易订单数据变化很频繁,每次都去刷新,显然不是一个好方法。

而且不能直接丢数据,常规的"第一次执行,后续丢弃"的限频策略,满足不了需求。

当时思考,这个策略应满足的条件:

  • 一定时间内,合并多次触发为一次,并且触发时数据是最新的.

因为代码实现问题,和大佬请教。

说明完目的,他一听就说,这不是函数防抖和节流吗?在前端很常见..

好嘛...原来人家前端早就有了?我都工作 2 年里才知道,又学会了新姿势,好饭不怕晚。

而我发现这个概念,不仅是前端,后端也能应用,甚至 TCP 的流量控制策略,就是属于函数防抖。

什么是函数防抖和节流

前面解释了为什么要用到 函数防抖和节流,现在说说它们具体是什么。

很多文章都提到一个演示的网址 debounce&throttle,里面模拟鼠标移动事件几种情况的调用,带颜色的竖线,代表一次函数执行。

使用的大概效果是这样:

demo

  • regular 代表常规情况,不做限制时,函数直接调用的结果。
  • deboundce 代表防抖,可以发现,如果函数一直调用,它不会立即执行,而是等到一段时间后,函数没有新调用,它才执行一次。
  • throttle 代表节流,在一定时间内,只执行一次

👾 防抖 (Debounce)

防抖的情况,有点像一个极度珍惜 执行机会的人,只要时间段内,有任务来,就再等一会。

等到最后一次,超过一定时间,确定没有新任务了,才去做执行。

有人觉得它像黑车司机,有人形容它是上班时的电梯,但黑车或者电梯容量满了都会开走。

而我认为,它就像一只耐心上好的怪兽,等到所有食物都来完了,确定没有新食物,再张开它的大嘴,一网打尽。

🐯 节流 (Throttle)

节流比较好理解,在一定时间段内,丢弃掉其它触发,就做一次执行。

使用场景

函数节流的使用场景:

  • 防止多次点击

  • 重复发多个网络请求

    等等..

其实函数节流 最简单的实现方式,仅用时间戳对比,就可以办到,大家一般这么写:

if((now-last)<time){
  return;
}
last = now;
//do something

很多人已经用过了,只是不知道名称。

而特殊一点的节流需求:

时间段内,只执行最后一次触发,丢掉之前的触发。

碰到的应用场景是,消息队列在时间段内有数据变化,在最后一次进行批量处理传递。

函数防抖,我看到的使用场景:

  • 列表刷新,为避免短时间内反复 reload,可以多次合并为一次
  • TCP 流量控制
  • 直播房间的全屏游戏界面,点击 1 次出现控制工具,一定时间内,多次点击不隐藏工具。等时间过去后,执行自动隐藏

现成的轮子 - MessageThrottle

按照套路,该亮出自己的代码来实现了。

然而 iOS 也早有人实现了轮子,不重复造轮子嘛,可以直接使用。

发现 MessageThrottle 是比较完备的实现,而且在手 Q 中应用了,质量比较可靠。推荐一下。

MessageThrottle 使用

它的使用很简单:

Stub *s = [Stub new];
MTRule *rule = [MTRule new];
rule.target = s; // You can also assign `Stub.class` or `mt_metaClass(Stub.class)`
rule.selector = @selector(foo:);
rule.durationThreshold = 0.01;
[MTEngine.defaultEngine applyRule:rule]; // or use `[rule apply]`

主要就是对 MTRule的设置,决定我们将以哪种模式,多少的时间限制来控制方法调用。

MessageThrottle 分析

虽说不再造轮子,但要了解它是什么样的。当然如不感兴趣,看使用也够了。

整个库就只有 MessageThrottle.hMessageThrottle.m 两个文件。

主要思路是:对进行节流和防抖的方法,进行 hook,然后再统一做处理。

其实里面能学习的点不少,这里只大概介绍一下。

主要设计思路

引用作者自己说明主要类关系的图,虚线代表弱引用:

NSMapTable 存储数据

MTEngine 中通过 NSMapTable 来以target 作为key,selector数组作为 value,来存储管理数据。

NSMapTable 的一个特性是支持任意指针作为 Key 且无需持有,NSMapTable 也会自动移除那些键或值为 nil 的数据。

通过关联对象进行规则移除

一个关键设计点在于,使用关联对象,将 MTDealloc 对象关联在 target 上:

- (MTDealloc *)mt_deallocObject
{
    MTDealloc *mtDealloc = objc_getAssociatedObject(self.target, self.selector);
    if (!mtDealloc) {
        mtDealloc = [MTDealloc new];
        mtDealloc.rule = self;
        mtDealloc.cls = object_getClass(self.target);
        objc_setAssociatedObject(self.target, self.selector, mtDealloc, OBJC_ASSOCIATION_RETAIN);
    }
    return mtDealloc;
}

关联对象设计的好处是:

在 target 释放时,关联对象也是会被清除的,所以 MTDealloc 对象也会释放,达到了 target 释放时自动移除 rule 的效果。

在 MTDealloc 的 dealloc 方法进行discard操作:

- (void)dealloc
{
    SEL selector = NSSelectorFromString(@"discardRule:whenTargetDealloc:");
    ((void (*)(id, SEL, MTRule *, MTDealloc *))[MTEngine.defaultEngine methodForSelector:selector])(MTEngine.defaultEngine, selector, self.rule, self);
}

里面调用写的有点骚...其实就是:

[MTEngine.defaultEngine discardRule:self.rule whenTargetDealloc:self];

消息转发中的核心处理逻辑

整个库的核心处理在 mt_handleInvocation 中:

/**
 处理执行 NSInvocation

 @param invocation NSInvocation 对象
 @param rule MTRule 对象
 */
static void mt_handleInvocation(NSInvocation *invocation, MTRule *rule)
{
    NSCParameterAssert(invocation);
    NSCParameterAssert(rule);
    
    if (!rule.isActive) {//规则非 active 状态的,直接 invoke
        [invocation invoke];
        return;
    }
    
    if (rule.durationThreshold <= 0 || mt_invokeFilterBlock(rule, invocation)) {//时间小于等于0,设置aliasSelector(为原始方法IMP)后执行.
        invocation.selector = rule.aliasSelector;
        [invocation invoke];
        return;
    }
    
    //时间戳处理,用 correctionForSystemTime 校正系统时间所需的差值。
    NSTimeInterval now = [[NSDate date] timeIntervalSince1970];
    now += MTEngine.defaultEngine.correctionForSystemTime;
    
    switch (rule.mode) {
        //节流模式:执行第一次触发
        case MTPerformModeFirstly: {
            //触发时,直接看现在的时间间隔是否比限制时间大,如果大于则直接执行,否则不响应
            if (now - rule.lastTimeRequest > rule.durationThreshold) {
                invocation.selector = rule.aliasSelector;
                [invocation invoke];
                //执行后,更新最近执行时间
                rule.lastTimeRequest = now;
                dispatch_async(rule.messageQueue, ^{
                    // May switch from other modes, set nil just in case.
                    rule.lastInvocation = nil;
                });
            }
            break;
        }
        //节流模式:执行最后一次触发
        case MTPerformModeLast: {
            invocation.selector = rule.aliasSelector;
            //invocation 提前持有参数,防止延迟执行时被释放掉
            [invocation retainArguments];
            dispatch_async(rule.messageQueue, ^{
                //更新最近触发的 invocation
                rule.lastInvocation = invocation;
                //如间隔时间超出 rule 限定时间,则对方法做执行。保证为最后一次调用
                if (now - rule.lastTimeRequest > rule.durationThreshold) {
                    //更新执行时间
                    rule.lastTimeRequest = now;
                    //按规则的间隔时间后执行 invoke
                    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(rule.durationThreshold * NSEC_PER_SEC)), rule.messageQueue, ^{
                        if (!rule.isActive) {
                            rule.lastInvocation.selector = rule.selector;
                        }
                        [rule.lastInvocation invoke];
                        //invoke 后将 lastInvocation 置 nil
                        rule.lastInvocation = nil;
                    });
                }
            });
            break;
        }
        //防抖模式:一段时间内不再有新触发,再执行
        case MTPerformModeDebounce: {
            //设置 invocation 的 selector
            invocation.selector = rule.aliasSelector;
            //提前持有参数
            [invocation retainArguments];
            dispatch_async(rule.messageQueue, ^{
                //更新 invocation
                rule.lastInvocation = invocation;
                //在限制时间段过后做执行
                dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(rule.durationThreshold * NSEC_PER_SEC)), rule.messageQueue, ^{
                    //假如还是rule.invocation 和 invocation一样,证明没有新的触发,达到执行条件
                    if (rule.lastInvocation == invocation) {
                        if (!rule.isActive) {
                            rule.lastInvocation.selector = rule.selector;
                        }
                        [rule.lastInvocation invoke];
                        rule.lastInvocation = nil;
                    }
                });
            });
            break;
        }
    }
}

而作者自己也写了相关 4 篇相关的说明:

限于篇幅,不再继续放代码了,可以详细阅读作者说明和源码。

这次感想就是:

  • 再次比较的深体会到,很多概念或者策略,整个大前端领域是基本通用的,甚至在整个计算机技术里都是通用的。
  • 每当自己有什么想法,基本上前人都会有很好的实现了,大家常常是站在巨人的肩膀上做事情。感谢这么多优秀程序员的创造与分享

参考文章