Skip to content

Commit

Permalink
Fire timers in the background via NSTimer (#23674)
Browse files Browse the repository at this point in the history
Summary:
This PR is a follow-up to #21211 by request of hramos to incorporate some additional crash fixes / synchronization edge cases found in our production testing.  What follows is largely a copy of the original PR description from #21211 - see that PR for original discussion thread as well as context on why this replacement PR was needed.

This PR is a minimalistic change to RCTTiming that causes it to switch exclusively to NSTimer (i.e., the 'sleep timer') in order to continue triggering timers when the app has moved to the background.

Many people have expressed a desire for background timer support on iOS. (See #1282, #167, and #16493). In our app — a podcast/audio player — we use background timers to ensure that we never lose track of the user's playback position, should the app crash or be terminated by the OS.

The RCTTimer module uses a RN-managed CADisplayLink if the next requested timer is less than a second away; otherwise, it switches to an NSTimer (which is refers to as a 'sleep timer' in source). The RN-managed CADisplayLink is always disabled when the app goes to the background (and thus cannot be used); however, the NSTimer will still issue its callbacks in the background.

This PR adds a flag to track whether the app is in the background, and if so, all timers are routed through NSTimer until the app returns to the foreground. vishnevskiy at Discord opened a similar PR (#16493) that implements a drop-in for CADisplayLink which falls back to NSTimer, but I decided to incorporate the background-NSTimer logic directly into RCTTimer, since NSTimer is already in use.

It's worth noting that the background NSTimer may not fire as often as requested — it may give the appearance of lagging depending upon your app's priority in the background. For our audio app, NSTimer fires exactly on schedule if there's an open AVAudioSession and audio is playing; if audio is not playing, it fires about half as often as requested, which is still adequate for networking polling and other tasks.

It's worth noting that background timers only function as long as an app is actually running in the background. Apple offers a variety of Background Modes (which can be toggled in the Capabilities section of the target inspector in Xcode), and the app will need to be legitimately using one of these modes in order for this change to provide any value — otherwise it will be terminated within a couple of seconds of moving to the background.

The good thing about this change is that for apps that do perform essential computation in support of their Background Mode, they can now use `setTimeout` and `setInterval` without problem — whereas in the past, neither would ever trigger their callback until the app returns to the foreground.
Pull Request resolved: #23674

Differential Revision: D14621326

Pulled By: shergin

fbshipit-source-id: c76e060ad2c662c140d7d2f4fb5aaa7094032515
  • Loading branch information
cojo authored and facebook-github-bot committed Mar 26, 2019
1 parent 56679ed commit 97c414e
Showing 1 changed file with 71 additions and 25 deletions.
96 changes: 71 additions & 25 deletions React/Modules/RCTTiming.m
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ @implementation RCTTiming
NSMutableDictionary<NSNumber *, _RCTTimer *> *_timers;
NSTimer *_sleepTimer;
BOOL _sendIdleEvents;
BOOL _inBackground;
}

@synthesize bridge = _bridge;
Expand All @@ -110,20 +111,21 @@ - (void)setBridge:(RCTBridge *)bridge

_paused = YES;
_timers = [NSMutableDictionary new];
_inBackground = NO;

for (NSString *name in @[UIApplicationWillResignActiveNotification,
UIApplicationDidEnterBackgroundNotification,
UIApplicationWillTerminateNotification]) {
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(stopTimers)
selector:@selector(appDidMoveToBackground)
name:name
object:nil];
}

for (NSString *name in @[UIApplicationDidBecomeActiveNotification,
UIApplicationWillEnterForegroundNotification]) {
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(startTimers)
selector:@selector(appDidMoveToForeground)
name:name
object:nil];
}
Expand All @@ -148,8 +150,29 @@ - (void)invalidate
_bridge = 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) {
Expand All @@ -160,7 +183,7 @@ - (void)stopTimers

- (void)startTimers
{
if (!_bridge || ![self hasPendingTimers]) {
if (!_bridge || _inBackground || ![self hasPendingTimers]) {
return;
}

Expand All @@ -174,19 +197,23 @@ - (void)startTimers

- (BOOL)hasPendingTimers
{
return _sendIdleEvents || _timers.count > 0;
@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
for (_RCTTimer *timer in _timers.allValues) {
if ([timer shouldFire:now]) {
[timersToCall addObject:timer];
} else {
nextScheduledTarget = [nextScheduledTarget earlierDate:timer.target];
@synchronized (_timers) {
for (_RCTTimer *timer in _timers.allValues) {
if ([timer shouldFire:now]) {
[timersToCall addObject:timer];
} else {
nextScheduledTarget = [nextScheduledTarget earlierDate:timer.target];
}
}
}

Expand All @@ -206,7 +233,9 @@ - (void)didUpdateFrame:(RCTFrameUpdate *)update
[timer reschedule];
nextScheduledTarget = [nextScheduledTarget earlierDate:timer.target];
} else {
[_timers removeObjectForKey:timer.callbackID];
@synchronized (_timers) {
[_timers removeObjectForKey:timer.callbackID];
}
}
}

Expand All @@ -225,10 +254,18 @@ - (void)didUpdateFrame:(RCTFrameUpdate *)update
// 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.
if (!_sendIdleEvents && timersToCall.count == 0) {
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 (_timers.count == 0) {
if (timerCount == 0) {
_paused = YES;
}
// If the next timer is more than 1 second out, pause and schedule an NSTimer;
Expand All @@ -241,16 +278,18 @@ - (void)didUpdateFrame:(RCTFrameUpdate *)update

- (void)scheduleSleepTimer:(NSDate *)sleepTarget
{
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];
@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];
}
}
}

Expand Down Expand Up @@ -294,8 +333,13 @@ - (void)timerDidFire
interval:jsDuration
targetTime:targetTime
repeats:repeats];
_timers[callbackID] = timer;
if (_paused) {
@synchronized (_timers) {
_timers[callbackID] = timer;
}

if (_inBackground) {
[self scheduleSleepTimer:timer.target];
} else if (_paused) {
if ([timer.target timeIntervalSinceNow] > kMinimumSleepInterval) {
[self scheduleSleepTimer:timer.target];
} else {
Expand All @@ -306,7 +350,9 @@ - (void)timerDidFire

RCT_EXPORT_METHOD(deleteTimer:(nonnull NSNumber *)timerID)
{
[_timers removeObjectForKey:timerID];
@synchronized (_timers) {
[_timers removeObjectForKey:timerID];
}
if (![self hasPendingTimers]) {
[self stopTimers];
}
Expand Down

0 comments on commit 97c414e

Please sign in to comment.