Reducing WatchKit Traffic With View Models

13 min read

If you own an Apple Watch, you probably noticed something disappointing: 3rd party apps are SLOW.

If you've written a watch app, you'll know that a big part of the problem lies in the bandwidth limitations of phone to watch communication. Architecting a performant WatchKit extension can be difficult due to the write-only nature of WatchKit UI elements and the stringly-typed nature of controllers.

How can we decrease traffic sent from the phone to the watch while simultaneously reducing the complexity of your app's architecture? In this post I will describe how these can be achieved by using View Models.

Apple's Recommendations

Apple specifically recommends that you should:

  • Minimize traffic
  • Update only what has changed
  • Load content lazily

This can be difficult with the WatchKit SDK since UI elements exist on the watch but are controlled from the WatchKit extension. We can't query the state of the app running on the watch, forcing us to maintain UI state in our extension. If we don't do this we risk sending unneccesary updates to the UI on the watch.

View Models allow us to manage UI state without adding complexity to your WatchKit extension.

View Models

A View Model is an object for encapsulating the presentation logic of a model. In MVC we don’t want a view to contain this logic since it results in tight coupling to a specific model. Instead, the controller object is generally responsible for containing the presentation logic and uses it to configure a view. However, this just moves the coupling from the view to the controller, leaving you with a controller that is bloated and not reusable.

View Models sit in-between the view and the controller, allowing you to easily encapsulate the manipulation of a model to provide just what is needed to populate the view. Business logic for representing models can be removed from both the view and the controller, increasing their reusability and testability.

For example, here's a simple View Model for an Event object that exposes only what is needed to populate a row of this table:

struct EventViewModel {

    let firstTeamName : String
    let secondTeamName : String

    let firstTeamScore : String
    let secondTeamScore : String

    let firstTeamLogo : ImageViewModel?
    let secondTeamLogo : ImageViewModel?

    var description : String {
        return self.firstTeamName + " @ " + self.secondTeamName
    }

    init(model: Event?, imageSize: CGSize) {
        self.firstTeamName = model?.awayTeam?.name ?? "0"
        self.secondTeamName = model?.homeTeam?.name ?? "0"

        self.firstTeamScore  = model?.awayScore?.description ?? "0"
        self.secondTeamScore = model?.homeScore?.description ?? "0"

        self.firstTeamLogo = model?.awayTeam?.logo != nil ? ImageViewModel(url: model?.awayTeam?.logo.doubleResolutionURL, size: imageSize, imageQuality: 1) : nil
        self.secondTeamLogo = model?.homeTeam?.logo != nil ? ImageViewModel(url: model?.homeTeam?.logo.doubleResolutionURL, size: imageSize, imageQuality: 1) : nil
    }
}

Minimizing Traffic & Updating Only What Has Changed

WatchKit interface elements are write-only, meaning that they can’t be queried to decide whether or not to send an update. Using View Models adds the benefit of holding the current state of the view, allowing the extension to determine if an interface element’s value needs to be updated.

To easily add this ‘diffing’ functionality, we created a simple protocol that all WKInterface elements can implement in an extension (or category, in Objective-C).

protocol Updatable {
    typealias T
    func updateFrom(oldValue : T?, to newValue : T?)
}

The implementation of this on WKInterface label looks like this:

extension WKInterfaceLabel : Updatable {
    func updateFrom(oldValue : String?, to newValue : String?) {
        if newValue != oldValue {
            self.setText(newValue)
        }
    }
}

Now instead of calling myLabel.setText(“newValue”) we can call myLabel.updateFrom(“oldValue”, to: “newValue”) and setText will only be called if the arguments differ.

Since all of our images are loaded remotely and then sized to the dimensions required by the WKInterfaceImage, we have to compare two different properties of the image. We can do this while still conforming to the Updatable protocol by making a simple ImageViewModel:

struct ImageViewModel : Equatable {
    let url: NSURL?
    let size: CGSize
}

func == (lhs: ImageViewModel, rhs: ImageViewModel) -> Bool {
    return lhs.url == rhs.url && lhs.size == rhs.size
}

Now the extension on WKInterfaceImage looks like this:

extension WKInterfaceImage : Updatable {
    func updateFrom(oldValue : ImageViewModel?, to newValue : ImageViewModel?) {
        if newValue != nil && newValue != oldValue {
            self.setImage(URL: newValue!.url!, size: newValue!.size)
        }
    }
}

Here we call a function that we’ve written that asynchronously downloads an image, resizes it, caches it and sets it on the WKInterfaceImage, so by using the View Model we have potentially saved a lot of network and processing work.

Tables

Perhaps the most interesting application of View Models in WatchKit is for WKInterfaceTables. Whereas UITableViews have a datasource and delegate, WKInterfaceTables require the controller to maintain an internal mapping of data to both row controller type and row controller identifier, since row controllers are instantiated by a storyboard identifier and not by alloc’ing an instance of a class.

This mapping can result in lots of logic being stuck inside a big loop where each row controller is populated. Additionally, it makes it much easier to discard every row in the table and recreate it, which Apple strongly discourages. We can combine a View Model for the table with individual View Models for each row type to move the presentation logic and the updating logic out of the controller.

