Skip to content

Instantly share code, notes, and snippets.

@mheiber
Last active January 31, 2024 17:42
Show Gist options
  • Star 24 You must be signed in to star a gist
  • Fork 4 You must be signed in to fork a gist
  • Save mheiber/9e35ddb29ee2a76bd44b3cc1193a9215 to your computer and use it in GitHub Desktop.
Save mheiber/9e35ddb29ee2a76bd44b3cc1193a9215 to your computer and use it in GitHub Desktop.
Using JavaScriptCore in a Production iOS app

OUTDATED

JavaScriptCore is a built-in iOS library that enables you to use JavaScript in apps alongside Objective-C and Swift. It lets developers read JavaScript from a string, execute it from Objective-C or Swift, and share data structures and functions across languages. We JavaScriptCore to share code between Web and iOS.

Sharing code helped us produce a high-quality, consistent experience across devices while iterating rapidly.

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?

A killer feature of one of our apps is search that is optimized for finding guests by name. 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
  1. 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];
  1. Then call JavaScript functions from Objective-C:
JSValue *func = jsContext[funcName];
NSString *query = @"Hayber";
NSDictionary<NSString*, NSString*>* guest = @{@"first_name": @"Max", @"last_name": @"Heiber"};
[func callWithArguments:@[query, guest]];
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 on JSValue like toDictionary and toArray.

JavaScriptCore's also lets you do HPFM stuff like convert instances of arbitrary Objective-C classes to JSValues by conforming to JSExport. We didn't use that, since our models already had nice toJSONable and fromJSONable 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 a JSContext.

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 JSContext and a separate asynchronous dispatch queue. This enables us to, for example, split up a set of guests 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. We ended up with types like NSMapTable<JSWorker*, NSMapTable<Guest*, CachedGuest*>*> * (!).

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.

@WhoJave
Copy link

WhoJave commented Jun 16, 2017

Hi,thx for your directions, but I come across some problems.

  1. when I run my shell script, the error shows : missing script : build
    error Darwin 16.5.0
    error argv "/usr/local/Cellar/node/7.7.1_1/bin/node" "/usr/local/bin/npm" "run" "build"
    error node v7.7.1
    error npm v4.1.2
    2.➜ HTAi git:(dev) ✗ webpack -p --progress --bail
    No configuration file found and no output filename configured via CLI option.
    A configuration file could be named 'webpack.config.js' in the current directory.
    Use --help to display the CLI options.

@here-nerd
Copy link

I am curious what target you set in your webpack-config. I want to run my javascript where it requires crypto.getRandomValues. I am not able to run my code in JavaScriptCore VM using a default webpack-config target (which is 'web'). JavaScriptCore doesn't seem to provide the crypto API implementation.
I did try target: 'node-webkit' but it complains about "Can't find variable: 'require'". Any hint? please

@mheiber
Copy link
Author

mheiber commented Apr 15, 2021

Sorry, this was a long time ago, I'm sure everything has changed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment