Search code examples
iosuikitnsdatensrunloop

Timestamp / NSDate for current event start in UIKit


Question: How can I make sure that the code executed due to a runloop event (timer, user interaction, performSelector, etc) have the same concept of "now"?

Background: Say that event handler takes 100ms to execute, that means that [NSDate date] will return a slightly different "now" depending on when in the execution you make the call. If you are very unlucky with the timing you might even end up with different dates between the calls.

This creates problems for things that rely on the current time for doing various calculations since those calculations can differ during the execution.

Of course, for a specific event handler you could just store the date in the AppDelegate or similar or pass it on in each call starting from the entry point.

However, I want something safer and automatic. Ideally I want to know at what time the current run loop started processing the event. Something I can simply replace [NSDate date] with and always get the same result until the next event is fired.

I looked into the documentation of NSRunLoop without much luck. I also looked into CADisplayLink for potential workarounds. Neither provided a clear cut answer.

It feels like this should be a common thing to need, not something that needs "workarounds". My guess is that I am looking in the wrong places or using the wrong search terms.

Code Example:

UIView *_foo, _fie;
NSDate *_hideDate;

- (void)handleTimer
  {
    [self checkVisible:_foo];
    [self checkVisible:_fie];
  }

- (void)checkVisible:(UIView *)view
  {
    view.hidden = [_hideDate timeIntervalSinceNow] < 0];
  }

In this case we could end up with _fie being hidden when _foo is still visible since "now" has changed by a very small amount between calls.

This is a very simplified example in which a fix is trivial by simply calling [NSDate date] and sending that instance to all callers. It is the general case that I am interested in though where call chains might be very deep, cyclic, re-entrant, etc.


Solution

  • NSRunLoop is a wrapper for CFRunLoop. CFRunLoop has features that NSRunLoop doesn't expose, so sometimes you have to drop down to the CF level.

    One such feature is observers, which are callbacks you can register to be called when the run loop enters different phases. The phase you want in this case is an after-waiting observer, which is called after the run loop receives an event (from a source, or due to a timer firing, or due to a block being added to the main queue).

    Let's add a wakeDate property to NSRunLoop:

    // NSRunLoop+wakeDate.h
    
    #import <Foundation/Foundation.h>
    
    @interface NSRunLoop (wakeDate)
    
    @property (nonatomic, strong, readonly) NSDate *wakeDate;
    
    @end
    

    With this category, we can ask an NSRunLoop for its wakeDate property any time we want, for example like this:

    #import "AppDelegate.h"
    #import "NSRunLoop+wakeDate.h"
    
    @implementation AppDelegate
    
    - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
        NSTimer *timer = [NSTimer timerWithTimeInterval:0.5 repeats:YES block:^(NSTimer *timer){
            NSLog(@"timer: %.6f", NSRunLoop.currentRunLoop.wakeDate.timeIntervalSinceReferenceDate);
        }];
        [NSRunLoop.currentRunLoop addTimer:timer forMode:NSRunLoopCommonModes];
        return YES;
    }
    
    @end
    

    To implement this property, we'll create a WakeDateRecord class that we can attach to the run loop as an associated object:

    // NSRunLoop+wakeDate.m
    
    #import "NSRunLoop+wakeDate.h"
    #import <objc/runtime.h>
    
    @interface WakeDateRecord: NSObject
    @property (nonatomic, strong) NSDate *date;
    - (instancetype)initWithRunLoop:(NSRunLoop *)runLoop;
    @end
    
    static const void *wakeDateRecordKey = &wakeDateRecordKey;
    
    @implementation NSRunLoop (wakeDate)
    
    - (NSDate *)wakeDate {
        WakeDateRecord *record = objc_getAssociatedObject(self, wakeDateRecordKey);
        if (record == nil) {
            record = [[WakeDateRecord alloc] initWithRunLoop:self];
            objc_setAssociatedObject(self, wakeDateRecordKey, record, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
        }
        return record.date;
    }
    
    @end
    

    The run loop can run in different modes, and although there are a small number of common modes, new modes can in theory be created on the fly. If you want an observer to be called in a particular mode, you have to register it for that mode. So, to ensure that the reported date is always correct, we'll remember not just the date but also the mode in which we recorded the date:

    @implementation WakeDateRecord {
        NSRunLoop *_runLoop;
        NSRunLoopMode _dateMode;
        NSDate *_date;
        CFRunLoopObserverRef _observer;
    }
    

    To initialize, we just store the run loop and create the observer:

    - (instancetype)initWithRunLoop:(NSRunLoop *)runLoop {
        if (self = [super init]) {
            _runLoop = runLoop;
            _observer = CFRunLoopObserverCreateWithHandler(nil, kCFRunLoopEntry | kCFRunLoopAfterWaiting, true, -2000000, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
                [self setDate];
            });
        }
        return self;
    }
    

    When asked for the date, we first check whether the current mode is different from the date in which we recorded the mode. If so, then the date wasn't updated when the run loop awoke in the current mode. That means the observer wasn't registered for the current mode, so we should register it now and update the date now:

    - (NSDate *)date {
        NSRunLoopMode mode = _runLoop.currentMode;
        if (![_dateMode isEqualToString:mode]) {
            // My observer didn't run when the run loop awoke in this mode, so it must not be registered in this mode yet.
            NSLog(@"debug: WakeDateRecord registering in mode %@", mode);
            CFRunLoopAddObserver(_runLoop.getCFRunLoop, _observer, (__bridge CFRunLoopMode)mode);
            [self setDate];
        }
        return _date;
    }
    

    When we update the date, we also need to update the stored mode:

    - (void)setDate {
        _date = [NSDate date];
        _dateMode = _runLoop.currentMode;
    }
    
    @end
    

    An important warning about this solution: the observer fires once per pass through the run loop. The run loop can service multiple timers and multiple blocks added to the main queue during a single pass. All of the serviced timers or blocks will see the same wakeDate.