React Native

React Native JSI: Part 2 - Converting Native Modules to JSI Modules

Photo by Oskar Yildiz on Unsplash

If you don't know what JSI is or are confused about it, I recommend that you read part 1 of this series before continuing.

In my previous blog I explained in detail how you can write JSI Modules in React Native from scratch. I talked about the basics and then explained how to write functions in C++ which you can then call in React Native.

But we all know that most of the native modules in React Native are written in Java or Objective C. While some of them can be rewritten in C++, most of these native modules use platform specific APIs and SDKs and it's just not possible to write them in C++.

In this post, I will be discussing how we can convert these Native Modules to React Native JSI modules. I won't touch any of the basics in this post. I have already explained them in the previous part of this series. If you don't know what JSI is or are still confused about it, I recommend that you read it before continuing.

Communication between Javascript and Android/iOS environment with JSI
Communication between Javascript and Android/iOS environment with JSI - Made with Excalidraw

The above figure details how this communication works. There are no signs of a React Native Bridge. We are using C++ as a middleware for two way communication between platform specific code and Javascript Runtime.

  1. On iOS this process is very simple because C++ code can run directly alongside Objective C code. 
  2. On Android it is a bit complicated because communication between Android and C++ happens through JNI (Java Native Interface). But once you have the basic setup done, the rest is just wrapper code.

I will be reusing the react-native-simple-jsi example library to implement a basic communication layer between Javascript Runtime and, iOS and Android as detailed in the diagram above. I suggest you git clone the repo so you can follow along.

Android

Open the example/android folder in Android Studio. Once Gradle build is complete, navigate to react-native-simple-jsi -> java -> SimpleJsiModule.java.

SimpleJsiModule.java

Let's add a simple function that retrieves our Android device model:

public String getModel() {
  String manufacturer = Build.MANUFACTURER;
  String model = Build.MODEL;
  if (model.startsWith(manufacturer)) {
    return model;
  } else {
    return manufacturer + " " + model;
  }
}

Let's also add two functions to read and write data to Shared Prefrences on Android:

public void setItem(final String key, final String value) {
  SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this.getReactApplicationContext());
  SharedPreferences.Editor editor = preferences.edit();
  editor.putString(key, value);
  editor.apply();
}

public String getItem(final String key) {
  SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this.getReactApplicationContext());
  String value = preferences.getString(key, "");
  return value;
}

cpp-adapter.cpp

To call any of the above functions from C++ we need access to the Java environment and the current instance of the SimpleJsiModule class. 

A naive approach will be reusing the JNIEnv* we already have:

Java_com_reactnativesimplejsi_SimpleJsiModule_nativeInstall(JNIEnv *env, jobject thiz, jlong jsi)

However caching a JNIEnv* is not a good idea because you can't use the same JNIEnv* across multiple threads, and might not even be able to use it for multiple native calls on the same thread (see http://android-developers.blogspot.se/2011/11/jni-local-reference-changes-in-ics.html).

A better implementation would be to create a function that helps us retrieves JNIEnv* whenever and wherever we need it:

#include <jni.h>
#include <sys/types.h>
#include "example.h"
#include "pthread.h"

JavaVM *java_vm;
jclass java_class;
jobject java_object;

extern "C" JNIEXPORT void JNICALL
Java_com_reactnativesimplejsi_SimpleJsiModule_nativeInstall(JNIEnv *env,
                                                            jobject thiz,
                                                            jlong jsi) {

  auto runtime = reinterpret_cast<facebook::jsi::Runtime *>(jsi);

  if (runtime) {
    example::install(*runtime);
  }

  env->GetJavaVM(&java_vm);
  java_object = env->NewGlobalRef(thiz);
}

/**
* A simple callback function that allows us to detach current JNI Environment
* when the thread
* See https://stackoverflow.com/a/30026231 for detailed explanation
*/

