/* * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ #import "RCTTiming.h" #import #import #import #import #import #import #import #import "CoreModulesPlugins.h" static const NSTimeInterval kMinimumSleepInterval = 1; // These timing contants should be kept in sync with the ones in `JSTimers.js`. // The duration of a frame. This assumes that we want to run at 60 fps. static const NSTimeInterval kFrameDuration = 1.0 / 60.0; // The minimum time left in a frame to trigger the idle callback. static const NSTimeInterval kIdleCallbackFrameDeadline = 0.001; @interface _RCTTimer : NSObject @property (nonatomic, strong, readonly) NSDate *target; @property (nonatomic, assign, readonly) BOOL repeats; @property (nonatomic, copy, readonly) NSNumber *callbackID; @property (nonatomic, assign, readonly) NSTimeInterval interval; @end @implementation _RCTTimer - (instancetype)initWithCallbackID:(NSNumber *)callbackID interval:(NSTimeInterval)interval targetTime:(NSTimeInterval)targetTime repeats:(BOOL)repeats { if ((self = [super init])) { _interval = interval; _repeats = repeats; _callbackID = callbackID; _target = [NSDate dateWithTimeIntervalSinceNow:targetTime]; } return self; } /** * Returns `YES` if we should invoke the JS callback. */ - (BOOL)shouldFire:(NSDate *)now { if (_target && [_target timeIntervalSinceDate:now] <= 0) { return YES; } return NO; } - (void)reschedule { // The JS Timers will do fine grained calculating of expired timeouts. _target = [NSDate dateWithTimeIntervalSinceNow:_interval]; } @end @interface _RCTTimingProxy : NSObject @end // NSTimer retains its target, insert this class to break potential retain cycles @implementation _RCTTimingProxy { __weak id _target; } + (instancetype)proxyWithTarget:(id)target { _RCTTimingProxy *proxy = [self new]; if (proxy) { proxy->_target = target; } return proxy; } - (void)timerDidFire { [_target timerDidFire]; } @end @implementation RCTTiming { NSMutableDictionary *_timers; NSTimer *_sleepTimer; BOOL _sendIdleEvents; BOOL _inBackground; id _timingDelegate; } @synthesize bridge = _bridge; @synthesize paused = _paused; @synthesize pauseCallback = _pauseCallback; RCT_EXPORT_MODULE() - (instancetype)initWithDelegate:(id)delegate { if (self = [super init]) { [self setup]; _timingDelegate = delegate; } return self; } - (void)initialize { [self setup]; } - (void)setup { _paused = YES; _timers = [NSMutableDictionary new]; _inBackground = NO; RCTExecuteOnMainQueue(^{ if (!self->_inBackground && [RCTSharedApplication() applicationState] == UIApplicationStateBackground) { [self appDidMoveToBackground]; } }); for (NSString *name in @[ UIApplicationWillResignActiveNotification, UIApplicationDidEnterBackgroundNotification, UIApplicationWillTerminateNotification ]) { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(appDidMoveToBackground) name:name object:nil]; } for (NSString *name in @[ UIApplicationDidBecomeActiveNotification, UIApplicationWillEnterForegroundNotification ]) { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(appDidMoveToForeground) name:name object:nil]; } } - (void)dealloc { [_sleepTimer invalidate]; } - (dispatch_queue_t)methodQueue { return RCTJSThread; } - (void)invalidate { [self stopTimers]; _bridge = nil; _timingDelegate = nil; } - (void)appDidMoveToBackground { // Deactivate the CADisplayLink while in the background. [self stopTimers]; _inBackground = YES; // Issue one final timer callback, which will schedule a // background NSTimer, if needed. [self didUpdateFrame:nil]; } - (void)appDidMoveToForeground { _inBackground = NO; [self startTimers]; } - (void)stopTimers { if (_inBackground) { return; } if (!_paused) { _paused = YES; if (_pauseCallback) { _pauseCallback(); } } } - (void)startTimers { if ((!_bridge && !_timingDelegate) || _inBackground || ![self hasPendingTimers]) { return; } if (_paused) { _paused = NO; if (_pauseCallback) { _pauseCallback(); } } } - (BOOL)hasPendingTimers { @synchronized(_timers) { return _sendIdleEvents || _timers.count > 0; } } - (void)didUpdateFrame:(RCTFrameUpdate *)update { NSDate *nextScheduledTarget = [NSDate distantFuture]; NSMutableArray<_RCTTimer *> *timersToCall = [NSMutableArray new]; NSDate *now = [NSDate date]; // compare all the timers to the same base time @synchronized(_timers) { for (_RCTTimer *timer in _timers.allValues) { if ([timer shouldFire:now]) { [timersToCall addObject:timer]; } else { nextScheduledTarget = [nextScheduledTarget earlierDate:timer.target]; } } } // Call timers that need to be called if (timersToCall.count > 0) { NSArray *sortedTimers = [[timersToCall sortedArrayUsingComparator:^(_RCTTimer *a, _RCTTimer *b) { return [a.target compare:b.target]; }] valueForKey:@"callbackID"]; if (_bridge) { [_bridge enqueueJSCall:@"JSTimers" method:@"callTimers" args:@[ sortedTimers ] completion:NULL]; } else { [_timingDelegate callTimers:sortedTimers]; } } for (_RCTTimer *timer in timersToCall) { if (timer.repeats) { [timer reschedule]; nextScheduledTarget = [nextScheduledTarget earlierDate:timer.target]; } else { @synchronized(_timers) { [_timers removeObjectForKey:timer.callbackID]; } } } if (_sendIdleEvents) { NSTimeInterval currentTimestamp = [[NSDate date] timeIntervalSince1970]; NSTimeInterval frameElapsed = currentTimestamp - update.timestamp; if (kFrameDuration - frameElapsed >= kIdleCallbackFrameDeadline) { NSNumber *absoluteFrameStartMS = @((currentTimestamp - frameElapsed) * 1000); if (_bridge) { [_bridge enqueueJSCall:@"JSTimers" method:@"callIdleCallbacks" args:@[ absoluteFrameStartMS ] completion:NULL]; } else { [_timingDelegate callIdleCallbacks:absoluteFrameStartMS]; } } } // Switch to a paused state only if we didn't call any timer this frame, so if // in response to this timer another timer is scheduled, we don't pause and unpause // the displaylink frivolously. NSUInteger timerCount; @synchronized(_timers) { timerCount = _timers.count; } if (_inBackground) { if (timerCount) { [self scheduleSleepTimer:nextScheduledTarget]; } } else if (!_sendIdleEvents && timersToCall.count == 0) { // No need to call the pauseCallback as RCTDisplayLink will ask us about our paused // status immediately after completing this call if (timerCount == 0) { _paused = YES; } // If the next timer is more than 1 second out, pause and schedule an NSTimer; else if ([nextScheduledTarget timeIntervalSinceNow] > kMinimumSleepInterval) { [self scheduleSleepTimer:nextScheduledTarget]; _paused = YES; } } } - (void)scheduleSleepTimer:(NSDate *)sleepTarget { @synchronized(self) { if (!_sleepTimer || !_sleepTimer.valid) { _sleepTimer = [[NSTimer alloc] initWithFireDate:sleepTarget interval:0 target:[_RCTTimingProxy proxyWithTarget:self] selector:@selector(timerDidFire) userInfo:nil repeats:NO]; [[NSRunLoop currentRunLoop] addTimer:_sleepTimer forMode:NSDefaultRunLoopMode]; } else { _sleepTimer.fireDate = [_sleepTimer.fireDate earlierDate:sleepTarget]; } } } - (void)timerDidFire { _sleepTimer = nil; if (_paused) { [self startTimers]; // Immediately dispatch frame, so we don't have to wait on the displaylink. [self didUpdateFrame:nil]; } } /** * A method used for asynchronously creating a timer. If the timer has already expired, * (based on the provided jsSchedulingTime) then it will be immediately invoked. * * There's a small difference between the time when we call * setTimeout/setInterval/requestAnimation frame and the time it actually makes * it here. This is important and needs to be taken into account when * calculating the timer's target time. We calculate this by passing in * Date.now() from JS and then subtracting that from the current time here. */ RCT_EXPORT_METHOD(createTimer : (double)callbackID duration : (NSTimeInterval)jsDuration jsSchedulingTime : (double)jsSchedulingTime repeats : (BOOL)repeats) { NSNumber *callbackIdObjc = [NSNumber numberWithDouble:callbackID]; NSDate *schedulingTime = [RCTConvert NSDate:[NSNumber numberWithDouble:jsSchedulingTime]]; if (jsDuration == 0 && repeats == NO) { // For super fast, one-off timers, just enqueue them immediately rather than waiting a frame. if (_bridge) { [_bridge _immediatelyCallTimer:callbackIdObjc]; } else { [_timingDelegate immediatelyCallTimer:callbackIdObjc]; } return; } [self createTimerForNextFrame:callbackIdObjc duration:jsDuration jsSchedulingTime:schedulingTime repeats:repeats]; } /** * A method used for synchronously creating a timer. The timer will not be invoked until the * next frame, regardless of whether it has already expired (i.e. jsSchedulingTime is 0). */ - (void)createTimerForNextFrame:(nonnull NSNumber *)callbackID duration:(NSTimeInterval)jsDuration jsSchedulingTime:(NSDate *)jsSchedulingTime repeats:(BOOL)repeats { NSTimeInterval jsSchedulingOverhead = MAX(-jsSchedulingTime.timeIntervalSinceNow, 0); NSTimeInterval targetTime = jsDuration - jsSchedulingOverhead; if (jsDuration < 0.018) { // Make sure short intervals run each frame jsDuration = 0; } _RCTTimer *timer = [[_RCTTimer alloc] initWithCallbackID:callbackID interval:jsDuration targetTime:targetTime repeats:repeats]; @synchronized(_timers) { _timers[callbackID] = timer; } if (_inBackground) { [self scheduleSleepTimer:timer.target]; } else if (_paused) { if ([timer.target timeIntervalSinceNow] > kMinimumSleepInterval) { [self scheduleSleepTimer:timer.target]; } else { [self startTimers]; } } } RCT_EXPORT_METHOD(deleteTimer : (double)timerID) { @synchronized(_timers) { [_timers removeObjectForKey:[NSNumber numberWithDouble:timerID]]; } if (![self hasPendingTimers]) { [self stopTimers]; } } RCT_EXPORT_METHOD(setSendIdleEvents : (BOOL)sendIdleEvents) { _sendIdleEvents = sendIdleEvents; if (sendIdleEvents) { [self startTimers]; } else if (![self hasPendingTimers]) { [self stopTimers]; } } @end Class RCTTimingCls(void) { return RCTTiming.class; }