实现iOS中的函数节流和函数防抖

函数防抖与节流

今天来和大家谈论一个非常有意思的话题,就是函数节流和函数防抖。
可能大家还不是非常了解这两个术语的意思,让我们先来看下他们的含义吧。


Throttling enforces a maximum number of times a function can be called over time. As in “execute this function at most once every 100 milliseconds.”

首先是函数节流(Throttling),意思就是说一个函数在一定时间内,只能执行有限次数。比如,一个函数在100毫秒呢,最多只能执行一次。当然,也可以不执行!

看完了节流,我们再来看看函数防抖(Debouncing)。


Debouncing enforces that a function not be called again until a certain amount of time has passed without it being called. As in “execute this function only if 100 milliseconds have passed without it being called.”

函数防抖的意思就是,一个函数被执行过一次以后,在一段时间内不能再次执行。比如,一个函数执行完了之后,100毫秒之内不能第二次执行。

咦?有人看到这,肯定就有疑惑了,这两玩意有啥区别啊?这么解释吧,函数防抖可能会被无限延迟。用现实乘坐公交车中的例子来说,Throttle就是准点就发车(比如15分钟一班公交车);Debounce就是黑车,上了一个人以后,司机说,再等一个人,等不到,咱么10分钟后出发。但是呢,如果在10分钟内又有一个人上车,这个10分钟自动延后直到等待的10分钟内没人上车了。换句话说,Debounce可以理解成merge一段时间的一系列相同函数调用。

如果还不能理解,这里有个很好玩的在线演示 (PS:你必须用电脑上)

JavaScript中的函数节流和防抖

说到防抖和节流,我们就不得不先来提一提JavaScript中坑爹的DOM操作。我们直接看underscore.js中的源码:
首先是Throttle:

_.throttle = function(func, wait) {
  var context, args, result;
  var timeout = null;

  var previous = 0;
  var later = function() {
    // 若设定了开始边界不执行选项,上次执行时间始终为0
    previous = options.leading === false ? 0 : _.now();
    timeout = null;
    result = func.apply(context, args);
    if (!timeout) context = args = null;
  };
  return function() {
    var now = _.now();
    // 首次执行时,如果设定了开始边界不执行选项,将上次执行时间设定为当前时间。
    if (!previous && options.leading === false) previous = now;
    // 延迟执行时间间隔
    var remaining = wait - (now - previous);
    context = this;
    args = arguments;
    // 延迟时间间隔remaining小于等于0,表示上次执行至此所间隔时间已经超过一个时间窗口
    // remaining大于时间窗口wait,表示客户端系统时间被调整过
    if (remaining <= 0 || remaining > wait) {
      clearTimeout(timeout);
      timeout = null;
      previous = now;
      result = func.apply(context, args);
      if (!timeout) context = args = null;
    //如果延迟执行不存在,且没有设定结尾边界不执行选项
    } else if (!timeout && options.trailing !== false) {
      timeout = setTimeout(later, remaining);
    }
    return result;
  };
};

代码逻辑非常好理解,

var remaining = wait - (now - previous);

就是计算下上一次调用的时间和现在所处的时间间隔是不是超过了wait的时间(这个时间只有可能会大于等于wait,因为runloop是很繁忙的,前面一个任务很耗时,那你就多等一会呗)
可能会有人不理解option是啥意思,我们看下代码中涉及了以下两个参数:

options.leadingoptions.trailing

这两个参数的意思就是禁止第一次调用和最后一次调用,简单吧。

理解了函数节流,我们再来看看防抖是怎么做的。
_.debounce = function(func, wait, immediate) {
var timeout, args, context, timestamp, result;

  var later = function() {
    // 据上一次触发时间间隔
    var last = _.now() - timestamp;

    // 上次被包装函数被调用时间间隔last小于设定时间间隔wait
    if (last < wait && last > 0) {
      timeout = setTimeout(later, wait - last);
    } else {
      timeout = null;
      // 如果设定为immediate===true,因为开始边界已经调用过了此处无需调用
      if (!immediate) {
        result = func.apply(context, args);
        if (!timeout) context = args = null;
      }
    }
  };

  return function() {
    context = this;
    args = arguments;
    timestamp = _.now();
    var callNow = immediate && !timeout;
    // 如果延时不存在,重新设定延时
    if (!timeout) timeout = setTimeout(later, wait);
    if (callNow) {
      result = func.apply(context, args);
      context = args = null;
    }

    return result;
  };
};

可以看到,在防抖的函数中包含了一个timestamp,这个参数用来记录了当前这个函数最后一次被调用是什么时候,每次调用的时候,就更新了timestamp。有人可能会问,这个timestamp在多次调用的过程中还能保留吗?答案是肯定的,debounce函数返回的实际是一个匿名函数,这个匿名函数就是一个闭包环境,可以捕捉timestap进行持久话访问,所以多次调用这个匿名函数实际上访问的都是同一个timestamp。

实现iOS中的函数节流

