location.hash or the
click() method on a dummy
<a> node (instead of
location.href or the
src attribute of iframes) to do fast synthetic navigations that trigger a
As previously mentioned, Quip's editor on iOS is implemented using a
UIWebView that wraps a
contentEditable area. The editor needs to communicate with the containing native layer for both big (document data in and out) and small (update toolbar state, accept or dismiss auto-corrections, etc.) things. While
UIWebView provides an officially sanctioned mechanism for getting data into it (
webView:shouldStartLoadWithRequest:navigationType: delegate method and then extract the data out of the request's URL².
The workaround did allow us to communicate back to the native Objective-C code, but it seemed to be higher latency than I would expect, especially on lower-end devices like the iPhone 4 (where it was several milliseconds). I decided to poke around and see what happened between the synthetic URL navigation happening and the delegate method being invoked. Getting a stack from the native side didn't prove helpful, since the delegate method was invoked via
NSInvocation with not much else on the stack beyond the event loop scaffolding. However, that did provide a hint that the delegate method was being invoked after some spins of the event loop, which perhaps explained the delays.
location.href property. By starting at the WebKit implementation of that setter, we end up in
DOMWindow::setLocation, which in turn uses
NavigationScheduler::scheduleLocationChange³. As the name “scheduler” suggests, this class requests navigations to happen sometime in the future. In the case of explicit location changes, a delay of 0 is used. However, 0 doesn't mean “immediately”: a timer is still installed, and WebKit waits for it to fire. That involves at least one spin of the event loop, which may be a few milliseconds on a low-end device.
NavigationScheduler. Some searching turned up the
HTMLAnchorElement::handleClick method, which invoked
FrameLoader::urlSelected directly (
FrameLoader being the main entrypoint into WebKit's URL loading). In turn, the anchor
click event (most easily done via the
click() method). Thus it seemed like an alternate approach would be to create a dummy link node, set its
href attribute to the synthetic URL, and simulate a click on it. More work than just setting the
location.href property, but perhaps it would be faster since it would avoid spinning the event loop.
Once I got that all hooked up, I could indeed see that everything was now running slightly faster, and synchronously too — here's a stack trace showing native-to-JS-to-native communication:
More recently, I took a more systematic approach in evaluating this and other communication mechanisms. I created a simple test bed and gathered timings (measured in milliseconds) from a few devices (all running iOS 7):
2.7 GHz Core i7
The mechanisms are as follows:
- location.href: Setting the
location.hrefproperty to a synthetic URL.
- location.hash: Setting the
location.hashproperty to a the data encoded as a fragment. The reason why it's faster than replacing the whole URL is because same-page navigations are executed immediately instead of being scheduled (thanks to Will Kiefer for telling me about this).
- <a> click: Simulating clicking on an anchor node that has the synthetic URL set as its
- frame.src: Setting the
srcproperty of a newly-created iframe. Based on examining the
chrome.jsfile inside the Chrome for iOS
.ipa, this is the approach that it uses to communicate: it creates an iframe with a
srcand appends it to the body (and immediately removes it). This approach does also trigger the navigation synchronously, but since it modifies the DOM the layout is invalidated, so repeated invocations end up being slower.
- XHR sync/async:
XMLHttpRequests that load a synthetic URL, either synchronously or asynchronously; on the native side, the load is intercepted via a
NSURLProtocolsubclass. This is the approach that Apache Cordova/PhoneGap prefers: it sends an
XMLHttpRequestthat is intercepted via
CDVURLProtocol.This also ends up being slower because the
NSURLProtocolmethods are invoked on a separate thread and it has to jump back to the main thread to invoke the endpoint methods.
NSHTTPCookieManagerCookiesChangedNotification. I'm not aware of anyone using this approach, but the idea came to me when I thought to look for other properties (besides the URL) that change in a web view which could be observed on the native side. Unfortunately the notification is triggered asynchronously, which explains why it's still not as fast as the simulated click.
My tests show the
location.hash and synthetic click approaches consistently beating
location.href (and all other mechanisms), and on low-end devices they're more than twice as fast. One might think that a few milliseconds would not matter, but when responding to user input and doing several such calls, the savings can add up⁴.
Quip has been using the synthetic click mechanism since before launch, and so far with no ill effects. There a few things to keep in mind though:
- Repeatedly setting
location.hrefin the same spin of the event loop results in only the final navigation happening. Since the synthetic clicks are processed immediately, they will all result in the delegate being invoked. This is generally desirable, but you may have to check for reentrancy since
- Changing the
hrefattribute of the anchor node would normally invalidate the layout of the page. However, you can avoid this by not adding the node to the document when creating it.
- Not having the anchor node in the document also avoids triggering any global
clickevent handlers that you have have registered.
I was very excited when iOS 7 shipped public access to the
UIWebView's. Thus it looks like we'll be stuck with hacks such as this one for another year.