Max Ogden | Open Web programmer
Fast WebView Applications

Article version of a JSConf Argentina presentation given in May 2012

tl;dr

Embedded mobile WebViews have bad default behaviors that result in slow applications. To help fix the bad defaults I started some new libraries, ViewKit and masseuse.js, and found some helpful libraries written by others. Additionally, all of the specific things covered in this article have associated code examples available in https://github.com/maxogden/fast-webview-applications-presentation

Why not go "full native"?

Having been recently tasked with writing an iPhone application I found myself at a fork in the road. Going down the path of Objective-C meant diving head first into a painful world of static typing, subclassing, IDEs, closed source frameworks and cultish fanaticism. The other path, JavaScript and HTML5, is one I have been working with every day for years. For me the natural choice would be to work with the tools I knew best, but I also recognized that most developers who write iPhone applications choose Objective-C so there would be a lot (see: tons) of rough edges along the road less traveled.

Cordova

I ended up using Cordova (formerly PhoneGap). The name Cordova comes from the street in Vancouver, Canada that was home to the software developers who built the framework, but the project's goals actually remind me a lot of the Spanish city of Cordoba (Cordova is actually the English form of Cordoba according to Encyclopedia Britannica). Around 1000 years ago Spain was filled, like much of pre-enlightenment western Europe, with people who got all of their information from the church. Then Muslims from northern Africa started moving into southern Spain and spread their ideology: to seek knowledge through science. Cordoba quickly became the most incredible city in the world with paved streets, street lights (gas powered), public hospitals, universities, medical schools, restaurants and the world’s largest library.

The whole Objective-C vs JavaScript debate is a modern version of what happened in Spain back in the day. Then: The church delivered the facts and you weren't encouraged to modify them. Now: Apple delivers SDKs and developers aren't allowed to modify them. Then: Cordoba made giant leaps and bounds due to a culture of open science. Now: JavaScript has created of the largest open source projects and fastest growing package repositories.

Contemporary Muslims who bring up the memory of Cordoba typically do so either to emphasize the need for a new Islamic scientific and industrial renaissance, or to emphasize the need for a multi-cultural and tolerant society.

via

Cordova gives you a nice platform to build HTML5 iOS apps on top of. Essentially Cordova wraps a full screen WebView in a native app 'container' so you get nice things like the ability to call from JavaScript into Objective-C to do things like send and receive push notifications, take photos, integrate with SDKs (Twitter and Facebookfor example), etc. At the end of the day, though, most of your time will be spent working on the HTML, CSS and JS that runs in that WebView. The harsh reality is that where Apple ships both the platform and the UI framework together, Cordova is just the platform. You are expected to BYOUI.

Even though Cordova implements the same core JS API across many platforms many mobile developers will tell you a bigger issue is that WebKit is turning into the new Internet Explorer: huge fragmentation in the Android market and a we-do-whatever-we-want attitude at Apple means it's not realistic to expect the code that runs in a WebView on iOS5 to run on iOS4 or an Android browser. Because of these issues I decided to first target iOS5 and then port to other platforms later.

There are a few big frameworks for iOS like jQuery Mobile and Sencha Touchbut having built web apps with them in the past I always felt too constricted by their monolithic approach. Instead, I prefer a small modules loosely joined approach.

Touch Events

The first thing you'll want to do is speed up your clicks. By default in WebViews there is a ~300ms delay if you are responding to click events. This is for capturing certain gestures like double tap to zoom in. Since we are in a viewport controlled unzoomable application container we don't actually want this behavior but sadly there is no easy way to disable this and make clicks fast again. touch events, on the other hand, have no artificial delay. Google has offered up one solution to this problem but I find their approach requires tight coupling between events and DOM elements. Rather than having a 1:1 ratio between DOM elements and their associated event listeners I instead use a few global listeners that aren't tightly coupled to any specific DOM elements.

masseuse.js is a library I wrote that includes all of the touch related helpers (in a less contrived form than this post) that I developed while working on my application.

Strange default behavior.

The numbers shown are a rough calculation of the time between when the user's finger touches the screen to when the associated click event finishes firing.

Beyond the click delay there are also certain CSS properties enabled in WebViews that can create a confusing UX (as you can see in the above video). Here's a rundown (via the PhoneGap wiki):

* {
  /* prevent callout when holding tap on links (the native dialog that comes up) */
  -webkit-touch-callout: none; 

  /* prevent webkit from resizing text to fit */
  -webkit-text-size-adjust: none; 

  /* make transparent link selection, adjust last value opacity 0 to 1.0 */
  -webkit-tap-highlight-color: rgba(0,0,0,0);

  /* prevent copy paste, to allow, change 'none' to 'text' */
  -webkit-user-select: none; 
}

