Dealing With WKInterfaceController Life Cycle Changes in WatchOS 1.0.1

In an effort to reduce loading times for 3rd party watch apps, for a page-based interface, Apple has changed the calling pattern for willActivate() and didDeactivate(). In essence, WatchOS will more aggressively call willActivate() on your WKInterfaceController even if it is not visible to cache the view so it can be displayed immediately when you swipe over to it.

While this is effective in reducing loading spinners that users will see, it can cause bugs based on some common assumptions that developers might make.

Let's first take a moment to understand deeply the change in the calling pattern.

In WatchOS 1.0, Apple would call willActivate() only just before the page is visible, and didActivate() only just as the page is losing visibility. This pattern could occur if the watch is going to sleep, or transitions between interface controllers.

Here's a trace of a page-based interface with 5 interface controllers.

WatchOS 1.0 Life Cycle

00:50:07.583 page0: init()
00:50:07.585 page0: awakeWithContext()
00:50:07.780 page1: init()
00:50:07.780 page1: awakeWithContext()
00:50:07.994 page2: init()
00:50:07.995 page2: awakeWithContext()
00:50:08.190 page3: init()
00:50:08.190 page3: awakeWithContext()
00:50:08.317 page4: init()
00:50:08.317 page4: awakeWithContext()
00:50:08.514 page0: willActivate()

Swipe to next page (page1)

00:50:16.527 page1: willActivate()
00:50:16.721 page0: didDeactivate()

Swipe to next page (page2)

00:50:22.713 page2: willActivate()
00:50:22.909 page1: didDeactivate()

Pretty straightforward. It will call init()awakeWithContext() on all pages, activate the first page (page0). On a swipe, it will first activate the next page, then deactivate the old page.

Let's look at a trace from WatchOS 1.0.1.

WatchOS 1.0.1 Life Cycle

23:55:18.750 page0: init()
23:55:18.753 page0: awakeWithContext()
23:55:18.990 page1: init()
23:55:18.991 page1: awakeWithContext()
23:55:19.289 page2: init()
23:55:19.290 page2: awakeWithContext()
23:55:19.748 page3: init()
23:55:19.749 page3: awakeWithContext()
23:55:19.764 page4: init()
23:55:19.765 page4: awakeWithContext()
23:55:19.768 page0: willActivate()
23:55:22.751 page1: willActivate()
23:55:23.291 page1: didDeactivate()

Swipe to next page (page1)

23:55:43.953 page1: willActivate()
23:55:44.040 page0: didDeactivate()
23:55:44.853 page2: willActivate()
23:55:45.633 page2: didDeactivate()

Swipe to next page (page2)

23:56:08.436 page2: willActivate()
23:56:08.552 page1: didDeactivate()
23:56:09.397 page3: willActivate()
23:56:10.115 page3: didDeactivate()

We can see that the init()awakeWithContext() call pattern is the same, but as soon as page0 is activated, it immediately activates, then deactivates page1.

Common Issues

While Apple's caching works well for populating the contents of the UI, but most issues revolve around doing any non-view configuration work in willActivate(). In particular, detecting visibility state change of the WKInterfaceController.

Here's a few scenarios that are more difficult in WatchOS 1.0.1:

Asynchronous updates kicked off by willActivate(). For example, willActivate() kicks off some network calls to populate data, and when it returns, didDeactivate() has already been called, so the updates will fail.

Knowing which interface controller is visible. For example, your MMWormHole is reporting a change on your iOS app, and you'd like to call a function on only the visible interface controller to refresh UI.

Workarounds/Solutions

Async Updates Solution: Track Visibility Within the WKInterfaceController

If we want to trigger event on first visibility, it is possible to track active state within the controller itself, and save any UI updates for when the controller is truly active.

class MyInterfaceController: WKInterfaceController {
    var isActive: Bool = false
    var pendingContext: AnyObject? = nil
    
    override func willActivate() {
        super.willActivate()

        isActive = true
        if let context: AnyObject = pendingContext {
            updateUI(context)
            pendingContext = nil
        } else {
            beginAsyncCallWithCallback(callback)
        }
    }
    
    override func didDeactivate() {
        super.didDeactivate()
        isActive = false
    }
    
    func callback(context: AnyObject) {
        if isActive {
            updateUI(context)
        } else {
            pendingContext = context
        }
    }
}

Visible Controller Solution 1: Assume that visible is always activated first.

In the calling patterns of 1.0 and 1.0.1, the visible controller always gets willActivate() called first. If we assume that this will always hold true, we can definitively know which controller is visible.

