In March, we released the Strava 4.0 Android and iPhone Apps, which featured a completely redesigned Activity Feed. In order to bring the new design from a concept to a functional product, both platform teams went through several iterations of implementation and performance tuning. This post highlights some of the techniques we used along the way, obstacles we faced and limitations we discovered.
One of the most striking differences in Strava 4.0 is the increase in maps used throughout the feed (prior to 4.0, we displayed thumbnail maps and only showed them in the “Me” Feed). While both iOS and Android platforms provide robust mapping APIs (complete with interactive view objects), we knew from prior experience that scrolling performance would be unacceptable if we simply added MKMapView (iOS) or MapView (Android) instances to Feed Entries. Those classes are designed to be high definition, interactive map views, rather than lightweight, static views that scroll nicely in a long list with many items. So, instead of using native maps we used map images from a remove provider and display them as bitmaps in the Feed Entries. The initial implementation was acceptable, but requesting larger map images in greater volume over a mobile network introduced a substantial amount of “placeholder” images while scrolling. In order to add interesting information to the Activity Feed Entries before their corresponding maps were loaded, we considered what additional data we could display without any additional loading. As it turns out, every Activity (Run, Ride, etc.) displayed in the Feed has a “summary polyline”, so as we prepare a Feed Entry for display we:
- Immediately render the Activity summary polyline on a background thread and display it over a simple background. The polyline view is generated using the Mercator Projection.
- Request the map image from a remote provider on a background thread and fade it in when it is ready.
With this implementation, a quick scroll through an Activity Feed will always show a summary polyline and give Activities “shape”, even if location images have not yet been retrieved.
iOS Performance Tuning
Refraining from prematurely optimizing, the iOS team first developed the core implementation of the new Feed before starting to look at what could be done to make it perform better. The new Feed cells are rather complex and so is the Feed.
However, one performance improvement that was agreed upon from the get-go with design was that the cells would not change height based on the text in them. While dynamically sizing cells based on the text content is not necessarily difficult to do on iOS, it can be rather costly in complicated cases. The main issue is that before the cells even get instantiated you have to compute how tall they will be, essentially performing the entire layout (and text rendering) twice. So, instead we have a few different types of cells which each have their own fixed size and the text resizes inside the boundaries of the cells with the gray header taking more or less space on top of the map.
With that in mind, we implemented our cells and then started analyzing the performance. On the most recent devices, everything was buttery smooth, but on older devices, not so much. Transparency is the main enemy of smooth scrolling in Table Views on iOS. The best tool to figure out transparency issues is in the Simulator’s Debug Menu:
With this option turned on the screenshot of the iPhone Feed above turns into this:
The red areas highlight which parts of the screen are being blended. The darker the red, the more blending. In order to minimize the blending we made sure that all the labels which could be opaque were opaque and had a proper background color. In addition to the background color, we decided, with the design team’s blessing, to take a more aggressive approach on older devices and slightly altered the appearance of the Feed, removing as much transparency as possible and removing all cross-fade animations which were happening when a map finished loading. And this is what the Feed looks like on iPhone 3Gs and iPhone 4:
As you can see on the right screenshot there is significantly less red, significantly less layer blending. These tweaks led to much improved scrolling performance on iPhone 3Gs and iPhone 4.
Android Performance Tuning
The Android implementation also began by first fleshing out the basic views for the various Feed Entry types with our existing set of utilities, deferring any performance optimizations until the functionality was complete. We then turned our focus to improving performance by evaluating our image loading library. With the new feed, a single Entry may have up to 3 associated images (profile photo, map background and Instagram thumbnail). In prior releases, the library we used for requesting and displaying remote images handled requests serially, but with the new Feed a noticeable backlog became evident when scrolling through multiple Feed Entries. To reduce this backlog, we had to find or build a new image loading library that would allow us to retrieve remote images in parallel, cancel pending load requests and manage image caching. After a bit of research and experimentation with several of the popular libraries available, we found Google’s Volley to be the fastest and most customizable tool for our needs. Requests are easy to create, cancel and most importantly, modify. We wrapped our Volley usage in a custom class that adds LRU memory caching, disk caching, custom callbacks, animations and device-specific parameter tuning. This allows us to limit the amount of resources used for loading images on older devices.
Once we had our new image loading library configured and working properly, we profiled the network traffic while scrolling through the feed and found that on high-resolution devices, the static map background images we requested (in png format) approached 300KB in size in urban areas. At that rate, scrolling through 10 new Feed Entries would consume 3MB of network data in map images alone. To minimize traffic, we changed the encoding quality of the images we requested to 70% jpg, which reduced the image footprint by roughly half with an imperceivable reduction in visible quality to the overall Feed Entry.
With things running relatively smoothly, we still noticed a seemingly random “stuttering” as we scrolled through the feed. A quick look at the GPU rendering profile (which can be enabled on an Android device under Settings->Developer Options->Profile GPU Rendering) confirmed that we were occasionally dropping frames as we scroll.
To diagnose this, we ran our app through the Android Monitor tools and found that Garbage Collection (GC) is the main offender. In order to scroll smoothly at 60 frames per second (fps), a single frame must take no more than 16 milliseconds (ms) to render. As seen by the log output, we were seeing GC occur frequently when scrolling the Feed and taking up to 115ms (that’s about 7 dropped frames).
In order to minimize our memory consumption, we made several adjustments:
Hunted down any old code that was excessive in its Object creation (which is good practice anyway)
Changed the Bitmap decoding configuration to use RGB_565, which consumes half the memory of the default ARGB_8888. Given our Feed images are not fullscreen and predominantly opaque, this change in decoding had a negligible impact on the user experience.
These changes helped, but after inspecting snapshots of heapspace allocation we discovered the main trigger for Garbage Collection in the new feed is the frequent allocation of memory for all the remote image Bitmaps. The greatest thing we can do to improve Feed scrolling performance is add a reusable Bitmap pool and decode new Bitmaps in the same memory space as stale ones. To date, we have not found a library that does this for us, so we plan on building this ourselves.
Overall, the new Feed gave us a great opportunity to focus on App performance and we continue to research and leverage new techniques with each release. Please leave any questions, feedback or recommendations in the comments section below.