// turn off webkit checkbox style, works for other inputs too
input[type="checkbox"] { -webkit-appearance: none; }

// placeholder text opacity
input::-webkit-input-placeholder { opacity: .8; }

To fix slow touches you must turn off click events for each kind of element that needs to be fast:

$('a').live('click', function(e) {
  e.preventDefault()
  return false
})

To replace clicks you can use the tap abstraction from zepto.js to handle touch events:

$('a').live('tap', handleTaps)

Since we are disabling click we are also throwing away the native behavior that we get from click events (focus for inputs, href changes for anchor tags, checkbox checking, etc) so we will have to re-implement that behavior in javascript. To implement this for anchor tags in a very rudimentary way:

function handleTaps( event ) {
  window.location.href = $(event.currentTarget).attr('href')
}

Fast clicks and CSS disabling in action

Zepto

Zepto is a jQuery API compatible library designed specifically for WebKit so it is smaller and much more easy to read because it doesn't aim to support every weird browser under the sun. It also has some nice mobile specific modules like gesture and touch.

Templating and Routing

Client side templating is good for mobile apps, as you wouldn't want to fetch remote templates over a wireless connection.

There are a ton of templating and routing engines out there, so you can pick whichever one you like! I happen to use mustache.js and director.js because they are nice and simple. Here's a simple example:

// render a template and insert it into the dom
function render( template, target, data ) {
  target = $( target ).first()
  target.html( buildTemplate(template, data) )
}

// gets templates out of hidden <script> tags in the DOM:
function buildTemplate(template, data) {
  return $.mustache( $( "." + template + "Template" ).first().html(), data || {} )
}

var routes = {
  cats: function() {
    var cats = ['aristotle.png', 'bikecat.png', 'awesomecat.gif', 'walkingcat.png', 'seriouscat.png']
    render('gallery', '.content', {pictures: cats})
  },
  dogs: function() {
    var dogs = ['hellodog.png', 'shopdog.png', 'bearddog.png', 'coco.png']
    render('gallery', '.content', {pictures: dogs})
  }
}

// Router is provided by Director
Router({
  '/': {
    on: function() { window.location.href = "#/cats" }
  },
  '/:page': { 
    on: function(page) {
      routes[page]()
    }
  }
}).init('/')

The above code in action.

To get the fixed header and nice scroll behavior of the content div in the above: as of iOS5 you can use CSS to assign native scroll behavior to a DOM element:

.content { -webkit-overflow-scrolling: touch; }

To get this behavior on pre-iOS5 WebViews you have to use JS libraries like iScroll. I personally have found most of these libraries to be very clunky to work with.

Sprites and Retina Images

When testing on device I noticed significant latency when loading images from the filesystem. Given the following CSS, when you tap on an input it will focus before the image loads and appears, which makes the app feel slow.

input { background-image: url('images/text.png') }
input:focus { background-image: url('images/text-focus.png') }

Image load time in the simulator. On actual devices it is even more noticeable.

If you look closely at the icons in the input fields you'll see that they are grainy, as the simulator in that video is simulating a retina display. To support retina devices I would have to have a copy of each CSS rule inside of a media query:

@media only screen and (-webkit-min-device-pixel-ratio: 2) {
  input { background-image: url('images/text@2x.png') }
  input:focus { background-image: url('images/text-focus@2x.png') }
}

This isn't very maintainable as it would double the amount of CSS required to show images. Instead, I create sprites using the glue utility:

glue images/ . --simple --algorithm=vertical

This will give me a single sprite png as well as CSS rules based on the filenames of the original images from within the sprite, so now my CSS looks like this:

.sprite { 
  background-image: url(sprite.png);
  background-repeat:no-repeat;
}
.sprite.text { background-position:0px 0px; }
.sprite.text:focus { background-position:0px -40px; }

@media only screen and (-webkit-min-device-pixel-ratio: 2) {
  .sprite {
    background-image: url('sprite@2x.png');
    background-repeat:no-repeat;
    background-size: 40px 160px;
  }
}

Now I don't have to re-define each element with a sprite inside of the media query -- I only need to swap out all elements with a sprite class on them with the retina sprite. The small caveat here is that I have to add the sprite class to each element in the markup that uses a sprite for it's background image:

<input type="text" name="name" class="text sprite" value="" placeholder="Name">

With the retina sprite enabled. Again, on an actual device the improvement is much more noticeable.

There are lots of WebView quirks not related to performance that I will save for another blog post.