Each time willActivate() is called, track it in an activeControllers dictionary along with the timestamp. Remove it when didDeactivate() gets called. When we want to know which controller is the visible one, return the controller with the smallest timestamp.

class RefreshableInterfaceController: WKInterfaceController {
    override func willActivate() {
        super.willActivate()
        // Do NOT call ControllerTracker.sharedInstance.visibleController() here
        // The previous page will not have called didDeactivate yet. 
        // Make sure to delay that call.
        ControllerTracker.sharedInstance.addActiveController(self)
    }
    
    override func didDeactivate() {
        super.didDeactivate()

        ControllerTracker.sharedInstance.removeActiveController(self)
    }
    
    func refreshUI() {
        // Refresh UI
    }
}

class ControllerTracker: NSObject {
    var activeControllers: [RefreshableInterfaceController : CFAbsoluteTime] = [:]
  
    func addActiveController(controller: RefreshableInterfaceController) {
        activeControllers[controller] = CFAbsoluteTimeGetCurrent()
    }
    
    func removeActiveController(controller: RefreshableInterfaceController) {
        activeControllers.removeValueForKey(controller)
    }
    
    func visibleController() -> RefreshableInterfaceController? {
        var earliestController: RefreshableInterfaceController? = nil
        var earliestTime: CFAbsoluteTime? = nil
        for (controller, timestamp) in activeControllers {
            var foundBetter = false
            if let time = earliestTime {
                if time > timestamp {
                    foundBetter = true
                }
            } else {
                foundBetter = true
            }
            if foundBetter {
                earliestController = controller
                earliestTime = timestamp
            }
        }
        return earliestController
    }
    
    class var sharedInstance: ControllerTracker {
        struct Singleton {
            static let instance = ControllerTracker()
        }
        return Singleton.instance
    }
    
    func resetControllers() {
        activeControllers.removeAll(keepCapacity: false)
        WKInterfaceController.reloadRootControllersWithNames(initialControllers, contexts: contexts)
    }

    func refreshVisibleController() {
        visibleController()?.refreshUI()
    }
}

With this strategy, it's extremely important to clear the activeControllers before you call reloadRootControllersWithNames or you might orphan a controller that you need. See resetControllers() for example.

The flaw with this strategy is in the assumption, that Apple will always create the visible controller first before caching the next one. The API documentation makes no guarantees on call order, so this solution is definitely hacky. It's a "safe hack" though, since logically Apple would likely want to start loading the first visible controller before trying to cache others. There are lots of reasons, including threading races, as to why this assumption could be broken in the future.

Visible Controller Solution 2: Poll until only one controller active.

If we dislike the above hack, another option is to wait until our activeControllers dictionary resolves down to exactly one element. It works the same as the Solution 1 code snippet with just visibleController() and refreshVisibleController() changed.

class ControllerTracker: NSObject {
    func visibleController() -> RefreshableInterfaceController? {
        if activeControllers.count != 1 {
            return nil
        }
        return activeControllers.values[0]
    }
  
    func refreshVisibleController() {
        if let controller = visibleController() {
            controller.refreshUI()
        } else {
            NSTimer.scheduledTimerWithTimeInterval(0.1, target: self, selector: "refreshVisibleController", userInfo: nil, repeats: false)
        }
    }
}

The problem with this strategy is that polling causes additional lags and is generally messy control flow every time you want to take an action on the visible controller. The above solution also requires additional code to be production ready. This code will poll forever if activeControllers is empty. (e.g. the watch went to sleep)

Final Thoughts

There is currently no way to detect if the watch is running WatchOS 1.0 or 1.0.1, so the code needs to be written in such a way that it works on all OS versions. It appears that Apple's intention is that willActivate() and didDeactivate() are intended just for setting up and tearing down views and no additional side effects should be occurring in the watch app. This is great in theory, but unfortunately there are no convenient method to actually know the difference between interface controller setup and the interface controller actually appearing.

The flow is really nice in iOS: init ➡ viewDidLoad ➡ viewWillAppear ➡ viewDidAppear and viewWillDisappear ➡ viewDidDisappear ➡ viewDidUnload ➡ dealloc. On the watch, perhaps this is overkill, and I can see them wanting to reduce communication over Bluetooth by reducing the number of events going over the air.

However the current state of the WatchKit API leads developers to a lot of hacks and workarounds when writing non-trivial code. Even if the watch UI is simple and clean, the underlying logic is sometimes necessarily complex to bring data from other sources cached and ready to go just-in-time.

Let's hope that improvements come in future updates.