

Objective-C Message Throttle and Debounce
source link: http://yulingtianxia.com/blog/2017/11/05/Objective-C-Message-Throttle-and-Debounce/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

在实际项目中经常会遇到因方法调用频繁而导致的 UI 闪动问题和性能问题,这时用某种策略需要控制调用频率,以达到节流和防抖的效果。MessageThrottle 是我实现的一个 Objective-C 消息节流和防抖的轻量级工具库,使用便捷且业务无关。
读懂本文的前提是对 Objective-C Runtime 和 Objective-C 消息发送与转发机制原理有一定了解。
函数节流(throttle)是一个很基础的概念,常常跟函数防抖(debounce)作比较。在处理连续事件时比较常用,可以通过这个 Demo 感受下二者区别。在 JS 中有较多的实现和应用案例,可以查看这篇文章 更直接地了解下。
虽然在开发 iOS 和 macOS 的时候不用过多关心连续事件的采样问题,但有时也需要避免某个方法被频繁调用。比如一个很复杂的页面可能会频繁请求网络,每次回包都需更新界面,这时就需要防抖,控制刷新频率。
在 Objective-C 中,方法调用其实就是消息发送,所以我改了个名字,叫消息节流和防抖。
假如我创建了一个 Stub
类的实例 s
,我想限制它调用 foo:
方法的频率。先要创建并配置一个 MTRule
,并将规则应用到 MTEngine
单例中:
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]`
target
可以是一个实例对象,也可以是一个类或元类。这样可以更灵活地控制限制策略,既可以只控制某个对象的消息发送频率,也可以控制某个类的实例方法和类方法的频率。当然,规则的 target
为实例对象的优先级比类更高,也不会发生冲突。
当然还有更简单的用法,跟上面那段代码作用相同:
[s limitSelector:@selector(foo:) oncePerDuration:0.01]; // returns MTRule instance
无论是节流还是防抖,都需要设定一个时间 durationThreshold
阈值来限制频率,都意味着方法在最后会延迟调用。MTRule
默认的模式是 MTPerformModeDebounce
,也就是防抖模式,需要等消息不再连续频繁发送后才执行。MTPerformModeLast
和 MTPerformModeFirstly
对应着节流模式,也就是控制一定时间内只执行一次。区别在于前者执行的是这段时间内最后发送的消息,后者执行第一次发送的消息。
比如我想要控制界面上某个 Label 内容的更新频率,给用户更好的体验,这时候很适合使用 MTPerformModeLast
模式:
rule.mode = MTPerformModeLast;
当然所有规则都是可以动态调整的,也就是在应用规则以后,依然可以改变 MTRule
对象中各项配置,并会在下次消息发送时生效。如果调皮地将 durationThreshold
改成非正数,那么等同于立即执行方法,不会限制频率。
当使用 MTPerformModeDebounce
和 MTPerformModeLast
模式的时候,因为执行消息会有延迟,可以指定执行消息的队列 messageQueue
,默认为主队列。
当想要废除某条规则时,使用一行代码即可:
[MTEngine.defaultEngine discardRule:rule]; // or use `[rule discard]`
应用和废除规则都是线程安全的。
参照 Aspects 和 JSPatch 中 Hook 的原理,将限制频率逻辑嵌入消息转发流程中:
- 给类添加一个新的方法
fixed_selector
,对应实现为rule.selector
的IMP
。 - 利用 Objective-C runtime 消息转发机制,将
rule.selector
对应的IMP
改成_objc_msgForward
从而触发调用forwardInvocation:
方法。 - 将
forwardInvocation:
的实现替换为自己实现的IMP
,并在自己实现的逻辑中将invocation.selector
设为fixed_selector
。并限制[invocation invoke]
的调用频率。
这种做法的缺陷是如果同时 hook 了基类和子类的同一个方法,且子类调用了基类的方法,就会导致循环调用。因为调用 super
方法时,传入的 target
还是 self
对象,导致调用了子类的方法。好在这里并不允许同时 hook 一条继承链上的两个类,因为子类和基类限制频率的规则会相互干扰,导致不易发现的 bug。
MessageThrottle 从设计上使用 MTEngine
单例这种中心化的的方式来管理所有规则。Aspects 是将 hook 的上下文插入到对应的 target
中,这样的好处是需要暴露的接口较少。而 MessageThrottle 需要提供当前所有的规则给使用方。因为方法调用频率的限制会影响其上游代码和下游代码的运行频率,所以中心化管理的做法很有必要。
由于配置规则的内容较多,如果使用逐个传参的方式,方法名会很长。所以这里用 MTRule
类封装了规则的上下文,并使用 applyRule:
和 discardRule:
方法应用和废除规则。
管理 MTRule
MTEngine
内部使用键值对存取 MTRule
,这里使用 target
和 selector
的组合值作为 key。这里只要保证唯一性即可区分不同的规则,格式不固定:
static NSString * mt_methodDescription(id target, SEL selector)
{
NSString *selectorName = NSStringFromSelector(selector);
if (object_isClass(target)) {
NSString *className = NSStringFromClass(target);
return [NSString stringWithFormat:@"%@ [%@ %@]", class_isMetaClass(target) ? @"+" : @"-", className, selectorName];
}
else {
return [NSString stringWithFormat:@"[%p %@]", target, selectorName];
}
}
在应用和废除规则的时候,需要检查规则合法性。这里只是简单检查下库中涉及的类和方法,一些内存管理和runtime 的方法并没有做限制,毕竟用户想作死我也管不着:
static BOOL mt_checkRuleValid(MTRule *rule)
{
if (rule.target && rule.selector && rule.durationThreshold > 0) {
NSString *selectorName = NSStringFromSelector(rule.selector);
if ([selectorName isEqualToString:@"forwardInvocation:"]) {
return NO;
}
Class cls;
if (object_isClass(rule.target)) {
cls = rule.target;
}
else {
cls = object_getClass(rule.target);
}
NSString *className = NSStringFromClass(cls);
if ([className isEqualToString:@"MTRule"] || [className isEqualToString:@"MTEngine"]) {
return NO;
}
return YES;
}
return NO;
}
处理 NSInvocation
在进入到消息转发流程调用 forwardInvocation:
方法时会进入到自定义的处理逻辑中,然后决定是否执行 [invocation invoke]
。之前已经将原始 selector
的 IMP 替换成了 fixedSelector
,所以调用 [invocation invoke]
之前需要调用 invocation.selector = fixedSelector
。
下面的函数就是处理 NSInvocation
对象的逻辑。先用 target
和 selector
获取 MTRule
对象,进而根据不同的 mode
采取不同的策略。如果 durationThreshold
非正数就立即执行方法。
static void mt_handleInvocation(NSInvocation *invocation, SEL fixedSelector)
{
NSString *methodDescriptionForInstance = mt_methodDescription(invocation.target, invocation.selector);
NSString *methodDescriptionForClass = mt_methodDescription(object_getClass(invocation.target), invocation.selector);
MTRule *rule = MTEngine.defaultEngine.rules[methodDescriptionForInstance];
if (!rule) {
rule = MTEngine.defaultEngine.rules[methodDescriptionForClass];
}
if (rule.durationThreshold <= 0) {
[invocation setSelector:fixedSelector];
[invocation invoke];
return;
}
NSTimeInterval now = [[NSDate date] timeIntervalSince1970];
switch (rule.mode) {
case MTPerformModeFirstly:
...
break;
case MTPerformModeLast:
...
break;
case MTPerformModeDebounce:
...
break;
}
}
上面代码省略了不同 mode
的处理逻辑,下面会逐个讲解。
MTPerformModeFirstly
MTModePerformFirstly:
start end
| durationThreshold |
@-------------------------@----------@---------------@---------------->>
| | | |
perform immediately ignore ignore ignore
最简单粗暴的实现方式,忽略第一次发送消息之后 durationThreshold
时间段内的所有消息。
if (now - rule.lastTimeRequest > rule.durationThreshold) {
rule.lastTimeRequest = now;
invocation.selector = fixedSelector;
[invocation invoke];
rule.lastInvocation = nil;
}
MTPerformModeLast
MTModePerformLast:
start end
| durationThreshold |
@-------------------------@----------@---------------@---------------->>
| | | |
ignore ignore ignore will perform at end
在 durationThreshold
时间内不断更新 lastInvocation
的值,并在达到阈值 durationThreshold
后执行 [lastInvocation invoke]
。这样保证了执行的是最后一次发送的消息。需要注意的是,NSInvocation
对象默认不会持有参数,在异步延迟执行 invoke
的时候参数可能已经被释放了,进而野指针 crash。所以需要调用 retainArguments
方法提前持有参数,防止之后被释放掉。如果实际传入的参数与参数类型不符,可能导致 retainArguments
方法 crash。我曾想过将参数列表保存到一个 NSArray
里,然后放到 MTRule
中,这样可以对参数类型做判断,避免 crash,也顺便持有了参数列表。但发现需要覆盖的类型太多,工作量和风险更多。我把这个半成品代码放在了 GitHubGist 上: ConvertInvocationArguments.m
if (now - rule.lastTimeRequest > rule.durationThreshold) {
rule.lastTimeRequest = now;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(rule.durationThreshold * NSEC_PER_SEC)), rule.messageQueue, ^{
[rule.lastInvocation invoke];
rule.lastInvocation = nil;
});
}
else {
invocation.selector = fixedSelector;
rule.lastInvocation = invocation;
[rule.lastInvocation retainArguments];
}
MTPerformModeDebounce
MTModePerformDebounce:
start end
| durationThreshold(old) |
@----------------------@---------------------->>
| |
ignore will perform at end of new duration
|--------------------------------------------->>
| durationThreshold(new) |
start end
虽然流程看上去复杂但其实实现起来也很简单。每次发送消息完再过 durationThreshold
时间后,检查下 lastInvocation
有没有变化。如果无变化,则说明这段时间内没有新的消息发送,则可以执行 lastInvocation
。
invocation.selector = fixedSelector;
rule.lastInvocation = invocation;
[rule.lastInvocation retainArguments];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(rule.durationThreshold * NSEC_PER_SEC)), rule.messageQueue, ^{
if (rule.lastInvocation == invocation) {
[rule.lastInvocation invoke];
rule.lastInvocation = nil;
}
});
规则的应用与废除
在真正应用规则之前,需要检查下规则合法性,然后检查继承链上是否已经应用过规则了。如果有,则需要输出错误信息;否则应用规则。这里使用 POSIX 的互斥锁保证线程安全。mt_overrideMethod()
函数所作的事情就是开始提到的利用消息转发流程 hook 的三个步骤。
- (BOOL)applyRule:(MTRule *)rule
{
pthread_mutex_lock(&mutex);
__block BOOL shouldApply = YES;
if (mt_checkRuleValid(rule)) {
[self.rules enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key, MTRule * _Nonnull obj, BOOL * _Nonnull stop) {
if (rule.selector == obj.selector
&& object_isClass(rule.target)
&& object_isClass(obj.target)) {
Class clsA = rule.target;
Class clsB = obj.target;
shouldApply = !([clsA isSubclassOfClass:clsB] || [clsB isSubclassOfClass:clsA]);
*stop = shouldApply;
NSString *errorDescription = [NSString stringWithFormat:@"Error: %@ already apply rule in %@. A message can only have one throttle per class hierarchy.", NSStringFromSelector(obj.selector), NSStringFromClass(clsB)];
NSLog(@"%@", errorDescription);
}
}];
if (shouldApply) {
self.rules[mt_methodDescription(rule.target, rule.selector)] = rule;
mt_overrideMethod(rule.target, rule.selector);
}
}
pthread_mutex_unlock(&mutex);
return shouldApply;
}
废除规则是执行相反的操作。如果 target
是个实例对象,mt_recoverMethod()
函数会判断是否有相同 selector
且 target
为这个实例对象的类的其他规则。如果有,那将不会移除 hook。
- (BOOL)discardRule:(MTRule *)rule
{
pthread_mutex_lock(&mutex);
BOOL shouldDiscard = NO;
if (mt_checkRuleValid(rule)) {
NSString *description = mt_methodDescription(rule.target, rule.selector);
shouldDiscard = self.rules[description] != nil;
if (shouldDiscard) {
self.rules[description] = nil;
mt_recoverMethod(rule.target, rule.selector);
}
}
pthread_mutex_unlock(&mutex);
return shouldDiscard;
}
其实在开发过程中遇到需要限制方法调用频率的场景并不多,只是最近恰巧连续碰到几个刷新 UI 过频繁的问题,才想到应该去造个轮子。因为时间仓促,肯定还有考虑不周和一些 bug,待投入使用后慢慢完善和修复。
其实想在某个特定函数做节流很简单,但每次都需要做重复劳动,写脏代码,还不如抽象出一个工具类出来。尽量造与业务无关的轮子,锻炼技术,也受益整个业务发展。
好,装逼到此为止。Github : https://github.com/yulingtianxia/MessageThrottle
Recommend
-
80
在实际项目中经常会遇到因方法调用频繁而导致的 UI 闪动问题和性能问题,这时用某种策略需要控制调用频率,以达到节流和防抖的效果。MessageThrottle 是我实现的一个 Objective-C 消息节流和防抖的轻量级工具库,使用便捷且业务无关。
-
96
一、概念这两个东西都是为了项目优化而出现的,官方是没有具体定义的,他们的出现主要是为了解决一些短时间内连续执行的事件带来性能上的不佳和内存的消耗巨大等问题;像这类事件一般像 scroll keyup mousemove resize等等,短时间内不断的触发,在
-
60
在2011年,Twitter网站曾爆出一个问题:在主页往下滚动时,页面会变得缓慢以致没有响应。John Resig发表了一篇文章《 a blog post about the problem》指出直接在scroll事件上面绑定高消耗的事件是一个多么愚蠢的想法。现在项目中大家都会...
-
88
-
47
引入 浏览器中某些计算和处理非常昂贵。比如当鼠标响应resize, touchmove,scroll等操作时,绑定的函数触发的频率会很高,如果该函数稍微复杂一些,响应速度会远远跟不上触发频率,便会出现卡顿,延迟,假死等现象。 下面来看一个例子,根据输入框输入的
-
69
为何要防抖和节流 有时候会在项目开发中频繁地触发一些事件,如 resize、 scroll、 keyup、 keydown等,或者诸如输入框的实时搜索功能,我们知道如果事件处理函数无限制调用,会大大加重浏览器的工作量,有可能导致页面卡顿影响体验;后台接口的频繁
-
9
How to Correctly Debounce and Throttle Callbacks in ReactWhen a React component handles bursting events like window resize, scrolling, user typing into an input, etc. it makes sense to soften the handlers of these events. Otherwise,...
-
8
How to Debounce and Throttle Callbacks in VueListening for often occurring events like user typing into the input field, window resize, scroll, intersection observer events etc. requires precaution. These events could occur so...
-
11
-
5
Blog / JavaScript / React / How And When To De...
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK