跳转至

7.Lifecycle

Written by Scott Grosch

The lifecycle of a watchOS app is a bit more complex than that of iOS apps. There are five possible states that a watchOS app may find itself in:

  • Not running
  • Inactive
  • Active
  • Background
  • Suspended

Common State Transitions

The five possible states that a watchOS app finds itself in are represented by the grey boxes in the following image:

ForegroundNot runningInactiveBackgroundBActiveBackgroundSuspendedAC

As a developer, you’ll only interact with three of the states. The not running and suspendedstates are only active, pun intended :], when your app is not running.

Launching to the Active State

If the user has yet to run the app or the system purged the app from memory, the app begins in the not running state. Once the app launches, it follows path A and transitions to the inactive state.

While in the inactive state, the app still runs in the foreground but doesn’t receive any actions from the user. However, the app may still be executing code. Don’t let the name trick you into thinking the app can’t work while in this state.

Almost immediately, the app will follow path B and transition to the active state, which is the normal mode for apps running on the screen. When in the active state, the app can receive actions from the physical controls on the Apple Watch and user gestures.

When the user starts your app, and it’s not already running, watchOS will perform these actions:

  • Call applicationDidFinishLaunching() and set the scenePhase to .inactive.
  • Create the app’s initial scene and the root view.
  • Call applicationWillEnterForeground().
  • Call applicationDidBecomeActive() and set the scenePhase to .active.
  • The app will appear on the screen, and then watchOS will call the root view’s onAppear(perform:).

Note

As of watchOS 7 and later, the scenePhase environment variable is preferred over the WKExtensionDelegate methods when using SwiftUI.

Note

watchOS will not call any WKExtensionDelegate lifecycle methods other than applicationDidFinishLaunching() for events unrelated to the main interface. Things like complications and notifications will not trigger lifecycle events.

Transitioning to an Inactive State

As soon as the user lowers their arm, they’re no longer actively using the app. At that point, watchOS will change to the inactive state. As previously mentioned, the app is still running and executing your code. Having your app still running, even though it’s in an inactive state, is a point of confusion for many new watchOS developers.

The inactive state is a great time to reduce your app’s impact on the Apple Watch’s battery. You should pause any battery-intensive actions which don’t need to continue. For example, you may be able to disable any active animations that are currently running.

You might also consider whether there’s any active state you need to save. Does it make sense to save your Core Data stack? Should you write anything to UserDefaults?

While your immediate thought is likely yes, consider the usage model of your app. As soon as the user lifts their arm again, the app will become active. Therefore, making too many saves might have more of an impact on the battery’s life.

When your app transitions to the inactive state, watchOS will set scenePhase to .inactive and then call WKExtensionDelegate’s applicationWillResignActive().

Transitioning to the Background

Two minutes after transitioning to the inactive state, or when the user switches to another app, your app will transition to the background state. By following the lower part of path A, you can also launch the app directly to the background mode via the system. Background sessions and background tasks will both launch an app directly to the background state.

The OS gives background apps a small but non-deterministic amount of time before the app suspends.

If your app has transitioned to the background state by following path C, you’ll want to quickly perform whatever necessary actions to recreate the current app state.

You may use the SwiftUI scenePhase or WKExtensionDelegate’s applicationDidEnterBackground() to determine when your app has transitioned to the background.

When transitioning from the inactive state to the background state, watchOS will set scenePhase to .background and then call applicationDidEnterBackground(). Should it require more resources, watchOS will eventually suspend the app.

Returning to Clock

Before watchOS 7, you could ask for eight minutes before your app transitioned to the background. Your app can no longer make that change because developers kept forgetting to set it back to two minutes.

Now the user can control the timeout by going to SettingsGeneralReturn to Clock on the Apple Watch. There are three options that they can choose:

  1. Always.
  2. After 2 minutes.
  3. After 1 hour.

The default is two minutes. The user may also set the time at an individual app level. As part of your documentation, you may wish to tell the user how to change the setting to one hour for your app if the expectation is that they’ll frequently interact with your app outside of the two-minute timeout.

Additional Background Execution Time

If the amount of work you need to perform when transitioning to the background takes more time than watchOS provides your app, you need to rethink what you’re doing. For example, this is not the time to make a network call. If you’ve performed all the optimizations you can and still need a bit more processing time, you can call the performExpiringActivity(withReason:using:) method of the ProcessInfo class.

If called while your app is in the foreground, you’ll get 30 seconds. If called when in the background, you’ll receive ten seconds.

The system will asynchronously try to perform the block of code you provide to the usingparameter. It will then return a boolean value, letting you know whether or not the process is about to suspend.

If you receive a value of false, then you may perform your activities as quickly as possible. On the other hand, if you receive a true value, the system won’t provide you extra time and you need to stop immediately.

Note that just because the system lets you start your extra work, that doesn’t mean it will give you enough time to complete it. If your block of code is still running, and the OS needs to suspend your app, then your block of code will be called a second time with the trueparameter. Your code should be able to handle this cancellation request.

For example, you could check before each action whether watchOS has told you to stop your work. Assuming you had a boolean instance property cancel, you might do something like this:

processInfo.performExpiringActivity(
  withReason: "I'm really slow"
) { suspending in
  // 1
  guard !suspending else {
    cancel = true
    return
  }

  // 2
  guard !cancel else { return }
  try? managedObjectContext.save()

  // 3
  guard !cancel else { return }
  userDefaults.set(someData(), forKey: "criticalData")
}

In the preceding code, you:

  1. Immediately check if you’re allowed to run. If the system tells you to suspend, then you set your cancel property to true.
  2. Before trying to save your Core Data model, ensure that cancel is not set. Another thread might have called this same method with a request to suspend.
  3. Before saving to UserDefaults, check if the OS told you to stop.

It may look a bit odd to check for cancellation each time, but doing so ensures you honor the OS’s directions. In the given example, you simply stop when told. In a production app, you may need to do something else quickly to flag that you couldn’t complete the desired action.

Transitioning Back to the Active State

If the user interacts with the app while it’s in the background state, watchOS will transition it back to active via this process:

  • Restart the app in the .background state.
  • Call applicationWillEnterForeground().
  • Set the scenePhase to .active.
  • Call applicationDidBecomeActive().

If you’ve been paying attention, you’re likely confused by how the user could interact with the app while it’s in the background state. The answer, of course, is that they tap the complication that you provided!

Transitioning to the Suspended State

When your app finally transitions to the suspended state, all code execution stops. Your app is still in memory but is not processing events. The system will transition your app to the suspended state when your app is in the background and doesn’t have any pending tasks to complete.

Once your app has moved to a suspended state, it’s eligible for purging. If the OS needs more memory, it may, without notice, purge any apps that are in a suspended state from memory. Just because your app has moved to a suspended state doesn’t mean it will be purged, only that it’s eligible for purging.

The system will do its best not to purge the most recently executed app, any apps in the Dock and any apps that have a complication on the currently active watch face. If the system must purge one of the aforementioned apps, it’ll relaunch the app when memory becomes available.

Always On State

Until watchOS 6, the Apple Watch would appear to go to sleep when the user had not recently interacted with it. Always On state changed that so that the watch continued to display the time. However, watchOS would blur the currently running app and show the time over your app’s display.

Now, by default, your app’s user interface displays instead of the time. watchOS won’t blur it as long as it’s either the frontmost app or running a background session. When in the Always On state, the watch will dim, and the UI will update slower to preserve battery life.

If the user interacts with your app, the system will return to its active state. One noticeable advantage of Always On relates to dates and times. If your app displays a timer, an offset or a relative date, the UI will continue to update with the correct value.

If you wish to disable Always On for your app, simply set the WKSupportsAlwaysOnDisplaykey to false in the Watch App’s Info.plist.

Note

Users can disable Always On for your app, or the entire device, by going to SettingsDisplay & BrightnessAlways On.

State Change Sample

The sample materials for this chapter contain a Lifecycle project which you can run against a physical device to observe state changes. When you raise and lower your wrist, you’ll see the state changing between active and inactive. If you leave the app inactive for two minutes, you’ll notice it switching to background mode.

Extended Runtime Sessions

It’s possible to keep your app running, sometimes even while in the background, for four specific types of use cases.

Self Care

Apps focused on the user’s emotional well-being or health will run in the foreground, even when the watch screen isn’t on. watchOS will give your app a 10 minute session that will continue until the user switches to another app or you invalidate the session.

Mindfulness

Silent meditation has become a popular practice in recent years. Like self-care, mindfulness apps will stay in the foreground. Meditation is a time-consuming process, though, so watchOS will give your app a 1 hour session.

If you wish to play audio during the meditation session, you shouldn’t use an extended runtime session. Instead, enable background audio and use an AVAudioSession as that will keep your app alive. However, it’s probably not the best time to play your favorite thrash metal band! :]

Physical Therapy

Stretching, strengthening and range‑of‑motion exercises are perfect for a physical therapysession. Unlike the last two session types, physical therapy sessions run in the background. A background session will run until the time limit expires or the app invalidates the session, even if the user launches another app.

Physical therapy sessions can run for up to 1 hour. PTs are expensive, and Apple knows you can’t afford more than an hour-long session.

Smart Alarm

Smart alarms are a great option when you need to schedule a time to check the user’s heart rate and motion. You’ll get a 30 minute session for your watch to run in the background.

Unlike the other three session types, you must schedule smart alarms to start in the future. You need to start the session within the next 36 hours and schedule it while your app is in the WKApplicationState.active state. Your app will likely suspend or terminate, but the session will continue.