void DeferThreadDetach(JNIEnv *env) {
  static pthread_key_t thread_key;

  // Set up a Thread Specific Data key, and a callback that
  // will be executed when a thread is destroyed.
  // This is only done once, across all threads, and the value
  // associated with the key for any given thread will initially
  // be NULL.
  static auto run_once = [] {
    const auto err = pthread_key_create(&thread_key, [](void *ts_env) {
      if (ts_env) {
        java_vm->DetachCurrentThread();
      }
    });
    if (err) {
      // Failed to create TSD key. Throw an exception if you want to.
    }
    return 0;
  }();

  // For the callback to actually be executed when a thread exits
  // we need to associate a non-NULL value with the key on that thread.
  // We can use the JNIEnv* as that value.
  const auto ts_env = pthread_getspecific(thread_key);
  if (!ts_env) {
    if (pthread_setspecific(thread_key, env)) {
      // Failed to set thread-specific value for key. Throw an exception if you
      // want to.
    }
  }
}

/**
* Get a JNIEnv* valid for this thread, regardless of whether
* we're on a native thread or a Java thread.
* If the calling thread is not currently attached to the JVM
* it will be attached, and then automatically detached when the
* thread is destroyed.
*
* See https://stackoverflow.com/a/30026231 for detailed explanation
*/
JNIEnv *GetJniEnv() {
  JNIEnv *env = nullptr;
  // We still call GetEnv first to detect if the thread already
  // is attached. This is done to avoid setting up a DetachCurrentThread
  // call on a Java thread.

  // g_vm is a global.
  auto get_env_result = java_vm->GetEnv((void **)&env, JNI_VERSION_1_6);
  if (get_env_result == JNI_EDETACHED) {
    if (java_vm->AttachCurrentThread(&env, NULL) == JNI_OK) {
      DeferThreadDetach(env);
    } else {
      // Failed to attach thread. Throw an exception if you want to.
    }
  } else if (get_env_result == JNI_EVERSION) {
    // Unsupported JNI version. Throw an exception if you want to.
  }
  return env;
}

Let's discuss what the above code actually does:

  1. At the top of the file, we are defining three global vars.
    1. java_vm A global reference of our Java Runtime in which our android app is running. We need this get access JNI environment
    2. java_class Java side class method (the native methods declared with "static"). jclass is a reference to the current class.
    3. java_object Java side instance method (the native methods declared without "static"). jobject is a reference to the current instance.
  2. In our nativeInstall method we are getting the current Java VM from JNI Environement and keeping its reference in java_vm. We are also storing current instance of SimpleJsiModule class in a GlobalRef.
  3. Finally we have GetJniEnv() method which we will be using to get the JNI environment whenever we need it. 

Since example::install includes functions that are platform agnostic, let's create another install function inside the cpp-adapter.cpp file which will install Android specific functions:

void install(facebook::jsi::Runtime &jsiRuntime) {

  auto getDeviceName = Function::createFromHostFunction(
      jsiRuntime, PropNameID::forAscii(jsiRuntime, "getDeviceName"), 0,
      [](Runtime &runtime, const Value &thisValue, const Value *arguments,
         size_t count) -> Value {

        JNIEnv *jniEnv = GetJniEnv();

        java_class = jniEnv->GetObjectClass(java_object);
        jmethodID getModel =
            jniEnv->GetMethodID(java_class, "getModel", "()Ljava/lang/String;");
        jobject result = jniEnv->CallObjectMethod(java_object, getModel);
        const char *str = jniEnv->GetStringUTFChars((jstring)result, NULL);

        return Value(runtime, String::createFromUtf8(runtime, str));

      });

  jsiRuntime.global().setProperty(jsiRuntime, "getDeviceName",
                                  move(getDeviceName));
}

We are installing the getDeviceName function which calls getModel function which we created in the beginning.

  1. GetJniEnv(); Gives us the current attached JNI environment with the Java VM
  2. GetObjectClass(java_object); Retrieve the Java Class which includes all the methods we need to call
  3. GetMethodID(java_class, "getModel", "()Ljava/lang/String;"); Get the method ID for the function we want to call in Java. In our case it is getModel. This third argument is automatically generated in Android Studio when you select the correct method in the second argument from intellisense.
  4. CallObjectMethod Call the method with the ID we got above.
  5. GetStringUTFChars The method returns a jobject from which we need to get UTF8 chars.
  6. Finally we return our value to Javascript.

