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

放肆的年华 提交于 2020-01-01 05:16:08

问题


A number of Cocoa Touch classes leverage a design pattern of coalescing events. UIViews, for example, have a method setNeedsLayout which causes layoutSubviews to be called in the very near future. This is especially useful in situations where a number of properties influence the layout. In the setter for each property you can call [self setNeedsLayout] which will ensure the layout will be updated, but will prevent many (potentially expensive) updates to the layout if multiple properties are changed at once or even if a single property were modified multiple times within one iteration of the run loop. Other expensive operations like the setNeedsDisplay and drawRect: pair of methods follow the same pattern.

What's the best way to implement pattern like this? Specifically I'd like to tie a number of dependent properties to an expensive method that needs to be called once per iteration of the run loop if a property has changed.


Possible Solutions:

Using a CADisplayLink or NSTimer you could get something working like this, but both seem more involved than necessary and I'm not sure what the performance implications of adding this to lots of objects (especially timers) would be. After all, performance is the only reason to do something like this.

I've used something like this in some cases:

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

This works great in situations where a user input should only trigger some event when a continuous action, or things like that. It seems clunky when we want to ensure there is no delay in triggering the event, instead we just want to coalesce calls within the same run loop.


回答1:


NSNotificationQueue has just the thing you're looking for. See the documentation on Coalescing Notifications

Here a simple example in a 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;
}

You can checkout the docs and play with the postingStyle to get the behavior you desired. Using NSPostASAP, in this example, will give us output:

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

meaning that back-to-back calls to setNeedsReload have been coalesced.




回答2:


I've implemented something like this using custom dispatch sources. Basically, you setup a dispatch source using DISPATCH_SOURCE_TYPE_DATA_OR as such:

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 );

After that, every time you want to notify that it's time to update, you call:

dispatch_source_merge_data( __source, 1 );

The event handler block is non-reentrant, so updates that occur while the event handler is running will coalesce.

This is a pattern I use a fair bit in my framework, Conche (https://github.com/djs-code/Conche). If you're looking for other examples, poke around CNCHStateMachine.m and CNCHObjectFeed.m.




回答3:


This borders on "primarily opinion based", but I'll throw out my usual method of handling this:

Set a flag and then queue processing with performSelector.

In your @interface put:

@property (nonatomic, readonly) BOOL  needsUpdate;

And then in your @implementation put:

-(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 {
}

The double check of _needsUpdate is a little redundant, but cheap. The truly paranoid would wrap all the relevant pieces in @synchronized, but that's really only necessary if setNeedsUpdate can be invoked from threads other than the main thread. If you're going to do that you also need to make changes to setNeedsUpdate to get to the main thread before calling performSelector.




回答4:


It's my understanding that calling performSelector:withObject:afterDelay: using a delay value of 0 causes the method to be called on the next pass through the event loop.

If you want your actions to be queued up until the next pass through the event loop, that should work fine.

If you want to coalesce multiple different actions and only want one "do everything that accumulated since the last pass through the event loop" call, you could add single call to performSelector:withObject:afterDelay: in your app delegate (or some other single instance object) at launch, and invoke your method again at the end of each call. You could then add an NSMutableSet of things to do, and add an entry to the set each time you trigger an action that you want to coalesce. If you created a custom action object and overrode the isEqual (and hash) methods on your action object, you could set it up so there would only ever be a single action object of each type in your set of actions. Adding the same action type multiple times in a pass through the event loop would add one and only one action of that type).

Your method might look something like this:

- (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];
}

It's hard to get into details on how to do this without specific details of what you want to do.



来源:https://stackoverflow.com/questions/28153632/implement-a-debounced-coalesced-pattern-in-cocoa-touch-like-layoutsubviews

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!