When it’s time to handle the session, watchOS will call your WKExtensionDelegate’s handle(_:).

Note

You must set the session’s delegate before the handler exits, or your session will terminate.

Once the session is running, you must trigger an alarm by calling the session’s notifyUser(hapticType:repeatHandler:). If you forget, watchOS will display a warning and offer to disable future sessions.

Brush Your Teeth

If you have children, you know what a chore it can be to get them to brush their teeth. Not only do you have to convince them to start brushing, but then they have to brush for the full two minutes recommended by most dentists. Seems like a great job for an Apple Watch app!

Open Toothbrush.xcodeproj from this chapter’s starter materials.

Assigning a Session Type

Brushing teeth falls into the self-care session type, but Xcode doesn’t know that unless you tell it. Following the steps in the image below, add a new capability to your Watch App target. First, select the Toothbrush app from the Project Navigator menu. Next, select the Toothbrush Watch App Target. Then, choose Signing & Capabilities from the main view and press the + Capability option. Finally, select Background Modes from the list of capabilities when prompted.

img

The Content Model

Create a new file named ContentModel. Add:

import SwiftUI

// 1
final class ContentModel: NSObject, ObservableObject {
  // 2
  @Published var roundsLeft = 0
  @Published var endOfRound: Date?
  @Published var endOfBrushing: Date?

  // 3
  private var timer: Timer!
  private var session: WKExtendedRuntimeSession!
}

In the preceding code:

  1. You need to conform to ObservableObject so the model can update the ContentView. You also need to subclass NSObject because it’s a requirement for conforming to WKExtendedRuntimeSessionDelegate, which you’ll add in just a bit.
  2. The first three properties are @Published so ContentView responds to updates. You’ll use them to track how long you still have to brush.
  3. Finally, you need a way to know when time is up, and control the session.

Once the user starts brushing their teeth, you’ll need to create the session and update the text displayed on the watch face’s button. Add this to ContentModel:

func startBrushing() {
  session = WKExtendedRuntimeSession()
  session.delegate = self
  session.start()
}

You know how picky Xcode is about protocol conformance. So, add the following code to the end of the file to resolve the error that assigning the delegate caused:

extension ContentModel: WKExtendedRuntimeSessionDelegate {
  // 1
  func extendedRuntimeSessionDidStart(
    _ extendedRuntimeSession: WKExtendedRuntimeSession
  ) {
  }

  // 2
  func extendedRuntimeSessionWillExpire(
    _ extendedRuntimeSession: WKExtendedRuntimeSession
  ) {
  }

  // 3
  func extendedRuntimeSession(
    _ extendedRuntimeSession: WKExtendedRuntimeSession,
    didInvalidateWith reason: WKExtendedRuntimeSessionInvalidationReason,
    error: Error?
  ) {
  }
}

Conforming to the protocol is pretty simple:

  1. The system calls extendedRuntimeSessionDidStart(_:) once the session starts running.
  2. watchOS will call extendedRuntimeSessionWillExpire(_:) just before forcibly expiring your session if your app is about to exceed the session’s time limit.
  3. Finally, watchOS calls extendedRuntimeSession(_:didInvalidateWith:error:) when the session completes for whatever reason.

Notice that the timer wasn’t initialized in startBrushing(). Until the session is running, you shouldn’t start counting down the time. Instead, you’ll want to perform startup actions in the delegate method.

Inside extendedRuntimeSessionDidStart(_:), add:

let secondsPerRound = 30.0
let now = Date.now

// 1
endOfRound = now.addingTimeInterval(secondsPerRound)
endOfBrushing = now.addingTimeInterval(secondsPerRound * 4)

roundsLeft = 4

// 2
let device = WKInterfaceDevice.current()
device.play(.start)

Startup code is mostly straightforward:

  1. This is one of the few instances where it’s valid to add several seconds instead of performing calendrical operations. You don’t care about the actual date or time: you just want a specific number of seconds.
  2. When the session starts, it’s nice to have the watch perform a quick vibration.

Also, note that both dates are based on the same now moment. While unlikely, it’s possible that the second of the two date assignments changes, resulting in incorrect calculations.

Now that you know how long each round of brushing will take, it’s time to set up a Timer. Add the code below to finish out the method:

// 1
timer = Timer(
  fire: endOfRound!,
  interval: secondsPerRound,
  repeats: true
) { _ in
  self.roundsLeft -= 1

  // 2
  guard self.roundsLeft == 0 else {
    self.endOfRound = Date.now.addingTimeInterval(secondsPerRound)
    device.play(.success)
    return
  }

  // 3
  device.play(.success)
  device.play(.success)
}

// 4
RunLoop.main.add(timer, forMode: .common)

