Android performance bits and pieces, part III – sleight of hand
As part of the bigger redesign of the Play Store (cards, cards everywhere) we also created a new tab strip. What’s nice about this strip that it can fit multiple tab titles on larger screens, while also allowing swiping it independently of the view pager itself and selecting tabs all the way at the “other end”. That functionality, however, resulted in a very jarring sliding transition on the underlying content. It can’t really be conveyed with a sequence of screenshots, but if you have a pre-4.3.10 version on your device, go to the landing page of apps or movies, swipe the tab strip all the way to the end and tap on one of the titles. If you are on a fast connection (4G / WiFi), the data for that tab is loaded before view pager completes its sliding transition to select that tab, and the UI thread is swamped with too many pixel operations to be able to both slide the tab and fill its content. The fact that we’re also configuring the pager to load the content of one tab to the left and one tab to the right of the selected one is not helping.
The omni-present Adam Powell suggested waiting until the view pager sliding transition is complete and only then do the data load. That deceptively simple sentence has lead to a rather gnarly, but stable enough solution that has made its way into the latest release of the Play Store. Let’s look at those gnarly details, starting with what happens when you tap a tab title:
- Click listener registered on the tab text view calls ViewPager.setCurrentItem
- ViewPager calls instantiateItem of its adapter for the newly selected tab and two tabs on the sides.
- ViewPager starts the sliding animation and transitions from IDLE to SETTLING state notifying the registered OnPageChangeListener.onPageScrollStateChanged listener callback.
- ViewPager notifies the registered OnPageChangeListener.onPageSelected listener callback that a new page has been selected.
- ViewPager completes the sliding animation and transitions from SETTLING to IDLE state notifying the registered OnPageChangeListener.onPageScrollStateChanged listener callback.
What we want to do is to set some kind of an indication before step 2 above to defer data loading / display until step 5 has completed. The first gnarliness comes from the sequence itself, where tab selection event happens way after the adapter was requested to instantiate the newly selected tab. If you start data loading in instantiateItem and you’re on a sufficiently fast network (or already have cached data locally), you will end up starting to bind the data for the selected tab well before the sliding transition has completed. I personally would have preferred a slightly different sequence of events, but hey, I’m not going to complain. So…
Given that we “own” our custom tab strip implementation, we can fire a “pre-select” event before calling ViewPager.setCurrentItem. In the handler for that event we propagate the boolean bit to postpone data loading until after step 5 has been completed. In step 2 our adapter checks the value of that bit and does not initiate data loading. In step 5 we go over all deferred tabs and ask each one of them to start data loading.
We end up effectively postponing data load in favor of a much smoother UI response to the tab click. Yes, the data will arrive later than it used to. If you’re on a fast network, the UI will return to a usable state at roughly the same time, as the UI thread completes the sliding transition much faster. If you’re on a slow network, the delay in beginning the data load is not very significant (pager sliding transition completes quite quickly).
There’s a new point of failure here. What happens if we don’t exit that deferred mode? We’re relying on a very specific sequence of events that needs to happen in a very specific order. If, for any reason (not that I’m saying that Adam has bugs in his code, but just saying) we don’t get the SLIDING -> IDLE state transition, the newly selected tab will never have its data loaded. That’s not good. A rather gnarly and brittle (but apparently functioning) hack is to post a delayed Runnable on the UI thread in our pre-select callback handler. If 500ms pass since we’ve posted that Runnable and we didn’t have the IDLE -> SLIDING transition, we force-exit the deferred data load mode for all the tabs. Otherwise, if that transition does happen (step 3 in the sequence above) we cancel the delayed Runnable and let the sequence complete.
This change has been made rather late in the development cycle, and one of the reviewers’ suggestions was to postpone the data binding instead of data loading. The contention is over the UI thread – between tab sliding and tab data binding. Why postpone the data loading then? Start loading the data, and only postpone the binding of the loaded data. Without any guarantee [TM], this is what we’ve added in the development branch. The sequencing of the events is still the same, but instead of deferring the data load in instantiateItem we defer the data binding when we get the data back (from network or local cache). The UI thread is handling the sliding transition, while the background worker threads fetch and massage the data. As the data arrives, we look at the current state of the sequence. If the sequence is complete, we bind the data immediately. If the sequence is not complete, we enter the deferred data binding mode. As the sequence completes, we go over all tabs in that deferred mode and notify them that they can start binding the data.
Gnarly? Check.
Brittle? Check.
Could be better if I knew how to bribe Adam to change ViewPager without breaking a gazillion apps that rely on it? Check.
But hey. It seems to be working. And, nobody said that you will always write nicely looking code that removes jank.