在 Cocoa Touch 中实现一个 Debounced/Coalesced 模式,例如 `layoutSubviews`

Implement a Debounced/Coalesced Pattern in Cocoa Touch like `layoutSubviews`

许多 Cocoa Touch 类 利用合并事件的设计模式。 UIViews,例如,有一个方法 setNeedsLayout 导致 layoutSubviews 在不久的将来被调用。这在许多属性影响布局的情况下特别有用。在每个 属性 的 setter 中,您可以调用 [self setNeedsLayout] 这将确保布局将被更新,但如果同时更改多个属性,将阻止对布局的许多(可能是昂贵的)更新或者即使在 运行 循环的一次迭代中多次修改单个 属性。 setNeedsDisplaydrawRect: 对方法等其他昂贵的操作遵循相同的模式。

实现这样的模式的最佳方法是什么?具体来说,我想将一些依赖属性绑定到一个昂贵的方法,每次迭代都需要调用一次如果 属性 已更改,则 运行 循环。


可能的解决方案

使用 CADisplayLinkNSTimer 你可以得到像这样的东西,但两者似乎都比必要的更复杂,我不确定将它添加到很多对象的性能影响(尤其是计时器)会。毕竟,性能是做这样的事情的唯一理由。

我在某些情况下使用过类似的东西:

- (void)debounceSelector:(SEL)sel withDelay:(CGFloat)delay {
    [NSObject cancelPreviousPerformRequestsWithTarget:self selector:sel object:nil];
    [self performSelector:sel withObject:nil afterDelay:delay];
}

这在用户输入只应在连续操作或类似情况下触发某个事件的情况下非常有效。当我们想要确保没有延迟触发事件时,这看起来很笨拙,相反我们只想在同一个 运行 循环中合并调用。

这与 "primarily opinion based" 接壤,但我会抛出我通常的处理方法:

设置一个标志,然后使用 performSelector 对处理进行排队。

在你的@interface 中输入:

@property (nonatomic, readonly) BOOL  needsUpdate;

然后在你的@implementation 中输入:

-(void)setNeedsUpdate {
    if(!_needsUpdate) {
        _needsUpdate = true;
        [self performSelector:@selector(_performUpdate) withObject:nil afterDelay:0.0];
    }
}

-(void)_performUpdate {
    if(_needsUpdate) {
        _needsUpdate = false;
        [self performUpdate];
    }
}

-(void)performUpdate {
}

_needsUpdate 的双重检查有点多余,但很便宜。真正的偏执狂会将所有相关部分包装在@synchronized 中,但只有当 setNeedsUpdate 可以从主线程以外的线程调用时才真正需要这样做。如果您要这样做,您还需要更改 setNeedsUpdate 以在调用 performSelector 之前进入主线程。

据我了解,使用延迟值 0 调用 performSelector:withObject:afterDelay: 会导致在下一次通过事件循环时调用该方法。

如果您希望您的操作在下一次通过事件循环之前排队,那应该没问题。

如果您想合并多个不同的操作并且只需要一个 "do everything that accumulated since the last pass through the event loop" 调用,您可以在启动时在您的应用委托(或其他一些单个实例对象)中添加对 performSelector:withObject:afterDelay: 的单个调用,并在每次调用结束时再次调用您的方法。然后,您可以添加一个 NSMutableSet 待办事项,并在每次触发要合并的操作时向该集合添加一个条目。如果您创建了一个自定义操作对象并覆盖操作对象上的 isEqual(和散列)方法,您可以将其设置为在您的操作集中每种类型只会有一个操作对象。在通过事件循环的过程中多次添加相同的动作类型将添加一个且仅一个该类型的动作)。

您的方法可能如下所示:

- (void) doCoalescedActions;
{
  for (CustomActionObject *aCustomAction in setOfActions)
  {
    //Do whatever it takes to handle coalesced actions
  }
  [setOfActions removeAllObjects];
  [self performSelector: @selector(doCoalescedActions) 
    withObject: nil 
    afterDelay: 0];
}

如果没有你想做什么的具体细节,就很难详细了解如何做到这一点。

我已经使用自定义调度源实现了类似的功能。基本上,您使用 DISPATCH_SOURCE_TYPE_DATA_OR 设置调度源:

dispatch_source_t source = dispatch_source_create( DISPATCH_SOURCE_TYPE_DATA_OR, 0, 0, dispatch_get_main_queue() );
dispatch_source_set_event_handler( source, ^{
    // UI update logic goes here

});

dispatch_resume( source );

之后,每次要通知更新时间到了,调用:

dispatch_source_merge_data( __source, 1 );

事件处理程序块是不可重入的,因此在事件处理程序 运行 期间发生的更新将合并。

这是我在我的框架 Conche (https://github.com/djs-code/Conche) 中经常使用的模式。如果您正在寻找其他示例,请查看 CNCHStateMachine.m 和 CNCHObjectFeed.m。

NSNotificationQueue 正好有您要找的东西。请参阅 Coalescing Notifications

上的文档

这里是 UIViewController 中的一个简单示例:

- (void)dealloc
{
    [[NSNotificationCenter defaultCenter] removeObserver:self];
}

- (void)viewDidLoad
{
    [super viewDidLoad];

    [[NSNotificationCenter defaultCenter] addObserver:self
                                             selector:@selector(configureView:)
                                                 name:@"CoalescingNotificationName"
                                               object:self];

    [self setNeedsReload:@"viewDidLoad1"];
    [self setNeedsReload:@"viewDidLoad2"];
}

- (void)viewWillAppear:(BOOL)animated
{
    [super viewWillAppear:animated];
    [self setNeedsReload:@"viewWillAppear1"];
    [self setNeedsReload:@"viewWillAppear2"];
}

- (void)viewDidAppear:(BOOL)animated
{
    [super viewDidAppear:animated];
    [self setNeedsReload:@"viewDidAppear1"];
    [self setNeedsReload:@"viewDidAppear2"];
}

- (void)setNeedsReload:(NSString *)context
{
    NSNotification *notification = [NSNotification notificationWithName:@"CoalescingNotificationName"
                                                                 object:self
                                                               userInfo:@{@"context":context}];

    [[NSNotificationQueue defaultQueue] enqueueNotification:notification
                                               postingStyle:NSPostASAP
                                               coalesceMask:NSNotificationCoalescingOnName|NSNotificationCoalescingOnSender
                                                   forModes:nil];
}

- (void)configureView:(NSNotification *)notification
{
    NSString *text = [NSString stringWithFormat:@"configureView called: %@", notification.userInfo];
    NSLog(@"%@", text);
    self.detailDescriptionLabel.text = text;
}

您可以查看文档并使用 postingStyle 来获得您想要的行为。使用 NSPostASAP,在这个例子中,会给我们输出:

configureView called: {
    context = viewDidLoad1;
}
configureView called: {
    context = viewDidAppear1;
}

意味着对 setNeedsReload 的背靠背调用已合并。