重头戏来啦!!!!。
在iOS中,我们经常会遇到这样的需求,比如说你要根据UIScrollView的contentOffset进行某些计算。这些计算有的很简单不耗时,比如根据offset动态的更改UINavigationBar的alpha值。但是有些就很复杂,比如你要根据某些offset启动某些动画,或者进行大规模的运算,还有很多时候时候会发送很多异步的API请求,由于很多用户会不停的用指尖在完美的iPhone屏幕上来回滑动,你一定不想你的App甚至是服务器这些操作给玩崩了,所以,函数节流是必不可少的。

初级版本

第一个版本的函数节流如下:

#import "PerformSelectorWithDebounce.h"
#import <objc/runtime.h>
#import <objc/message.h>

@implementation NSObject (Throttle)

const char * WZThrottledSelectorKey;

- (void) wz_performSelector:(SEL)aSelector withThrottle:(NSTimeInterval)duration
{
    NSMutableDictionary *blockedSelectors = objc_getAssociatedObject(self, WZThrottledSelectorKey);

    if(!blockedSelectors)
    {
        blockedSelectors = [NSMutableDictionary dictionary];
        objc_setAssociatedObject(self, kDictionaryOfSelectorsToBlock, blockedSelectors, OBJC_ASSOCIATION_RETAIN);
    }

    NSString * selectorName = NSStringFromSelector(aSelector);
    if(![blockedSelectors objectForKey:aSelectorAsStr])
    {
        [blockedSelectors setObject:selectorName selectorName];

        objc_msgSend(self, aSelector);
        [self performSelector:@selector(unlockSelector:) withObject:aSelectorAsStr afterDelay:duration];

    }
}

-(void)unlockSelector:(NSString*)selectorAsString
{
    NSMutableDictionary *blockedSelectors = objc_getAssociatedObject(self, WZThrottledSelectorKey);
    [blockedSelectors removeObjectForKey:selectorAsString];
}

初看这个代码逻辑,这个代码基本实现了在一定时间内只调用有限次数的函数。但是,这哪是函数节流啊,这是函数断流!不信你们在[UIScrollView scrollViewDidScroll]试试看,你们就知道啥叫断流了。原因也很简单,我们的unlockSelector是基于performSelector解锁的,而performSelector是基于runloop的,我们在不停的滚动的时候就会导致整个主runloop被我们占用,因此,unlock的函数一直没有得到调用,结果就导致了断流。

高级版本

因此,我又实现了一个高级版本,代码如下,需要测试用例和源码的可以上我的github自取,地址戳我。代码逻辑很简单同时也解决了上述版本所提及的问题。

#import "NSObject+Throttle.h"
#import <objc/runtime.h>
#import <objc/message.h>

static char WZThrottledSelectorKey;
static char WZThrottledSerialQueue;

@implementation NSObject (Throttle)

- (void)wz_performSelector:(SEL)aSelector withThrottle:(NSTimeInterval)inteval
{
    dispatch_async([self getSerialQueue], ^{
        NSMutableDictionary *blockedSelectors = [objc_getAssociatedObject(self, &WZThrottledSelectorKey) mutableCopy];

        if (!blockedSelectors) {
            blockedSelectors = [NSMutableDictionary dictionary];
            objc_setAssociatedObject(self, &WZThrottledSelectorKey, blockedSelectors, OBJC_ASSOCIATION_COPY_NONATOMIC);
        }

        NSString *selectorName = NSStringFromSelector(aSelector);
        if (![blockedSelectors objectForKey:selectorName]) {
            [blockedSelectors setObject:selectorName forKey:selectorName];
            objc_setAssociatedObject(self, &WZThrottledSelectorKey, blockedSelectors, OBJC_ASSOCIATION_COPY_NONATOMIC);

            dispatch_async(dispatch_get_main_queue(), ^{
                objc_msgSend(self, aSelector);

                dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(inteval * NSEC_PER_SEC)), [self getSerialQueue], ^{
                    [self unlockSelector:selectorName];
                });
            });
        }
    });
}

#pragma mark - Private
- (void)unlockSelector:(NSString *)selectorName
{
    dispatch_async([self getSerialQueue], ^{
        NSMutableDictionary *blockedSelectors = [objc_getAssociatedObject(self, &WZThrottledSelectorKey) mutableCopy];

        if ([blockedSelectors objectForKey:selectorName]) {
            [blockedSelectors removeObjectForKey:selectorName];
        }

        objc_setAssociatedObject(self, &WZThrottledSelectorKey, blockedSelectors, OBJC_ASSOCIATION_COPY_NONATOMIC);
    });
}

- (dispatch_queue_t)getSerialQueue
{
    dispatch_queue_t serialQueur = objc_getAssociatedObject(self, &WZThrottledSerialQueue);
    if (!serialQueur) {
        serialQueur = dispatch_queue_create("com.satanwoo.throttle", NULL);
        objc_setAssociatedObject(self, &WZThrottledSerialQueue, serialQueur, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    }
    return serialQueur;
}

@end