debounce跟throttle是其他语言中(如js)比较常见且常用的两个函数,直译就是防抖跟节流。debounce主要指在一定时间内重复调用某一函数,函数的执行会被一直推迟,直到在规定时间间隔内没有再触发该函数,才会去执行,同时只执行最后一次;throttle正好相反,是在某一时间内,只执行第一次。这么看可能对这两种函数没有概念,那么思考这样一个常见的场景:

在搜索内容的时候,用户在searchbar内输入内容,不断触发textDidChange回调,通常我们在该回调中请求后台搜索接口来搜索内容。但这样就带来一个问题:

搜索接口多次调用

用户只想搜索一个词语,但是由于textDidChange回调是即时触发,所以会调用多次搜索接口。比如用户想搜 abc ,但回调是每输入一个字符就会触发,所以实际上会调用三次搜索接口,分别搜索 a, ab, abc。

这样肯定不行,所以通常我们有两种处理方式:

  1. 加一个搜索按钮
  2. 使用timer或performseletor等方式延迟触发,通过全局静态变量来保存实时的变量、环境等。

第一种算是产品的调整,如果产品不想做成手动搜索的话就行不通了。

第二种方式其实就是debounce的思路,但可能每个人实现起来不太一样,这里只介绍一种比较优雅的使用block的方案(其实这个方案是曾经一次面试的内容,受到的启发很大)

基于block的debounce的实现

其实实现一个debounce函数最重要的是怎么保证函数执行的时候所引用的一些变量及环境等是最新的,同时,如果用一些全局的静态变量来保存实时的变量及环境肯定不够优雅,如果函数需要引用大量的变量,则外部就需要定义许多的static。使用block的方式主要是运用了block本身的特性——即可以捕获外部变量。
下面我将从一个简单的满足debounce的print函数来介绍整个功能的实现思路。


函数设计的思路

  • 首先思考这样一个问题:想要实现一个debounce功能的print函数,这个函数的调用形式应该是怎样的?
    通常我们习惯来说应该跟调用一个普通的print函数方式是一样的,那么应该就是类似这样一种调用方式
    1
    2
    3
    debouncePrint(@"1");
    debouncePrint(@"2");
    ...

如果设计出来一个 DebouncePrint 类,然后每次调用都是

1
[debouncePrintInstance print:@"1"];

也是可以,但就会非常复杂,在目前这个需求的情况下不够优雅,同时也会有其他的问题(这个在后面会再次提到),所以我们准备选择第一种方式。

  • 然后输出的内容根据时间间隔可能是多个输出或者是最后一个输出。那么肯定要有一个 timeInterval 或者叫 delay 之类的属性来控制该函数的时间间隔。那如何来设置这个时间间隔呢?每次调用 debouncePrint 的时候传递?类似
    1
    2
    debouncePrint(@"1", 2) // 2s间隔
    debouncePrint(@"2", 2)

这样么?
很明显,这种调用方法肯定是有问题的,如果多次调用改函数的时候传的时间间隔不同怎么办?按照哪个的时间间隔计算?这种调用方式是会有逻辑上的漏洞的,所以肯定是不行的。时间间隔应该是 debouncePrint 这个函数本身的一个属性,所以这个时间间隔应该是在构造这个函数的时候就指定好的,类似于这样:

1
2
3
debounceOneSecPrint = initDebouncePrintWithSec(1);
debounceTwoSecPrint = initDebouncePrintWithSec(2);
...

这样就OK了。

  • 从这里可以看到,initDebouncePrintWithSec 这个构造函数返回的看上去像是一个函数,在iOS里想实现这种方式最直接的办法就是block。因为调用 debouncePrint 的时候需要传入输出的内容,所以该block肯定是要有一个参数的。

所以目前为止我们明确了实现这样一个功能的几个关键点:

  1. 调用该函数应该足够方便(跟调用普通的print函数尽可能保持一致)
  2. 时间控制应该在构造函数的时候就指定好
  3. 构造函数返回的应该是一个block
  4. 该block应该有至少一个参数作为输出内容,供调用者传入。

明确了这几点之后就可以开始实现这个构造函数了。(中间涉及的一些基础知识暂时就不做介绍了,如果不太清楚的话可以再熟悉熟悉block的一些基本知识)

函数的实现

  • 首先返回值应该是一个block,同时时间间隔需要作为参数传入,那么 initDebouncePrintWithSec 函数应该是这个样子:
    1
    2
    3
    4
    5
    6
    7
    8
    - (void(^)(int printContent)) initDebouncePrintWithSec:(NSTimeInterval) delay; // 简化函数,输出内容就指定int类型了
    // 函数实现
    - (void(^)(int printContent)) initDebouncePrintWithSec:(NSTimeInterval) delay {
    // 设置delay,具体方式后面介绍
    return ^(int printContent) {
    // 输出内容,具体内容后面介绍
    }
    }

到目前为止这个构造函数的框架已经出来了,还需要考虑的问题有两个:

  1. 怎么给返回的 block 绑定传入的时间间隔 delay,也就是如何建立 delay 跟 block 之间的关系?
  2. 怎么实现在 delay 后执行输出及 delay 之间不执行?

1. 怎么给返回的 block 绑定传入的时间间隔 delay,也就是如何建立 delay 跟 block 之间的关系?