App.js

Now all we have to do in Javascript is call global.getDeviceName and we get our value.

console.log(global.getDeviceName);

I ran this in the Android Emulator and got Google skd_gphone_x86 in 0.03ms.

But what about the two other methods (setItem and getItem) I added in the beginning? Let's add JSI bindings for them too:

auto setItem = Function::createFromHostFunction(
    jsiRuntime, PropNameID::forAscii(jsiRuntime, "setItem"), 2,
    [](Runtime &runtime, const Value &thisValue, const Value *arguments,
       size_t count) -> Value {

      string key = arguments[0].getString(runtime).utf8(runtime);
      string value = arguments[1].getString(runtime).utf8(runtime);

      JNIEnv *jniEnv = GetJniEnv();

      java_class = jniEnv->GetObjectClass(java_object);

      jmethodID set = jniEnv->GetMethodID(
          java_class, "setItem", "(Ljava/lang/String;Ljava/lang/String;)V");

      jstring jstr1 = string2jstring(jniEnv, key);
      jstring jstr2 = string2jstring(jniEnv, value);
      jvalue params[2];
      params[0].l = jstr1;
      params[1].l = jstr2;

      jniEnv->CallVoidMethodA(java_object, set, params);

      return Value(true);

    });

jsiRuntime.global().setProperty(jsiRuntime, "setItem", move(setItem));

The setItem function has 2 arguments, key and value respectively. 

  1. paramsCount is set to 2
  2. GetMethodID(java_class, "setItem","(Ljava/lang/String;Ljava/lang/String;)V"); Get method ID for setItem function in Java.
  3. string2jstring A helper function to convert std::string to java String
  4. jvalue params[2]; Initializing a jvalue with two parameters since our setItem function has two arguments.
  5. CallVoidMethodA Since our method does not return a value and has arguments. 
  6. Finally we return a boolean value to Javascript
auto getItem = Function::createFromHostFunction(
    jsiRuntime, PropNameID::forAscii(jsiRuntime, "getItem"), 1,
    [](Runtime &runtime, const Value &thisValue, const Value *arguments,
       size_t count) -> Value {

      string key = arguments[0].getString(runtime).utf8(runtime);

      JNIEnv *jniEnv = GetJniEnv();

      java_class = jniEnv->GetObjectClass(java_object);
      jmethodID get = jniEnv->GetMethodID(
          java_class, "getItem", "(Ljava/lang/String;)Ljava/lang/String;");

      jstring jstr1 = string2jstring(jniEnv, key);
      jvalue params[1];
      params[0].l = jstr1;

      jobject result = jniEnv->CallObjectMethodA(java_object, get, params);
      const char *str = jniEnv->GetStringUTFChars((jstring)result, NULL);

      return Value(runtime, String::createFromUtf8(runtime, str));

    });

jsiRuntime.global().setProperty(jsiRuntime, "getItem", move(getItem));

The getItem function has 1 argument, key and returns a string value.

  1. paramsCount is set to 1
  2. GetMethodID(java_class, "getItem","(Ljava/lang/String;)Ljava/lang/String;"); Get method ID for getItem function in Java.
  3. string2jstring A helper function to convert std::string to java String
  4. jvalue params[1]; Initializing a jvalue with one parameter since our setItem function has one argument.
  5. CallObjectMethodA Since our method returns a String value and has arguments. 
  6. Finally we return the String value to Javascript

And that's it. At least for the Android part. Now let's see how we can do the same on iOS.

iOS

Let's open example/ios/example.xcworkspace in XCode. Navigate to Pods > Development Pods > react-native-simple-jsi -> ios and open SimpleJsi.mm.

Similar to Android, we will create three functions, getModel, setItem and getItem

- (NSString *)getModel {

  struct utsname systemInfo;

  uname(&systemInfo);

  return [NSString stringWithCString:systemInfo.machine
                            encoding:NSUTF8StringEncoding];
}

- (void)setItem:(NSString *)key:(NSString *)value {

  NSUserDefaults *standardUserDefaults = [NSUserDefaults standardUserDefaults];

  [standardUserDefaults setObject:value forKey:key];

  [standardUserDefaults synchronize];
}

- (NSString *)getItem:(NSString *)key {

  NSUserDefaults *standardUserDefaults = [NSUserDefaults standardUserDefaults];

  return [standardUserDefaults stringForKey:key];
}

All these methods return NSString values but these are not directly readable in JSI. Let's convert JSI values to NS values and NS values to JSI values using the awesome YeetJSIUtils module.

All you have to do is add the two files (YeetJSIUtils.mm & YeetJSIUtils.h) to the project and import them in SimpleJsi.mm file to call the required helper functions.

Finally let's install the host functions in JSI like we did in the Android part above:

static void install(jsi::Runtime &jsiRuntime, SimpleJsi *simpleJsi) {

  auto getDeviceName = Function::createFromHostFunction(
      jsiRuntime, PropNameID::forAscii(jsiRuntime, "getDeviceName"), 0,
      [simpleJsi](Runtime &runtime, const Value &thisValue,
                  const Value *arguments, size_t count) -> Value {

        jsi::String deviceName =
            convertNSStringToJSIString(runtime, [simpleJsi getModel]);

        return Value(runtime, deviceName);
      });
}

The first thing to notice here is that our install function has two arguments. The first one is the usual JS runtime. While the second one is the current instance of our class SimpleJsi. We will need it to access the functions in Objective C.

  1. The number of arguments for this function is 0.
  2. [simpleJsi]We are passing simpleJsi instance to our lambda function because variables cannot be implicitly captured in a lambda with no capture-default specified.
  3. convertNSStringToJSIString From YeetJSIUtils which helps us to convert NSString to JSIString.
  4. Finally we are returning our device model name.

Now let's also add the remaining two functions setItem and getItem:

auto setItem = Function::createFromHostFunction(
    jsiRuntime, PropNameID::forAscii(jsiRuntime, "setItem"), 2,
    [simpleJsi](Runtime &runtime, const Value &thisValue,
                const Value *arguments, size_t count) -> Value {

      NSString *key =
          convertJSIStringToNSString(runtime, arguments[0].getString(runtime));

      NSString *value =
          convertJSIStringToNSString(runtime, arguments[1].getString(runtime));

      [simpleJsi setItem:key:value];

      return Value(true);
    });

jsiRuntime.global().setProperty(jsiRuntime, "setItem", move(setItem));

auto getItem = Function::createFromHostFunction(
    jsiRuntime, PropNameID::forAscii(jsiRuntime, "getItem"), 0,
    [simpleJsi](Runtime &runtime, const Value &thisValue,
                const Value *arguments, size_t count) -> Value {

      NSString *key =
          convertJSIStringToNSString(runtime, arguments[0].getString(runtime));

      NSString *value = [simpleJsi getItem:key];

      return Value(runtime, convertNSStringToJSIString(runtime, value));
    });

jsiRuntime.global().setProperty(jsiRuntime, "getItem", move(getItem));

Since both of these functions have arguments we are using  convertJSIStringToNSString to convert JSIString to NSString. The remaining is similar to our getDeviceName function.

One more thing to note is that we are not using[self setItem::] function in Objective C class even though our install function is inside the class. This is because we can't use it directly in C++.  

And now we run our app and there you go. All good and running on iOS too.

Conclusion

Using the code above you can convert any Native Module to a React Native JSI module. Writing the boilerplate for React Native JSI seems daunting but JSI is, by far, the best way to deliver native performance to your users. I guess the extra work is worth it, eh?

And the best part? No unnecessary Promise based APIs. No unnecessary conversion of parameters. No overhead. Everything happens on a single layer giving the best possible performance (and the best interopability).

You can find the whole code of the library + examples on Github.

I am actively using React Native JSI for encryption & storage in Notesnook Android & iOS apps. You can get the apps from here.