Saturday, March 24, 2012

Working around Android Webkit

I use PhoneGap to output the Android app for EatDifferent, and that means that my app runs inside an embedded Android browser. As I've discovered and re-discover everytime I work on a new version of the app, I am not the biggest fan of the Android browser. And that's an understatement.

Sure, the Android browser is Webkit-based, so it technically supports modern HTML5 elements and CSS3 rules, but in practice, the browser can sometimes struggle with rendering the new shiny CSS stuff, especially when some user interaction causes it to repaint the DOM. It's not just that the browser slows down — it actually fails to re-paint DOM nodes (or as I like to describe it, it "white outs" those nodes). When the whited-out nodes are my navigation menu or form buttons, then my app is rendered basically un-usable. That's a shitty user experience, of course, so as the developer, I want to do whatever I can to make sure that a user doesn't have to experience that.

Unfortunately, these white-outs are difficult to debug. When I run into one, I try to replicate it a few times (so I know what user interactions caused it), and then I start stripping out CSS rules until I can't reliably replicate it anymore. Since the glitches only happen on the device themselves (and not in Chrome, where I usually test CSS changes), I have to re-deploy the app everytime I test a change. Needless to say, it's a slow process. The white-outs are also impossible to programmatically test for, as far as I know, so there's nothing I can add to my test suite to guarantee that changes in my code haven't brought any back.

So, yeah, they suck. But they suck less when you know what to look for and what to change, so here are some of the changes I made to out the white-outs.

But first... detecting Android

I use the same HTML, CSS, and JS codebase for both the Android and iOS versions of my app, and for most of the changes, I only wanted to make them for Android. To do that, I have a function that detects if we're on Android or if we're testing Android mode on the desktop. I can use the results of function in my initialization code to add a "android" class to the body tag, which I can reference in my CSS.

My Android detection function checks by looking at the user agent (which isn't as simple as just looking for "Android", thanks to HTC and Kindle) and as a backup, looking at the device information served by the PhoneGap API.

Note that my Android detection function only checks if we're on an Android operating system, not if we're specifically in the built-in Android WebKit browser. My app only needs to check if it's on an Android OS, since it's wrapped inside PhoneGap and not accessed from arbitrary mobile browsers. If you're writing a website accessible from a URL and want to employ these workarounds only on the built-in Android Webkit browser, then you need a check that looks for the Android OS and a non-Chrome Webkit browser. Thanks to Brendan Eich for pointing that out in the comments.

  function inUserAgent(str) {
    return (new RegExp(str)).test(navigator.userAgent.toLowerCase());
  }

  function isAndroid() {

    function isAndroidOS() {
      return inUserAgent('android') || inUserAgent('htc_') || inUserAgent('silk/');
    }

    function isAndroidPG() {
      return (window.device && window.device.platform && window.device.platform == 'Android' || false;
    }

    return isAndroidOS() || isAndroidPG();
  }

  if (isAndroid() || getUrlParam('os') == 'android') {
    $('body').addClass('android');
  }

The case of the shiny modals

I use the modals from Twitter Bootstrap for dialogs in the mobile app, and I noticed white-outs and other visual oddities would happen when a modal rendered. To workaround that, I overrode the modal CSS rules to reset various CSS3 properties to their defaults so that the browser processes them as never being set at all.

.android {
  .modal {
    @include box-shadow(none);
    @include background-clip(border-box);
    @include border-radius(0px);
    border: 1px solid black;
  }
}

After making that change, I decided to go ahead and strip down the Bootstrap buttons, nav bar, and tabs as well — not because I necessarily knew their CSS3 rules were causing issues, but because I'd rather have a useable app than a perfectly rounded, shadowed app. I later realized that the stripped down CSS conveniently matches the new Android design guidelines quite well, so my changes are actually a performance and usability gain. You can see all my Android CSS overrides in this gist, and see the visual effect of the overrides in the screenshot below.


The case of too many nodes

When my app loads the stream, it appends many DOM nodes for each of the updates, and those DOM nodes can include text, links, buttons, and images. Android was frequently whiting out while trying to render the stream, understandably. I made a few changes to improve the performance of the stream rendering:

  • Stripping the CSS3 rules from the buttons (as described above) plus a few other classes.
  • Implementing delayed image loading. I had already implemented that for the web version of the app, since it is pretty silly from a performance and bandwidth perspective to load images that your users may not scroll down to see, and I discussed that in detail in this post.
  • Pre-compiling my Handlebars templates. This is a change I actually made for the iPhone, after discovering super slow compile times on iOS 5, but it helps a bit on Android as well.

The case of the resizing textarea

My app includes a textarea for the user to enter notes which defaults to a few rows. Sometimes users get wordy though and type beyond the textarea, and I wanted the textarea to resize as they typed. I got this plugin working, but I kept seeing my app's header and footer white out when the textarea resized. I eventually figured out that Android didn't like repainting the header and footer because they were position:fixed (it didn't white out when I made them absolute), and couldn't figure out how to get Android to not white them out. So, I opted here to just make sure the code never resized the textarea while the user was typing by adding the resizeOnChange option, and setting that to true for Android. It's not ideal, but well, that's life.

A better future?

As recently announced, there's now a Chrome for Android which is significantly better than the built-in Webkit that ships with it, and I'm hopeful that Android apps will be able to use Chrome for their embedded WebView in the future (see my issue filed on it here). I look forward to making app design decisions based on making a better user experience, and not on preventing a horrible one. :)

2 comments:

Anonymous said...

Hi Pamela,

Alas that isAndroid test will be true for Opera (Mobile and Mini) and Mozilla Firefox, since it's just an "OS is Android" test. You'll have to look for "WebKit" too and rule out Chrome on ICS.

/be

christopher said...

Hi Pamela,

Insightful post. :)

You mentioned that you had to re-deploy the app to the physical device which is painful.

But there's now a solution! :) The just released testing tool From Adobe called Shadow.

With the beta version of Adobe Shadow, you tether as many devices as you have to a computer and then push out the web content you want to test to all of them. When you load up a website on that main computer, the same page is automatically loaded on every tethered device. As you edit the code and refresh the page, all the devices refresh together, instantly previewing your progress as you work. By viewing all the devices all at once, you can see how changes to fix something on one device impacts the others..

Cheers,

Christopher