通常实现一个类的时候我们会定义一个成员变量或者property来保存该值,init 出来不同的实例之后可以访问到该值,但这里init出来的不是一个对象,而是一个block。block本身也没有什么类似于 block.delay 的方法能获取到这个变量。但block有另外一个特性,前面也提到了,就是block能够捕获外部变量。这也体现了这里使用block方式实现的另一个优点:时间间隔 delay 属性其实只有该 block 关心,没有必要在外部定义任何的变量来保存该值。在外部定义一个成员变量甚至是静态变量却只有一个方法使用到这个变量,这是非常不好的设计。可以想象,如果这个类提供多个功能,而每一个功能使用的变量都定义在类的成员变量或者property里,这该是多么恐怖的一件事情。

2. 怎么实现在 delay 后执行输出及 delay 之间不执行?

在 delay 后执行方法自然可以使用 dispatch_after 来实现:

1
2
3
4
5
6
7
- (void(^)(int printContent)) initDebouncePrintWithSec:(NSTimeInterval) delay {
return ^(int printContent) {
dispatch_after(delay, queue, ^{
print(printContent);
});
}
}

这样的话这个函数每次调用都会固定在delay之后输出内容:

1
2
3
debounceOneSecPrint(1) // 1s后输出1
sleep(0.5) // sleep 0.5s
debounceOneSecPrint(2) // 再1s后输出2

如果想要实现delay时间间隔内只执行一次,肯定是需要校验上一次触发该函数的时间与当前时间是否满足 delay 的间隔,然后每次调用该函数的时候更新最近一次触发时间。所以我们敲下了这样的代码:

1
2
3
4
5
6
7
8
9
10
11
- (void(^)(int printContent)) initDebouncePrintWithSec:(NSTimeInterval) delay {
return ^(int printContent) {
dispatch_time_t lastTime = dispatch_time(DISPATCH_TIME_NOW, 0);
dispatch_after(delay, queue, ^{
dispatch_time_t now = dispatch_time(DISPATCH_TIME_NOW, 0);
if (now >= lastTime + delay) {
print(printContent);
}
});
}
}

当我们满心欢喜的run一下的时候,发现输出的内容是

1
2
3
4
5
6
7
debounceOneSecPrint(1)
sleep(0.5)
debounceOneSecPrint(2)
// 1s后输出1
// sleep 0.5s
// 再1s后输出2

问题出来了,跟没加时间校验的情况一样,每次调用仍然都执行了。。。这是为啥?

原因就是 lastTime 是定义在 block 中,而每次调用block都是一个新的 lastTime,相当于 lastTime 是跟每次调用绑定的,而不是跟 block 绑定的。所以 lastTime 也是需要跟 block 绑定(也就是一个 block 只有一个 lastTime,而不是每次调用都有一个新的 lastTime)。所以 lastTime 应该是在初始化 block 的时候定义的:

1
2
3
4
5
6
7
8
9
10
11
12
- (void(^)(int printContent)) initDebouncePrintWithSec:(NSTimeInterval) delay {
__block dispatch_time_t lastTime = dispatch_time(DISPATCH_TIME_NOW, 0); // 因为lastTime是需要在block内部修改的,所以不要忘了__block(其实忘了也没关系,毕竟也不会编译过的。。。)
return ^(int printContent) {
lastTime = dispatch_time(DISPATCH_TIME_NOW, 0);
dispatch_after(delay, queue, ^{
dispatch_time_t now = dispatch_time(DISPATCH_TIME_NOW, 0);
if (now >= lastTime + delay) {
print(printContent);
}
});
}
}

再充满期待的run一下:

1
2
3
4
5
6
debounceOneSecPrint(1)
sleep(0.5)
debounceOneSecPrint(2)
// sleep 0.5s
// 1s后输出2

大功告成!

至此,通过block实现debounce的思路基本已经完成了。但可能有的人觉得这个方法我只实现了print操作,如果我想实现自定义的方法怎么办?那每次都要改初始化方法?还是给每个操作提供一个初始化方法?当然,这些都不够灵活,如果想灵活的实现操作外部定义也是很简单的,只需要把操作当做一个参数传进来就好了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
- (void(^)()) initDebouncePrintWithSec:(NSTimeInterval) delay action:(void(^)())action {
__block dispatch_time_t lastTime = dispatch_time(DISPATCH_TIME_NOW, 0);
return ^(int printContent) {
lastTime = dispatch_time(DISPATCH_TIME_NOW, 0);
dispatch_after(delay, queue, ^{
dispatch_time_t now = dispatch_time(DISPATCH_TIME_NOW, 0);
if (now >= lastTime + delay) {
action();
}
});
}
}
// 每次调用的时候
debounceFunc1 = initDebouncePrintWithSec(2, ^(){
// do something
})
debounceFunc2 = initDebouncePrintWithSec(5, ^(){
// do something
})

这样就OK了,这也是前面提到的不使用 block,而使用对象方法调用的另一个弊端,如果是使用对象方法,那每次增加一个操作都需要在类中定义出来对应的方法,也不符合开闭原则。

至此该篇内容基本已经结束了,throttle的实现也类似于debounce,只是判断条件稍有不同而已,大家如果感兴趣可以自己尝试一下。