This is the second article in a series of blog posts that we will write about LinkedIn's new iPad App. In the first post, we described how we build a snappy mobile experience using HTML5 local storage. In this article, I am going to talk about the challenges we faced building an infinitely swipe-able list.
What's a "Stream"?
When we started the iPad project, we thought through how we can create an engaging content consumption experience for our users. We decided to present the articles, network updates, and other similar content as a "stream": an infinite, swipe-able view. Here's the first page of my stream:
Problems with an infinite list on Mobile Devices
Mobile devices have less memory and CPU power compared to Desktop computers. If you render a very long list in the HTML, you run the risk of crashing the device. This makes it challenging to build large, interactive HTML5 apps for mobile devices. Native technologies provide UITableViewController to build long, infinite scrolling lists. UITableView contains reusable UITableViewCells which are optimized for memory, performance and responsiveness . For HTML5, we did not have any solution. So we set out to build one!
Technique #1: unloading images
UIWebView/Mobile Safari have strict limits for images. Our stream is full of big images, so we hit the limits very quickly. One option was to use the HTML5 Canvas element to draw images without running into memory issues. However, we found drawing very big images on canvas was slow, so we had to take another approach: whenever an image was swiped sufficiently off screen, we replaced the "src" attribute of the img tag with a very small image. This ensured that the memory used for rendering large images was freed up periodically. Also, we ensured that we did not introduce the empty image src attribute bug.
Technique #2: hiding pages
Unloading images did not reclaim enough memory to prevent crashes. So we started hiding individual pages of the stream by setting the CSS visibility property to hidden (denoted in Picture 2 by "Invisible" page). After this change, we not only saw more memory being reclaimed (resulting in app not crashing) but also faster render times because the browser did not have to paint the "invisible" pages on the UI.
Technique #3: removing pages
Introducing invisible pages covered 80% of our use cases. The app would still crash for the remaining 20%! We went a step further and started removing pages that were not required. As a side effect, if we removed a page to the left of the current visible page, the stream would shift left and the user would lose the scroll position. In order to maintain the scroll position, we had to replace the removed page (aka the DOM node), with an empty page of equal height and width (denoted by "Stub" in picture 2). If the user is viewing page 5, for example, we removed page 0 and replaced it with a stub.
Armed with these 3 techniques, our stream started looking something like the following images. As you can see in picture 1, if the user is currently viewing page3, one page before and after the current page is fully loaded. So if the user decided to swipe forward/backward, she can see the fully rendered page. We played around with loading the images and rendering the page while the user is trying to scroll. It worked perfectly on the iPad simulator, but on the actual device, you could see degradation in scrolling performance.
As seen in picture 2, when the user swipes to Page 5, Page 0 and Page 1 remove themselves, Page 2 becomes invisible and Page 3 unloads all of its images. At this point, the user can continue to swipe forward, and the stream pages will decide to unload images/hide/remove themselves based on how "far" they are from the visible page.
We had to use a variable sized "window" for the different versions of the iPad. For example, the iPad1 has the least memory, so we had to give it a very small window:
Technique #4: avoid scaling and box-shadow
As we worked, we ran across two more HTML/CSS optimizations that helped performance:
- Avoid client side scaling of images by specifying the width and height attributes in HTML img tags
- Avoid CSS box shadow: it's slow on webkit
Technique #5: minimizing DOM nodes
With the above optimizations, you'd expect the app to not crash ever, right? Wrong! During testing, the techniques above would let the app run longer, but after a while, it would still crash.
From our iPhone app experience, we knew that keeping DOM nodes to a minimum was the key to smooth scrolling and keeping memory in check. Keeping this in mind, we merged all the stub DOM nodes with one dummy node equal in size to all of them combined. The result: no matter how many pages we swiped to, the stream did not crash on any of the devices! The final mechanism is depicted in Picture 3:
A video of all the techniques in action
Here's a screencast of how the DOM behaves when the user is swiping forward in the Stream. On the left, we have the stream loaded in a Google Chrome window. On the right, we have the Google Chrome Web Inspector showing how we add/remove nodes and extend the dummy page's width to fill in for removed stream pages. Note how the DOM nodes remain at a constant number and how the first li in the ul (the "Dummy" node), grows in size (you may need to make the video full screen to see it well):
We did not get this right the first time around, so it's only fair to list some of the things we tried that didn't work. We started by using multiple UIWebViews, each one rendering one stream page and using UISwipeGestureRecognizer to swipe between them. We quickly realized that using multiple web views in one native app was a bad idea for memory and performance reasons.
We then tried an approach similar to the 3-DIV approach . It worked, but we were not satisfied with the swipe performance on the stream. Sometimes, if the user swiped while we were rendering one of the stream pages, the UIWebView, being single threaded, did not register the swipe.