UIKit 中当前事件开始的时间戳/NSDate

Timestamp / NSDate for current event start in UIKit

问题: 我如何确保由于 运行 循环事件(计时器、用户交互、performSelector 等)而执行的代码具有与 "now" 相同的概念?

背景: 假设事件处理程序需要 100 毫秒来执行,这意味着 [NSDate date] 将 return 略有不同 "now",具体取决于执行调用的时间。如果你对时间安排很不走运,你甚至可能会在两次通话之间得到不同的日期。

这会给依赖当前时间进行各种计算的事物带来问题,因为这些计算在执行过程中可能会有所不同。

当然,对于特定的事件处理程序,您可以只将日期存储在 AppDelegate 或类似的程序中,或者在从入口点开始的每次调用中传递它。

但是,我想要更安全和自动化的东西。理想情况下,我想知道当前 运行 循环在什么时间开始处理事件。我可以简单地将 [NSDate date] 替换为并且在下一个事件被触发之前总是得到相同的结果。

我查看了 NSRunLoop 的文档,但运气不佳。我还研究了 CADisplayLink 以寻找潜在的解决方法。两者都没有提供明确的答案。

感觉这应该是常人需要的东西,而不是需要"workarounds"的东西。我的猜测是我找错了地方或使用了错误的搜索词。

代码示例:

UIView *_foo, _fie;
NSDate *_hideDate;

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

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

在这种情况下,我们最终可能会在 _foo 仍然可见时隐藏 _fie,因为 "now" 在两次调用之间发生了非常小的变化。

这是一个非常简化的示例,其中只需调用 [NSDate date] 并将该实例发送给所有调用者即可轻松修复。这是我感兴趣的一般情况,尽管调用链可能非常深、循环、可重入等。

NSRunLoopCFRunLoop 的包装器。 CFRunLoop 具有 NSRunLoop 没有的功能,因此有时您必须降到 CF 级别。

其中一个功能是 observers,它们是您可以注册的回调,以便在 运行 循环进入不同阶段时调用。在这种情况下你想要的阶段是一个 after-waiting 观察者,它在 运行 循环接收到一个事件(来自一个源,或者由于定时器触发,或者由于一个块被添加到主队列)。

让我们在 NSRunLoop 中添加一个 wakeDate 属性:

// NSRunLoop+wakeDate.h

#import <Foundation/Foundation.h>

@interface NSRunLoop (wakeDate)

@property (nonatomic, strong, readonly) NSDate *wakeDate;

@end

有了这个类别,我们可以随时询问 NSRunLoopwakeDate 属性,例如:

#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

为了实现这个 属性,我们将创建一个 WakeDateRecord class,我们可以将其作为关联对象附加到 运行 循环:

// 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

运行 循环可以 运行 在不同的模式下,虽然有少量的常见模式,但理论上可以即时创建新模式。如果您希望在特定模式下调用观察者,则必须为该模式注册它。因此,为了确保报告的日期始终正确,我们不仅要记住日期,还要记住我们记录日期的方式:

@implementation WakeDateRecord {
    NSRunLoop *_runLoop;
    NSRunLoopMode _dateMode;
    NSDate *_date;
    CFRunLoopObserverRef _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;
}

当询问日期时,我们首先检查当前模式是否与我们记录模式的日期不同。如果是这样,那么当 运行 循环在当前模式下唤醒时,日期没有更新。这意味着观察者没有为当前模式注册,所以我们现在应该注册它并更新日期:

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

当我们更新日期的时候,我们也需要更新存储模式:

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

@end

有关此解决方案的重要警告:观察者每次通过 运行 循环都会触发一次。 运行 循环可以在单次传递期间为添加到主队列的多个计时器和多个块提供服务。所有服务的计时器或块将看到相同的 wakeDate.