iOS 上的函数防抖与节流

前言

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

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

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

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

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

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

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

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

因为代码实现问题,和大佬请教。说明完目的,他一听就说,这不是函数防抖和节流吗?在前端很常见..

好嘛…原来人家前端早就有了?我又学会了新姿势。

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

什么是函数防抖和节流

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

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

使用的大概效果是这样:

demo

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

👾 防抖 (Debounce)

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

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

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

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

🐯 节流 (Throttle)

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

使用场景

函数节流的使用场景:

  • 防止多次点击

  • 重复发多个网络网络请求

    等等..

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

1
2
3
4
if((now-last)<time){
return;
}
//do something

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

而特殊一点的节流需求:

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

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

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

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

现成的轮子 - MessageThrottle

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

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

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

MessageThrottle 使用

它的使用很简单:

1
2
3
4
5
6
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 上:

1
2
3
4
5
6
7
8
9
10
11
- (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操作:

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
/**
处理执行 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 篇相关的说明:

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

这次感想就是:

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

参考文章