radex.io

aboutarchiveetsybooksmastodontwitter

Lazy WatchKit tables

June 23, 2015
Hey! This is an old post. It reflects my views and the state of the world in 2015, but is likely to be outdated. You’ll find me on Twitter, building Nozbe, and making engineering blueprints.

In Practical and efficient WatchKit tables with view model diffing, I presented a way of making Apple Watch apps that simplifies application code and results in superior performance by moving UI updating logic to the framework layer and making it smart about pushing updates over the radio.

Another way you can reduce iPhone—Watch traffic and make your app faster is by loading tables lazily. Instead of displaying everything at once, you only load a portion of the data, and then add more rows as needed. This can make a huge difference.

However, even though it sounds simple on paper, lazy loading can be surprisingly hard to do by hand. It’s trivial when you have relatively static data (for example, the calendar rows in Things). But when you have a combination of cached data and network fetches, and you allow users to manipulate the table in some way, there’s a lot of bookkeeping requried to do it right.

Unless you use the view model diffing architecture. Then it becomes simple again.

Limit the load-time traffic

Step one: when loading the table for the first time, only load a single screenful of content.

extension ShoppingListApp {
    var rowLimit = 5

    func updateView() {
        let newRows = items.limit(rowLimit).map { ShoppingItemRowModel($0) }
        table.updateViewModels(from: displayedRows, to: newRows)
        displayedRows = newRows
    }
}

I hope you can appreciate how easy the view model architecture makes this. We just specify what we want to display (up to 5 rows) and the framework takes care of the rest.

This little helper on Array helps:

extension Array {
    func limit(n: Int) -> [Element] {
        precondition(n >= 0)
        if self.count <= n {
            return self
        } else {
            return Array(self[0 ..< n])
        }
    }
}

Display more on demand

Step two: when the user gets to the bottom of the table, load more content.

It would be best to do this automatically by detecting scroll position. Sadly, this isn’t possible, so we’ll have to do with the second best option: a “Load more” button. I know what you’re thinking — this is terrible UX — but trust me, having to wait for the whole table to load at once is much worse.

extension ShoppingListApp {
    @IBAction func loadMore() {
        rowLimit += 10
        updateView()
    }

    func updateView() {
        // ... update the table ...
        let moreToLoad = items.count > rowLimit
        loadMoreButton.updateHidden(!moreToLoad)
    }
}

Optimize the button

Bonus 1: Don’t waste bandwidth on the button

Have you noticed how I’ve sinned in the last section? When we update the view, I call setHidden directly on the button. Even when it doesn’t change, I still end up sending unnecessary updates.

Now, you might think this is insignificant — just a single boolean sent over radio. But it’s something. The great advantage of view model diffing is that you can just call updateView() any time there’s a possibility that application state changed. The framework should take care of the rest, and you shouldn’t have to worry about performance on caller site. So we don’t want to leak bandwidth needlessly.

Let’s introduce a simple class to encapsulate the button and keep track of its state:

class WKUpdatableButton {
    private(set) var button: WKInterfaceButton
    private(set) var hidden: Bool

    init(_ button: WKInterfaceButton, defaultHidden: Bool) {
        self.button = button
        self.hidden = defaultHidden
    }

    func updateHidden(hidden: Bool) {
        if hidden != self.hidden {
            button.setHidden(hidden)
            self.hidden = hidden
        }
    }
}

And then in our controller:

extension ShoppingListApp {
    @IBOutlet weak var _loadMoreButton: WKInterfaceButton!
    lazy var loadMoreButton: WKUpdatableButton =
        WKUpdatableButton(self._loadMoreButton, defaultHidden: false)
}

This is a different abstraction from what we did before with tables. If we did that here, we would have to keep track of the “hidden” flag in our controller, which is messy. (It worked better with tables, because the update(old:, new:) calls stack nicely from primitives, to rows, to the whole table.)

I like these little helper classes whenever I want to update non-table views. It only takes a few minutes to make them when needed, and they clean up my controller code without impacting performance.

Delayed loading on activation

Step three: when the initial data is displayed, load more.

It would be terribly inconvenient if we actually only loaded 5 rows and then had to tap a button to see more. We can (and should) load more than that, but we’ll delay it until the initial batch is loaded.

extension ShoppingListApp {
    var firstActivation = true

    override func willActivate() {
        if firstActivation {
            dispatch_async(dispatch_get_main_queue()) {
                self.loadMore()
            }
        }

        firstActivation = false
    }
}

This is a good compromise between optimizing load time and making the interface not suck. The rows below the fold aren’t accessible immediately, but we’ll see some UI a bit sooner, and then we’ll have about three screenfuls of content before we have to tap the button.

(The exact initial row limit and increments are up to you, this is just a rule of thumb.)

Optimize your inserts

Bonus 2: Consider what happens when you allow users to add or remove rows from the table.

In the Nozbe Watch app I’m working on, there’s an option to add a new task to your to-do list. Conceptually, it looks something like this:

@IBAction func add() {
    items.insert(newItem(), atIndex: 0)
    updateView()
}

Now consider what will happen when we do that in a lazily-loaded table. Let’s say there are 10 rows on screen, and more that can be loaded. If we insert a new item at the top, and then call updateView(), the last displayed row will necessarily have to disappear to fit within the limit.

This makes no sense, of course. We’re removing a perfectly fine table row, and when the user taps “Load more”, we’ll have to load it again.

Easy fix, though:

@IBAction func add() {
    items.insert(newItem(), atIndex: 0)
    rowLimit++
    updateView()
}

Just make space for the new row before updating the table, and that’s it.

Result

Let’s talk speed. In one extreme case (100 table rows), I’ve seen an improvement of a whopping 8 seconds in initial load time thanks to lazy loading (and, to lesser extent, view model diffing). If you have an Apple Watch app where it’s conceivable to have more than a dozen rows, you owe it to your users to load it lazily. The difference is that big.

What about the code? Admittedly, with lazy loading, the whole example takes a bit too much space to fit comfortably in a blog post. (You can view it on GitHub.) But when you look closely, there’s no complex logic there. A few extra properties and methods, for sure, but nothing particularly error-prone or difficult to reason about.

But… native apps?

I know, I know — you’re already excited about native Watch apps. I know that feeling. Why add lazy loading if it’s a performance trick that won’t matter in a few months?

But think about it: the performance win is significant and “few months” is a pretty long time to live with a frustrating app.

If you already use the view model diffing architecture, just take a look at my demo app and you can get this implemented in 15 minutes.

If you don’t, it might take you some more time, but it’s still worth it. I also encourage you to consider adopting view model diffing. Even though this scheme was conceived to improve performance of WatchKit 1 apps, I believe the architectural benefits are still well worth it on watchOS 2.

Just say yes

If you want to learn more, check out my GitHub repo with a demo app: DiffyTables.

If you have comments, complaints or ideas for improvement, please let me know on Twitter or make an issue on GitHub.

Published June 23, 2015. Last updated October 05, 2015. Send feedback.