iosswiftsprite-kitgame-loopios-lifecycle

When does the SpriteKit game loop run for the first time?


I'm trying to understand when a SpriteKit scene's frame cycle runs within the main iOS run loop. Specifically, I am concerned about the AppDelegate's applicationDidBecomeActive(_:) method. I always thought that method was called after the app becomes active, but before your presented scene's frame cycle runs.

This is important to the project I am building because I use the applicationDidBecomeActive(_:) method to perform some time-sensitive tasks like examining a timestamp, setting flags, starting timers, etc. So I need to reliably anticipate when this method will be called during the frame cycle (let's just call it the "game loop").

I did some testing which suggests that the game loop runs at different times in relation to the applicationDidBecomeActive(_:) method, depending on which version of iOS the app is running on. This is a concern because it means I cannot rely on a single implementation of this method to perform the tasks I need at the correct time.

I want to know definitively when applicationDidBecomeActive(_:) is called in relation to the SpriteKit game loop. That seems like a fundamental thing that anyone writing a SpriteKit game needs to understand. And I'm shocked to see that it seems to vary depending on the OS version. It's possible that I made a mistake in my testing and assumptions. But I'll report what I found here and see if anyone else has noticed this, and if anyone can explain this odd behavior.

In my current project, I have been testing on my physical iPhone running iOS 12.4 and sometimes using the Simulator for an iPhone running iOS 13. By using print statements, I have observed that the AppDelegate's applicationDidBecomeActive(_:) method and the SKScene's update(_:) method are being called in a different order, depending on which version of iOS is used.

Note that my project uses UIViewController's viewDidLoad() method to present the scene. I tried using viewWillLayoutSubviews() instead, hoping that things might work more reliably that way. But that proved to be even less reliable, so I won't discuss that here.

Order of Method Calls (iOS 12.4):

didFinishLaunchingWithOptions
viewDidLoad
didMove
update
applicationDidBecomeActive
update
...

Order of Method Calls (iOS 13):

didFinishLaunchingWithOptions
viewDidLoad
didMove
?
applicationDidBecomeActive
update
...

You can see that both versions of the OS call AppDelegate's application(_:didFinishLaunchingWithOptions:) method first, then they load the view. In viewDidLoad(), I make my call to have the view present my SKScene. As expected, the scene's didMove(to:) method is called after the view presents the scene. But what happens next is the odd part.

In iOS 12.4, the scene's update(_:) method is called, which indicates that the scene performed a single run of its game loop. Then the AppDelegate calls its applicationDidBecomeActive(_:) method. Next, the update(_:) method runs again. Then update(_:) keeps being called over and over as the scene's game loop fires 60 times per second, as expected.

In iOS 13, the update(_:) method does not get called immediately after didMove(to:) is called. Instead, applicationDidBecomeActive(_:) is called right after didMove(to:). Only then does the update(_:) method run (and then continues running, as expected).

So basically, the issue here is that in iOS 12.4, the game loop appears to run once immediately after it is presented, before applicationDidBecomeActive(_:) is called. But in iOS 13 this does not happen.

It's a problem that the game loop in iOS 12.4 runs one extra time, before applicationDidBecomeActive(_:) is called. This makes the game's lifecycle inconsistent among different versions of the OS, and it means I will have to write different code to handle cases for different OS versions. Either that, or I must redesign the parts of the app that rely on applicationDidBecomeActive(_:) to find a more consistent way of handling them. It also makes me wonder if the extra run of the game loop is a bug in iOS 12.

I always assumed that the app's lifecycle was consistent between OS versions (at least regarding the order of method calls for AppDelegate and SKScene). But this discovery throws all of that into question. I have not yet tested with other versions of iOS, because even if this is the only discrepancy between all of the OS versions, it still means that your code must handle things differently depending on the OS version.

To add one more wrinkle to this analysis...

I also made a new SpriteKit template project and performed the same test. I found the same discrepancy, with one added peculiarity: in iOS 12.4, the update(_:) method is called twice immediately following didMove(to:), before applicationDidBecomeActive(_:) is called. In iOS 13, the behavior is the same as described above.

I'm not sure why update(_:) is firing twice rather than once as it does in my other project. That seems quite odd. But this test in a "clean" template project suggests that this is a real issue, rather than some error in my own code.

To reiterate my question...
I would like to know if anyone else has noticed this. Maybe I am mistaken in my conclusion. If this is a real issue, I wonder if there is any "fix" that can be done to make the game loop work in a consistent way for all OS versions. If not, can anyone suggest a good workaround so that your code in applicationDidBecomeActive(_:) consistently runs before the game loop first fires? I already have some ideas. But first, I want to confirm if this is an actual issue with iOS or just a mistake in my own code.


Solution

  • I used one of the code-level support incidents that I get as part of the Apple Developer Program to ask this question of one of their support engineers. What he told me was that there is no guarantee that update(_:) is always called after applicationDidBecomeActive(_:), so your app should not rely on such behavior. I had already implemented a workaround, and he told me I should either continue using that approach, or explore a different design that does not rely on the active state.

    My workaround is:

    This way, the rest of the code in my update(_:) method won't run unless applicationDidBecomeActive(_:) has already run. This ensures that the order of code execution for applicationDidBecomeActive(_:) and update(_:) will be consistent, no matter which iOS version the game is running on.

    I was surprised to hear that the order of these two method calls is not guaranteed by default, because in every situation I have encountered up until this point, it has behaved in a consistent way (unless I just did not notice the inconsistency). Admittedly, I've never seen any Apple documentation that states that it should behave consistently. But when you see something happen a certain way every time, you naturally come to expect that it always works that way and indeed is supposed to work that way. It's helpful to learn that in rare edge cases like this one, it may not work in the usual way.