7

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.

Anthony Mattox
  • 7,048
  • 6
  • 43
  • 59
  • If you're interested in taking action exactly per run loop, I would think you'd want a [run loop observer](https://developer.apple.com/library/mac/documentation/Cocoa/Conceptual/Multithreading/RunLoopManagement/RunLoopManagement.html#//apple_ref/doc/uid/10000057i-CH16-SW22). For a quick example, see [Performing selector at beginning/end of run loop](http://stackoverflow.com/q/16789342). – jscs Jan 28 '15 at 22:57

4 Answers4

3

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.

Dan Stenmark
  • 591
  • 6
  • 11
3

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.

Joseph Lin
  • 3,324
  • 1
  • 29
  • 39
  • Cocoa is amazing and huge. I never would have looked there. I'm not crazy about the added syntax necessary for registering/unregistering notifications, or using `NSNotificationCenter` at all, but this is the first answer to actually interacts with the run loop in a meaningful way rather than just delaying messages and hoping for the best. – Anthony Mattox Oct 09 '15 at 13:31
  • Probably want to avoid `NSNotificationQueue`. https://www.mikeash.com/pyblog/friday-qa-2010-01-08-nsnotificationqueue.html#comment-a463a9228074bd16e6b8c95c376b7993. Basically events may not ever get posted, or posted in a timely fashion. A 2009 OS X release note continues, "Foundation and AppKit do not use NSNotificationQueue themselves, partly for these reasons." – Graham Perks Feb 21 '18 at 23:18
0

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.

David Berry
  • 40,941
  • 12
  • 84
  • 95
0

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.

Duncan C
  • 128,072
  • 22
  • 173
  • 272