WKWebView. It is a simple matter of registering a
WKScriptMessageHandler on the native side and then invoking it with
window.webkit.messageHandlers.<handler name>.postMessage on the JS side.
After a heads-up from Jordan that messaging latency with the new web view was not that great, I extended the test bed I had developed to measure
UIWebView communication mechanisms to also measure this setup. I got results that mirrored Jordan's experiences — on an iPad mini 2 (A7),
UIWebView's best officially supported communication mechanism (changing
location.hash¹) took 0.44ms, while the same round trip on a
WKWebView took 3.63ms. I was expecting somewhat higher latency, since there is a cross-process IPC involved now, but not this much higher.
Curious as to what was going on, I pointed a profiler at the test harness, and got the following results.
Running Time Symbol Name 4147.0ms 94.1% Main Thread 10 stack frames elided 2165.0ms 49.1% IPC::Connection::dispatchOneMessage() 2164.0ms 49.1% IPC::Connection::dispatchMessage(…) 2161.0ms 49.0% WebKit::WebProcessProxy::didReceiveMessage(…) 2160.0ms 49.0% IPC::MessageReceiverMap::dispatchMessage(…) 2137.0ms 48.4% void IPC::handleMessage<…> 2137.0ms 48.4% WebKit::WebUserContentControllerProxy::didPostMessage(…) 2132.0ms 48.3% ScriptMessageHandlerDelegate::didPostMessage(…) 1274.0ms 28.9% -[JSContext initWithVirtualMachine:] 769.0ms 17.4% -[JSContext init] 30.0ms 0.6% -[BenchmarkViewController userContentController:didReceiveScriptMessage:] 16.0ms 0.3% -[JSValue toObject]
It looks like nearly half the time is spent in
JSContext² initializers. As previously mentioned, the WKWebView API is open source, so it's actually possible to read the source and see what's going on. If we look at the
ScriptMessageHandlerDelegate::didPostMessage source we can see that indeed for every
postMessage() call on the JS side a new
JSContext is initialized to own the deserialized value.
Even when not doing any
postMessage calls on the JS side, and just measuring
JSContext initialization. This turned out to be because the completion handler also results in a
JSContext being created in order to own the deserialized value. Thankfully this only happens if a completion handler is specified and there is a result, in other cases the function exits early.
After thinking about this some more, it occurred to me that the old
location.hash mechanism could still work with a
WKWebView. By adding a
WKNavigationDelegate to the web view, it's possible to observe location changes on the native side. I implemented this approach and was pleasantly surprised to see that it took 0.95ms. This was almost 4x faster than the officially sanctioned mechanism (albeit still about twice as slow as the equivalent on a
UIWebView, but that is presumably explained by the IPC overhead).
I then wondered if using
location.hash to communicate in both directions (changing the location on the native side, listening for
hashchange events on the JS side) would be be even better (to bypass more of the JS execution machinery), but that approach ended up being slower (for both
WKWebView) since it involved more delegate invocations.
Putting all this together, here are the results (in milliseconds, averaged over 100 round trips) using these mechanisms on various devices (all running iOS 8.1) using my test bed.
|Method/Device||iPhone 5 (A6)||iPad mini 2 (A7)||iPhone 6 (A8)||Simulator (Core i7)|
I reported this performance problem to Apple last summer (while iOS 8 was still in beta), but I haven't seen activity in this area. Ideally there would be a way to skip over JS value deserialization altogether, for cases where the client doesn't actually benefit from the built-in parsing that is done. Specifically, Quip uses Protocol Buffer-encoded messages to have richer (and typed) data passed back and forth between the native and JS side, and so, as far as the JS runtime is concerned, they're all strings anyway.
Separately, it is a good question as to why I'm making such a big fuss over the overhead of one millisecond (or three) per call. Quip continues to be a “hybrid” app, with the editing experience implemented in a web view (a
UIWebView for now). With the launch of spreadsheets we now have even more native ↔ web communication, and in some cases we notify the web view of touch events continuously while
UIScrollViews are scrolled (for custom behavior³). When trying to hit 60 frames per second, spending 3 milliseconds of your 16 millisecond budget on pure overhead feels wasteful.
Update on 06/29/2015: Mark Lam recently fixed
ScriptMessageHandlerDelegate::didPostMessage to reuse
JSContext instances across calls, which fixes one of the two performance problems that this post covers. I've filed another bug to track the remaining issue with
Update on 08/12/2015: The other bug has been fixed too, and both fixes are live in iOS 9. I have also discovered additional alternative mechanisms that perform better. See my more recent post about the current state of things.