When your product spans web, iOS, and Android, sharing code between apps not only saves a lot of engineering time, but also results in user benefits: shorter iteration cycles, cross-platform availability, and consistency. Code sharing is particularly useful when your code requires constant tweaking. These iteration cycles typically involve manually translating code from one language to another, and can prove to be difficult and time-consuming.
At Quizlet we build learning tools — students and learners can create a set of flashcards and study them using a variety of games and memorization aides. The core code that determines whether a user’s answer is correct contains small details for different languages and content types. This is a great example of an area of our product where sharing code is particularly useful; we are able to iterate on the grading algorithm in one place, then ship to all of our apps simultaneously.
Edit 12/16: Due to feedback from several readers wondering about Duktape, we looked into the library and updated this post with our findings. All tests were completely re-run to ensure consistency.
We found and evaluated four promising libraries:
- JS Evaluator for Android, based around the native Android WebView.
Rhino was also previously evaluated as a potential solution, but at the time we found it was not performant enough to be usable in the Android context.
We set up a simple test app that has a few modules. Each module lets the user select an engine to use for the test, and enter a number of iterations to be executed serially, allowing them to stress test each engine in various scenarios. The app tracks the cumulative execution time for each iteration, and displays it to the user once all iterations have completed.
The test modules are as follows:
- Initialization: Creates the JS context objects for each engine in a loop, and optionally shuts them down, if the library includes a mechanism for doing so.
- Loading: Marshals a 174 KB array of sample JSON data into the engine.
On top of the runtime considerations covered in the tests above, we also needed to consider the increase in APK size resulting from including each library in the app, and the reliability of each library when attempting to run shared code on various Android devices.
Code for the test app can be found on GitHub.
JS Evaluator for Android
Code execution was a bit slower than with AndroidJSCore and J2V8, clocking in around 20 ms per iteration in the looping test, and 103 ms per iteration in the grading test. A critical limitation of this library worth mentioning, is that every call to JsEvaluator.evaluate() spawns a separate JS context, meaning all code required for execution must be passed in every call. This is different from AndroidJSCore and J2V8, where objects and functions assigned in a JS context can be re-used in subsequent calls. Users requiring a large amount of JS in context may see decreased performance, as they need to concatenate all of the JS into a single string, then marshal it into the engine every time they want to execute the code.
Importing the library is just as easy as JS Evaluator — simply import Maven dependency via gradle. The first thing you will notice after importing the dependency, however, is that the APK size increase is massive. AndroidJSCore includes binaries for many CPU ABIs, specifically arm64-v8a, armeabi, armeabi-v7a, mips, mips64, x86, and x86_64. For our test app, AndroidJSCore added 40.4 MB to the APK without ProGuard.
Looking at performance, AndroidJSCore tested to perform better than JS Evaluator and Duktape, but slower than J2V8. It is worth noting that the performance of the library during stress tests was a bit unstable. During the initialization test, AndroidJSCore was quick to start up (around 9 ms per iteration), but after around 100 loops creating new JSContext objects, the library stopped processing further calls.
For most use cases, re-initializing the library in a loop is not a realistic scenario, so in the loading and looping tests, we only initialized JS context objects once, and re-used them for each iteration. The looping test proved most successful for AndroidJSCore, averaging 1.5 ms per iteration. The loading test failed sporadically, executing around 7 ms per iteration when successful. Similarly, the grading test averaged 5 ms per iteration, but tended to fail between 5 and 100 iterations.
Similar to AndroidJSCore, J2V8 includes pre-built binaries for the two most common CPU ABIs amongst Android devices, armeabi-v7a and x86. Because only two binaries are included for this library, the universal APK is only a 7.4 MB size increase. Using APK splits, this can be reduced to 3.5 MB for the armeabi-v7a APK, and 3.9 MB for the x86 APK.
J2V8 was very performant in our tests compared to the other libraries discussed. Despite a high initialization time of around 143 ms on our test device, the execution time was faster than both AndroidJSCore and JS Evaluator. The looping test averaged about 0.5 ms per iteration, loading averaged 2.9 ms per iteration, and grading averaged 1.9 ms per iteration. And unlike AndroidJSCore, we found the V8 engine to be very stable; executing 1000 iterations of any module in our test app completed without failure.
A noteworthy “feature” of J2V8 is its insistence on remaining single-threaded. V8 instances must be accessed on the same thread on which they were created, else the library itself will throw an error. This can prove problematic if you try to initialize the V8 engine at the start of your application using an arbitrary thread, and then use the V8 instance on a different thread. An example of working within this requirement can be found in the V8 implementations of each module in the test app.
Duktape provides native binaries for the same CPU ABIs as AndroidJSCore, and therefore should be runnable by all Android devices. And despite including the same number of binaries, this library is actually a lot smaller than both AndroidJSCore and J2V8, with the 1.1.0 version adding only 2.1 MB to our test APK. This size increase can be reduced to between 247 and 358 KB using APK splits — not a huge hit on app size.
The context object for Duktape initialized faster than that of any of the other libraries tested, around 3 ms per iteration. This fast initialization time can be beneficial, as it allows users to shut down instances of the context object, instead of re-using them between calls to the API, risking native memory leaks.
On the code execution side, however, Duktape ranked lower than AndroidJSCore and J2V8 in terms of marshaling and execution. Specifically, it clocked in at 58 ms/iteration in the loading test, 13 ms/iteration in the looping test, and 9 ms/iteration in the grading test. Interestingly, and possibly a point for further investigation: Duktape performed better in the grading test compared to the looping test, relative to the other libraries tested. From a reliability standpoint, Duktape held up as well as J2V8, executing thousands of iterations in each test module without issues.
We ran each of the tests in our test app on a Google Pixel XL running Android 7.1.1. Results are in the charts below, displayed in iterations/second (higher is better).
It’s worth restating that JS Evaluator and AndroidJSCore were relatively unreliable in testing. The numbers above are averages over as many iterations as each library could handle before throwing an error or crashing. For J2V8 and Duktape, each test was able to run for 1000 iterations or more, and did not exhibit errors.
Here’s a summary of our findings for each library tested:
Use in Production
Unit testing a native library also proved to be somewhat of an issue, as the binaries included with J2V8 were not loadable on our development machines (OSX) or CI (Linux). This was solved by copying the libj2v8_macosx_x86_64.dylib and libj2v8_linux_x86_64.so files from the respective versions of the J2V8 dependencies to our project’s JNI folder. After adding these files, unit tests ran on our local machines and CI as expected.