protocol TableViewModel {
    var rowTypes : [String] { get }
    func rowViewModelAtIndex(index: Int) -> Any?
    func table(table: WKInterfaceTable, updateFrom oldRowViewModel:Any?, to newRowViewModel:Any?, atIndex index: Int)
}

extension WKInterfaceTable : Updatable {
    func updateFrom(oldValue: TableViewModel?, to newValue: TableViewModel?) {
        if let oldTableModel = oldValue, newTableModel = newValue {
            // only update if necessary
            if oldTableModel.rowTypes.count == 0 {
                self.setRowTypes(newTableModel.rowTypes)
            }
            else if newTableModel.rowTypes != oldTableModel.rowTypes {
                // swap old row types for new row types
                for (index, newRowType) in enumerate(newTableModel.rowTypes) {
                    var swap = true
                    if index < oldTableModel.rowTypes.count {
                        let oldRowType = oldTableModel.rowTypes[index]
                        if oldRowType == newRowType {
                            swap = false
                        }
                        else {
                            self.removeRowsAtIndexes(NSIndexSet(index:index))
                        }
                    }
                    if swap {
                        self.insertRowsAtIndexes(NSIndexSet(index:index), withRowType: newRowType)
                    }
                }

                // remove extraneous rows
                let numRowsToRemove = oldTableModel.rowTypes.count - newTableModel.rowTypes.count
                if numRowsToRemove > 0 {
                    self.removeRowsAtIndexes(NSIndexSet(indexesInRange: NSMakeRange(newTableModel.rowTypes.count, numRowsToRemove)))
                }
            }

            // populate each row
            for i in 0..<self.numberOfRows {
                newTableModel.table(self, updateFrom: oldTableModel.rowViewModelAtIndex(i), to: newTableModel.rowViewModelAtIndex(i), atIndex: i)
            }
        }
        else if let newTableModel = newValue {
            self.setRowTypes(newTableModel.rowTypes)
            for i in 0..<self.numberOfRows {
                newTableModel.table(self, updateFrom: nil, to: newTableModel.rowViewModelAtIndex(i), atIndex: i)
            }
        }
    }
}

Now if we load a data set, do a network fetch and get the same data set, zero updates are sent to the watch. No wasted bandwidth or rendering time. If one label in one row changes, only that change is sent. If we insert a different row type in the middle of the table, only the affected rows are modified.

We swizzled the setters for WKInterfaceLabel and WKInterfaceTable to log their calls, demonstrating the improvements:

Initial loading:

  • 1 setRowTypes: call
  • 17 setText: calls

Reloading same data:

  • no calls

Inserting a new row type and deleting a row from the end:

  • 1 insertRowsAtIndexes:withRowType: call
  • 1 removeRowsAtIndexes: call
  • 3 setText: calls

Even more useful is that we can easily adhere to Apple’s recommendation:

“To optimize the launch time of your WatchKit app and make your app feel more responsive, load the content below the initially visible area of a controller after the controller has displayed to the user.”

If we do a network fetch for the first 3 rows of data, update the table, then do a subsequent fetch for 10 rows of data, we only have to call myTable.updateFrom(_, to:) and don’t have to worry about computing row offsets. Only the rows that have been added to the bottom of the table will be sent to the watch.

Code Simplification

Prior to using View Models, our table updating code looked something like this:

table.setRowTypes(DateRow, EventRow, EventRow)

func updateTableWithData(rowData:[NSObject]) {
    for i in 0..<rowData.count {
        let data = rowData[i]

        switch data {
            case is Event:
                let rowController = table.rowControllerAtIndex(i) as! EventController
                // set all the labels and images
                rowController.populate(data)
               break
            case is Date:
                let rowController = table.rowControllerAtIndex(i) as! DateController
                // set all the labels and images
                rowController.populate(data)
                break
            default:
                break
        }
    }
}

Now that we’ve moved all of our logic out of the interface controller, an update call to the table looks like this:

var previousTableViewModel : EventsTableViewModel?

func updateTableWithData(data:[NSObject]) {
    let newTableViewModel = EventsTableViewModel(data:data)
    self.table.updateFromOldValue(previousTableViewModel, toNewValue: newTableViewModel)
    previousTableViewModel = newTableViewModel
}

Testing

View Models also have the benefit of adding basic testing functionality to your WatchKit extension. Because we can’t query WatchKit interface elements, we have no way of knowing if they correctly represent the data we are interested in. Now if we assume that the content of the view is accurately represented by the View Model, it allows us to perform business logic tests on the View Model.

Profiling

While we can demonstrate that the number of setter calls has been reduced and our code has been simplified, we are unable to profile and measure the performance improvements. The Apple Watch is essentially a black box, with several confounding variables (latency, watch state) that can’t be eliminated when testing. However, our methods follow Apple’s recommendations so we can hope that by reducing traffic and updating only what has changed we improve the responsiveness of our WatchKit Extension.

Conclusion

By using View Models the developer can create wrappers around their model objects, simplifying their extension architecture while at the same time reducing the network traffic sent from the phone to the watch. Give this approach a try when you re-architect your WatchKit extension and let us know if you see any performance improvements!