Search code examples
iosswiftbackground-fetchios-lifecycle

Black screen when opening app after background state


I'm tasked to debug why some users sometimes experience getting stuck at a black screen when opening the app. I'm new to this particular app so I don't know the entire flow, but I can tell that the app has background-capabilities. There are certain tasks running at night.

I'm struggling to understand the full lifecycle of an iOS app when it comes to background modes.

When an app is started in background from a terminated state, I assume that didFinishLaunchingWithOptions still will be called. I see we have a small check for this in the code, in which case it omits the entire UI-initialization:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    /*{ Initial setup }*/
    if UIApplication.shared.applicationState == .background {
        // App was launched due to Background Fetch event. No need for UI.
        return true
    }
    /*{ Start UI }*/
    return true
}

I suspect that this code is causing the app to present no UI whenever the app is manually opened when it is currently or recently run in background by the "system" (from an initially terminated state). Is this correct? Meaning that there are edge cases where one would open the app without "{ Start UI }" ever being called.

We also have applicationDidBecomeActive implemented, which I assume should be used to ensure that the UI is presented in this very case. However, there's just some reachability-stuff here now:

func applicationDidBecomeActive(_ application: UIApplication) {
    reachabilityManager?.startObserving()
}

Most online resources I've found doesn't specifically show how apps started in background act, such as this graph which always transitions to didBecomeActive in all cases.

So question 1; what is the best-practice way to evaluate state in didFinishLaunchingWithOptions? Is the current implementation with omitting UI in case of .background optimal? If so, should I perform a check to see whether a UI is running in didBecomeActive, and start the UI if it isn't?

Leading to question 2; If we are supposed to load UI in didBecomeActive, are we also supposed to unload or deallocate any active UI in didEnterBackground?

Bonus questions: Is it possible to actually reproduce this with debugger? Every time I start the app with debugger, it's obviously not in background. How can I debug a lifecycle from background to foreground?

Or might I be completely off target here, and there could be a different reason as to why there's no UI for some users sometimes?


Solution

  • In addition to the comments here a kind of answer:

    Obviously we cannot know whether it's the case, but I'd say you are on the right track. From your description I assume here's what happens:

    The scheduled task launches the app and no UI is build. iOS has not much to do, so it decides to let your app live in suspended mode instead of completely terminating it once the scheduled task is done. That's actually to be expected I'd say, as it would only terminate it if it's really starved for resources (I assume a suspended app takes next to nothing, memory is swapped to disk, no CPU cycles, so why completely terminate it?).

    Then, at a later time, the user taps the app and it gets put into the foreground. Since it was suspended and not terminated didFinishLaunchingWithOptions is not called again (as it was already launched way earlier) and "oh noes" no UI is built.


    So to concretely answer your Q1 (warning, opinionated!):

    Well, the best practice to check the state is obviously okay in your implementation (the if check)... but: Building UI based on the state is not a good practice. As a matter of fact, "initiating" the UI build-up from code yourself is a bad idea in my opinion, in general. There's a reason why neither Swift(UI) apps nor UIKit & Storyboard-based apps do that. I know some people favor "doing the UI in code", but I disagree with that. The people who manage to do that well typically do not mean that too literal and there's a whole bunch of people out there who misinterpret that and have lines and lines of shoveling views into each other as a result. That turns inefficient, so solutions like yours pop up with the intent "Well, in background tasks we have to be fast, so let's just dupe that out", whereas the correct take-away should be "maybe we're doing something wrong with that tedious manual UI build up"...

    So, how does that help you (as I guess you cannot completely redo all the UI stuff just to fix this bug)? Unfortunately, "it depends". The first thing I'd try is actually just omitting the state check for the UI, i.e. build it up regardless of whether the app is launched from background or not. Here's wishing that nothing in that process actually requires the app being visible on screen to successfully complete that. If that works, so what? The app then has a not-yet-shown UI while it executes the background task, but so what? It will be of use later, once the user gets it into the foreground anyway.

    If that does not work, then try to move the UI building into didBecomeActive along with some check to ensure it's not "rebuild" in the case the app already has executed that code. It might be worthwhile to openly question this entire UI approach in your team.

    About question 2:

    This is a hard and flaky thing I guess...

    As explained in the comments, the trick to debug this to prepare the debugger for an app start that is not initiated by Xcode, but the scheduled BG task. That means two things:

    1. You must somehow schedule a task to a more or less known point in the future. This most likely requires you to add some debug code (unless you want to stay up late for the nightly scheduled real tasks... only to miss it). Just schedule one from within didFinishLaunchingWithOptions to be run in a minute or so. Now, I know you don't get a guarantee that it's executed then, but I don't see another way for now. I haven't tried this myself and if it doesn't work, erm... you're probably out of luck (or have to wait for quite a while for a task to be actually called).
    2. Start the app so the task gets scheduled, then immediately terminate the app again before the targeted time passes.
    3. Change the Xcode scheme. "Run - Info - Launch" to "Wait for the executable to be launched". Press the run button. The debugger now waits for the app to be launched by some other means. Unless you tap the icon yourself, that will hopefully be the scheduled task.

    By the way, Apple has a specific how to to help you with debugging BG tasks, but that looks like it won't actually terminate your app in between scheduling it and launching the task handler (so didFinishLaunchingWithOptions wouldn't be called in the way you need). You may want to investigate this yourself, though (this answer is long enough as it is... sorry for that).


    Update as I am an idiot:

    While all the above may be useful, I kind of lost sight of what you actually want to test: Whether that UI setup based on the state is responsible for the problem. There's a way to confirm that by kind of "injecting" the state via launch parameters. You need to minimally change your didFinishLaunchingWithOptions implementation and mess around a bit with your app scheme, but it should do the trick:

    1. Give your app scheme a custom launch argument, e.g. "fakeBGState". To do so, edit the scheme, go to "Run - Arguments" and add that string under "Arguments Passed On Launch".
    2. In your didFinishLaunchingWithOptions, wrap your UI set up code into
        if ProcessInfo.processInfo.arguments.contains("fakeBGState") {
            // ...
        }
    

    This way, you can start the way the same way it would be started by the BG task depending on the launch argument. While that completely eliminates an actual start triggered from the OS for the BG task, it should still be enough to let you figure out that this particular start-up flow causes the black screen.

    Lastly, if your app also supports background fetches, you can use that to trigger an app start from the OS into background mode via Xcode. The approach is similar as above described in my answer to question 2, but you do not schedule any task. Instead, you just follow step 3 to have the debugger waiting and then, in Xcode, select "Simulate Background Fetch" from the Debug menu. Maybe that also helps you, good luck!