Here’s what the code does:

  1. You generate a timer that starts at the end of the current round and repeats every secondsPerRound seconds.
  2. If there are still rounds of brushing left to perform, you update the time that the round ends so the view’s display updates. Having the watch vibrate lets the user know it’s time to switch to a new section of their mouth.
  3. If the final round is complete, you perform two vibrations to let your kid know they can finally stop their onerous chore.
  4. Finally, you schedule the timer into the main run loop.

Did you spot the issue with the preceding code? While you signaled the user via the vibrations that they finished brushing, you didn’t let watchOS know you were done. In 30 seconds, they’ll get tapped again.

When the last round is complete, you need to perform cleanup:

  • Disable the timer.
  • Stop the extended runtime session.
  • Update the UI.

The extendedRuntimeSession(_:didInvalidateWith:error:) seems like the obvious location for cleanup. Right before step three in the code above, cancel the session:

extendedRuntimeSession.invalidate()

When you invalidate the session, watchOS will call that delegate method. Add the following code to extendedRuntimeSession(_:didInvalidateWith:error:):

timer.invalidate()
timer = nil

endOfRound = nil
endOfBrushing = nil
roundsLeft = 0

Build your project to make sure you’ve entered everything properly so far. Now it’s time to handle the UI.

The Content View

Edit ContentView, and you’ll see that it’s already configured to print the ScenePhase for you during phase updates. The first task required is, of course, to use the model you just created. So, add the following line to the view:

@ObservedObject private var model = ContentModel()

Using an @ObservedObject, SwiftUI will update the body any time one of the published variables from your model updates. Next, replace the default Text("Hello, World!") in the body (don’t remove .onChange(of:)) with:

// 1
VStack {
  // 2
  Button {
    model.startBrushing()
  } label: {
    Text("Start brushing")
  }
  .disabled(model.roundsLeft != 0)
  .padding()

  // 3
  if let endOfBrushing = model.endOfBrushing,
     let endOfRound = model.endOfRound {
    Text("Rounds Left: \(model.roundsLeft - 1)")
    Text("Total time left: \(endOfBrushing, style: .timer)")
    Text("This round time left: \(endOfRound, style: .timer)")
  }
}

In the preceding code:

  1. Using a VStack lets you place multiple views in a vertical layout.
  2. When the user taps Start brushing, you’ll begin the process by calling into your model. Be sure to disable the button if they’re already brushing!
  3. If there are dates set in the model, that means a session is active, and you’ll therefore want to let the user know how much time is left. Take note of the style: .timer modifier in the string interpolation, which may be new syntax to you. Instead of displaying a date, SwiftUI will automatically update a countdown timer.

Build and run the app on a physical device. While you could run in the simulator, the scene phase will never switch to inactive or background if you do.

Once you tap the button, you’ll see the countdown begin:

img

Activate the console by pressing ShiftCommandC. Then clear out all unhelpful debugging information Xcode emits by default by pressing ControlK.

Lift and lower your arm a few times, and you’ll see messages in the console that the app has switched to the active or inactive states. However, the app never moves to the background, even though you’ll eventually pass the two-minute mark. The session has kept the app alive.

Ready, Set, Go

While functional, you can do better! Have you ever used the Workout app on your watch? When you start a workout, you get a few seconds to get ready before it begins. That seems useful for your app as well. The starter sample contains GetReadyView that simulates that display.

In ContentModel, add another property:

@Published var showGettingReady = false

By default, the getting ready timer shouldn’t display. You’ll also want to set it to false once brushing has started. So, add this as the first line of startBrushing():

showGettingReady = false

Then, back in ContentView, replace the call to model.startBrushing() with:

model.showGettingReady = true

To make something happen when you tell the timer to display, add an overlay modifier to the VStask immediately before the .onChange(of:) call:

// 1
.overlay(
  // 2
  VStack {
    if model.showGettingReady {
      // 3
      GetReadyView {
        model.startBrushing()
      }
      .frame(width: 125, height: 125)
      .padding()
    } else {
      // 4
      EmptyView()
    }
  }
)

Cool stuff happening:

  1. An overlay layers a secondary view in front of another view.
  2. By using a VStack, even though you’re only going to show a single view, you gain the ability to display a timer conditionally.
  3. If you asked to show it, then you provide the GetReadyView. The closure is called when the countdown is complete, at which point it’s time to start brushing.
  4. By using the EmptyView, you don’t display anything, but SwiftUI is still happy about the conditional check.

Build and rerun the app. This time, you’ll get a couple of seconds to get ready:

img

Key Points

  • An inactive phase on watchOS doesn’t mean the app isn’t running.
  • Prefer SwiftUI’s .scenePhase environment variable over extension delegate methods.
  • For specific types of apps, extended runtimes let your app keep running even when in the background.

Where to Go From Here?

Check out Apple’s documentation for WatchKit Life Cycles and Extended Runtime Sessions.