Using JavaScriptCore in a Production iOS App

This post is about why we chose to use JavaScriptCore and what we learned. The biggest challenges to using JavaScriptCore in a production app were performance optimization for older devices and getting the build process right. Luckily, these problems have simple solutions that just weren’t documented.

Why did we use JavaScriptCore?

An important part of our app was the search experience. Our goals included:

  • Fast updating of search results as the user types on iOS and Web
  • Offline-mode search for iOS

Those two goals tip the scales in favor of client-side search instead of server-side search. The following nice-to-haves also incluenced our decisions, though they are not strict requirements

  • A consistent experience across mobile and Web.
  • Rapid development (ideally by sharing code)

So we looked for a language that will run on both iOS and Web. There are at least two: JavaScript and C (with the help of Emscripten). We didn’t go Emscripten and C because Emscripten would make our JS bundle for Web too big, and C would increase both our dev time and segfault count.

On to what we learned from using JS modules in an existing iOS app!

JavaScriptCore Basics

JavaScriptCore lets you run JavaScript within an iOS app. I’ll provide sample code that shows how to get started, including how to set up logging (which is really cool).

1) Write a JavaScript file that imports our private npm modules that are shared between Web and iOS and exposes them as globals. Each of our modules exposes a single function, which keeps things simple.

"use strict"
const secretModule = require("@socialtables/secret-module")
// for older devices with Safari < 9.0
require("babel-polyfill")
global.getSearchResults = secretModule.getSearchResults
global.nameToColor = secretModule.nameToColor

2) Execute the JavaScript file in JSContexts and expose them as globals. A JSContext is roughly analogous to a Node process: it’s an isolated place to run JavaScript.

JSContext jsContext = [[JSContext alloc] init];
// load JS source file
NSString *filePath = [[NSBundle mainBundle] pathForResource:@"bundle" ofType:@"js"];
NSError *error = nil;
NSString *script = [[NSString alloc] initWithContentsOfFile:filePath
encoding:NSUTF8StringEncoding
error:&error];
if(error){
@throw error;
}
[jsContext evaluateScript:script];

3) Then call JavaScript functions from Objective-C:

JSValue *func = jsContext[funcName];
NSString *query = @"Hayber";
NSDictionary<NSString*, NSString*>* item = @{@"id": @33, @"name": @"Heiber"};
[func callWithArguments:@[query, item]];
NSMutableDictionary *result = [rawResult toDictionary];

As you can see, you don’t have to explicitly convert Objective-C/Swift types in order to use them as arguments to JavaScript functions. Converting from JSValues to Objective-C/Swift types is also simple, as there are methods onJSValue like toDictionary and toArray.

JavaScriptCore’s also lets you do HPFM stuff like convert instances of arbitrary Objective-C classes toJSValues by conforming to JSExport. We didn’t use that, since our models already had nice toJSONable andfromJSONable methods to convert objects to/from nested dictionaries and arrays.

JavaScriptCore Logging

By default, JavaScriptCore will swallow errors and console.logs, but this is easily fixed by passing C blocks into aJSContext.

The following code lets us see all JS errors and console.logs in the Xcode debug console, right along side our Objective-C logging. A huge help for troubleshooting!

// patch JS console to log to the Xcode console
jsContext[@"console"][@"log"] = ^(NSString *message){
NSLog(@"JS Console: %@", message);
};
// log exceptions to the Xcode console
jsContext.exceptionHandler = ^(JSContext *context, JSValue *exception) {
NSLog(@"JS Error: %@", exception);
};

Performance: Threads, Tight Loops, and Caching

Threads

JavaScriptCore can be an order of magnitude slower on older devices. While single-threading worked well when we tested with modern devices, we saw severe lag and occasional crashes on iPad 2.

We solved the performance problem by distributing work amongst several workers, each of which wraps a JSContextand a separate asynchronous dispatch queue. This enables us to, for example, split up a set of items into four subsets, send each of the subsets to a worker, then combine the results.

Tight Loops

Our user interface for search involves “type-to-filter.” When users type another letter before search results for the previous query are ready, this leaves threads tied up doing useless work. For example, if someone types “Berg” and then “er,” the user no longer cares about search results for “Berg,” since “Berger” is more important to getting accurate results. We needed “cancellability.”

We got cancellability by doing the following:

  • Call our JavaScriptCore functions in a tight loop
  • At the top of each loop, check a shouldStop flag
  • When shouldStop is true, return early, skipping any callbacks that would update the UI.

Caching JavaScript Objects

At one point, 20% of the iPad 2’s memory was eaten up by converting Objective-C objects to and from JSValues. That’s too much! We solved the problem by caching the mapping between Objective-C instances and JSValues. This requires care because each JSValue belongs to exactly one JSContext.

Building a JavaScript Bundle for iOS

At Social Tables, we rely on the good programming practice of splitting code into modules. Unfortunately, JavaScriptCore, like most JS environments, does not support modules outside the box. To get around this problem, we used Webpack to build a JS bundle for our app.

Rather than have two separate build processes (Xcode and Babel), we integrated Webpack’s build process into Xcode’s. Every time we build the app in Xcode, our build process uses Webpack to re-build the JS bundle. Integrating the Webpack build with Xcode’s has the following advantages:

  • We get error reporting and build stats in one place.
  • The app doesn’t build unless the JavaScript compiles.
  • We can easily bring on an iOS developer who doesn’t know about Webpack, npm, Node, and nvm!

To accomplish this, we added a bash script to our Xcode build phase:

# save working directory
WORKING_DIR=$(pwd)
# temporarily switch to the JavaScript dir so we can run `nvm` and `npm` stuff
cd $SRCROOT"/JavaScript"
# build the JavaScript bundle
npm run build
# if non-zero exit code, exit and warn the user
if [ $? -ne 0 ]; then
echo "failure in 'Run Script' phase - building JS bundle"
cd $WORKING_DIR
exit 1
fi
# go back to the working directory
cd $WORKING_DIR

The most difficult thing to get right is that the Xcode build should fail when the Webpack build fails. Otherwise, we could end up with bad or stale JavaScript in the app. Here’s how we did it:

  • In our npm script, we pass the bail flag to Webpack so it exits with a nonzero status if there’s a problem with the build: webpack -p — progress — bail
  • In our Bash script, we watch for exit code of the npm script and halt the build if necessary.

Summary

The techniques in this article helped us use JavaScriptCore to do performance-sensitive, non-trivial work in a production app. Hopefully they will be useful to you in reusing code across Web and iOS.

Questions and feedback welcome