◐

Writing React Native Modules With JSI - Part Two (Simple Calls)

In Part One of the series, we used React Native’s Turbo Modules to wire a basic function on the JSI. We were able to define a native function that could be called from JavaScript. In Part Two, I will show you how to call methods on Objective-C and Java classes.

We’ll get started with iOS, before proceeding with Android. But first, update App.tsx. It will call our native code and render the result. While working on this article, I also found it needed a small tweak.

import { Text, View, StyleSheet } from 'react-native';
import "native-mod"; // Changed this

export default function App() {
  const concatenate = (global as any).concatenate;
  return (
    <View style={styles.container}>
      <Text>{concatenate("Hi, ", "everybody!")}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
});

iOS

Using the JSI introduced me to the wonders of Objective-C++. As you may have noticed in Part One, Objective-C++ means we can call C++ code directly from Objective-C classes. So we will define a simple Objective-C class with a concatenate method. It will join two strings together. Objective C++ will let us call concatenate from the C++ runtime.

Define the class in ios/NativeImpl.h as below.

@interface NativeImpl : NSObject

- (NSString *)concatenate:(NSString *)string otherString:(NSString *)suffix;

@end

And its implementation in ios/NativeImpl.m:

#include "NativeImpl.h"

@implementation NativeImpl

- (NSString *)concatenate:(NSString *)string otherString:(NSString *)suffix {
	return [NSString stringWithFormat:@"%@%@", string, suffix];
}

@end

It is a plain Objective-C class that only uses stringWithFormat. So far we have not used any C++ or C++ types, as this is not an Objective C++ file. But we’ll be able to use it from NativeMod.mm, which is. Update the entire contents of ios/NativeMod.mm with the below:

#import "NativeMod.h"
#import "Foundation/Foundation.h"

#include <utility>
#include <stdexcept>

#import <jsi/jsi.h>

#include "NativeImpl.h"

using namespace facebook;
using namespace std;

jsi::Value concatenate(jsi::Runtime &rt, const jsi::Value &thisValue, const jsi::Value *args, size_t argc) {
    NativeImpl *nativeImpl = [[NativeImpl alloc] init];

    if (argc < 2) {
        throw runtime_error("at least two arguments must be provided");
    }

    std::string prefix = args[0].asString(rt).utf8(rt);
    std::string suffix = args[1].asString(rt).utf8(rt);

    NSString *prefixNs = @(prefix.c_str());
    NSString *suffixNs = @(suffix.c_str());
    NSString *resultNs = [nativeImpl concatenate:prefixNs otherString:suffixNs];

    NSData* data = [resultNs dataUsingEncoding:NSUTF8StringEncoding];
    const uint8_t *result_cstr = (uint8_t *)[data bytes];

    return jsi::String::createFromUtf8(rt, result_cstr, [data length]);
}

@implementation NativeMod

+ (NSString *)moduleName
{
  return @"NativeMod";
}

- (std::shared_ptr<react::TurboModule>)getTurboModule:
    (const react::ObjCTurboModule::InitParams &)params
{
    return std::make_shared<react::NativeModSpecJSI>(params);
}

- (void)installJSIBindingsWithRuntime:(facebook::jsi::Runtime &)runtime
                          callInvoker:(const std::shared_ptr<facebook::react::CallInvoker> &)callinvoker {
    auto concatenateJs = jsi::Function::createFromHostFunction(
        runtime,
        jsi::PropNameID::forAscii(runtime, "concatenate"),
        2,
        concatenate
    );
    runtime.global().setProperty(runtime, "concatenate", std::move(concatenateJs));
}

@end

You’ll see that we’ve replaced doSomethingNative from Part One with concatenate. I’ve defined the latter in its own function to make the code a little more readable. You’ll see that the bulk of it is dedicated to string conversion.

+-------------+
| jsi::String |
+-------------+
       |
   asString
       |
       v
+-------------+
| std::string |
+-------------+
       |
     c_str
       |
       v
+-------------+
| const char* |
+-------------+
       |
  stringWithUTF8String
       |
       v
+-------------+
| NSString*   |
+-------------+

These are three operations between four types! The first asString copies the string out of JSI. Then c_str returns a pointer to an array of characters. Finally stringWithUTF8String converts it to NSString. We do the reverse to convert the output NSString back to jsi::String. The first and third operations involve copying, so it isn’t greatly efficient. When you write a real library or app, you may want to optimise this.

Build and run your app with yarn ios, and open the app on your iOS device. You should see it display Hi, everybody! This was quite straightforward. I think it was satisfying. Let’s do the same for Android.

Android

Before we begin, let’s fix a problem with our CMakeLists file. Find the relevant section in android/CMakeLists.txt and update it to match the below. The previous entry used ** which caused some problems finding our source files. The latest will be sure to match every source file in src/cxx.

file(GLOB_RECURSE android_files RELATIVE ${CMAKE_SOURCE_DIR}
    "src/cxx/*.cpp"
    "src/cxx/*.c"
)

Now let’s write the Java class our React Native app will interact with. It will be just like the Objective-C NativeImpl we wrote. Copy it into android/src/main/java/com/nativemod/NativeImpl.java:

package com.nativemod;

public class NativeImpl {
  public String concatenate(String prefix, String suffix) {
    return prefix + suffix;
  }
}

We are going to use JNI to call concatenate. You might recall we used JNI in the previous entry to implement the install method. install calls native code from Java. Now we are doing the reverse - our native module calls into Java from native code.

Update android/src/cxx/NativeModImpl.h by adding a JNIEnv * pointer to the declaration of installJsi. JNIEnv is our gateway into Java from native code. Any methods we define on the JavaScript runtime hold onto the pointer to access Java classes and methods.

#pragma once

#include <jni.h>

void installJsi(JNIEnv *, void *runtimePtr);

Implement it by updating android/src/cxx/NativeModImpl.cpp:

#include <jsi/jsi.h>
#include <jni.h>

#include "concatenate.hpp"

using namespace facebook;

extern "C" void installJsi(JNIEnv *jenv, void *runtimePtr) {
  auto* runtime = static_cast<jsi::Runtime*>(runtimePtr);
  jsi::Runtime& rt = *runtime;
  
  auto concatenateClosure = [jenv](jsi::Runtime &rt, const jsi::Value &thisValue, const jsi::Value *args, size_t argc) -> jsi::Value {
    return concatenate(jenv, rt, thisValue, args, argc);
  };
  auto concatenateJs = jsi::Function::createFromHostFunction(
      rt,
      jsi::PropNameID::forAscii(rt, "concatenate"),
      2,
      concatenateClosure
  );
  rt.global().setProperty(rt, "concatenate", std::move(concatenateJs));
}

The installation function defines concatenate on the runtime, and calls the implementation behind concatenate.hpp. We’ll add that in next. We capture jenv in concatenateClosure with the [jenv] clause. C++ requires local variables used by the closure be declared in the capture list.

We are now ready to implement our concatenate method. First create the header file android/src/cxx/concatenate.hpp as below:

#include <jni.h>

using namespace facebook;

jsi::Value concatenate(JNIEnv *jenv, jsi::Runtime &rt, const jsi::Value &thisValue, const jsi::Value *args, size_t argc);

JNIEnv is the first parameter, but the rest are identifical to JSI’s HostFunctionType.

For the implementation, I want us to proceed in steps. I will show you snippets of android/src/cxx/concatenate.cpp one at a time, and discuss any notable points with you. You are responsibile for concatenating them together.

The first snippet is a helper function that calls concantenate in Java. While writing, I refactored my work into separate functions to keep it readable. JNI code quickly explodes in length!

#include <jsi/jsi.h>
#include <jni.h>

using namespace facebook;

jstring callConcatenate(JNIEnv *jenv, jstring prefix, jstring suffix) {
    // Locate the class
    jclass nativeImplClass = jenv->FindClass("com/nativemod/NativeImpl");
    if (nativeImplClass == nullptr) {
        throw std::runtime_error("unable to find com/nativemod/NativeImpl");
    }

    // Allocate it, no need to call constructor
    jobject nativeImplObj = jenv->AllocObject(nativeImplClass);
    if (jenv->ExceptionCheck()) {
        throw std::runtime_error("exception thrown while allocating com/nativemod/NativeImpl");
    }

    // Find a method called concatenate that takes two strings and returns string
    jmethodID concatenateID = jenv->GetMethodID(nativeImplClass, "concatenate", "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;"); 
    if (concatenateID == nullptr) {
        throw std::runtime_error("unable to find concatenate method");
    }

    // Actually call the method here
    jstring result = (jstring)jenv->CallObjectMethod(nativeImplObj, concatenateID, prefix, suffix);
    if (jenv->ExceptionCheck()) {
        throw std::runtime_error("exception thrown in concatenate method");
    }

    return result;
}

I hope the code and comments are clear enough to show you how we are using JSI here. It looks bulky and a bit ugly, but is it simply initialising our NativeImpl class, then calling concatenate with prefix and suffix parameters. You might observe that:

If you want to know more, I reccommend reading Chapter 4: JNI Functions of the JNI specification.

Our next two snippets involving marshalling data between Java and JSI land. First, we need a function that can convert from jsi::String to a jstring (a special JNI type for java.lang.String).

jstring toJni(JNIEnv *jenv, jsi::Runtime &rt, jsi::String &input) {
    std::u16string input_utf16 = input.utf16(rt);
    const char16_t *inputChars = input_utf16.c_str();

    jstring output = jenv->NewString((const jchar *)inputChars, input_utf16.length());
    if (output == nullptr) {
        throw std::runtime_error("unable to convert arg 0 to Java string");
    }
    if (jenv->ExceptionCheck()) {
        throw std::runtime_error("exception thrown while converting arg 0");
    }

    return output;
}

JNI has specialised functions for string operations. String is a Java class, but the JNI allows us to easily allocate and manage strings, without going through FindClass, GetMethodID, CallObjectMethod, and so on. Thus NewString allows us to initialise the string in one step from a buffer of data.

Both Java and JavaScript strings are UTF-16, meaning that characters such as are encoded as 16-bit numbers, or pairs of 8-bit bytes. For example, the ASCII string ‘AB’ is encoded as 0x0041 0x0042, which in little-endian might be stored as the byes 0x41 0x00 0x42 0x00 (little endian means the smaller byte goes first).

Treating that UTF-16 string as UTF-8, we would get ‘A’, followed by null-character (zero), followed by ‘B’, and another null-character, which would be four characters, and wrong. So we must take some care not to mix them up

Thus the toJni function converts our jsi::String to a UTF-16 string, acquires the raw buffer, and passes it to NewString which wants 16-bit codepoints (array of jchar).

I hope the explanation was useful. Now we need to do the reverse to convert from Java jstring to jsi::String:

jsi::String toJsi(JNIEnv *jenv, jsi::Runtime &rt, jstring input) {
    const char *data = jenv->GetStringUTFChars(input, nullptr);
    if (data == nullptr) {
        throw std::runtime_error("unable to convert concatenated string");
    }
    jsize len = jenv->GetStringUTFLength(input);

    jsi::String resultString = jsi::String::createFromUtf8(rt, (const uint8_t *)data, len);
    jenv->ReleaseStringUTFChars(input, data);

    return resultString;
}

Here the implementation is a little different. GetStringUTFChars returns UTF8-encoded-data, not UTF-16. But this is OK, because jsi::String supports createFromUtf8. For consistency, it would be ideal to use UTF-16 for the whole program. JNI may support a UTF-16 equivalent of GetStringUTFChars, but I have not encountered it yet.

I found a bug while testing my implementation of toJsi. In JavaScript, you can express a string like "Hello\x00World". See the null character between the two words. However, when toJsi processes the string, the null gets treated as a string terminator. This is reasonable for a class C-string, but not in JavaScript.

> "Hello\x00" + "World"
'Hello\x00World'
> window.concatenate("Hello\x00", "World")
'Hello'

Understanding these subtle differences is what makes a good native programmer. We’ll leave the bug in as a limitation of our concatenate function.

With our JNI and string code ready, we are ready to put together the primary function of android/src/cxx/concatenate.cpp:

jsi::Value concatenate(JNIEnv *jenv, jsi::Runtime &rt, const jsi::Value &thisValue, const jsi::Value *args, size_t argc) {
    if (argc < 2) {
        throw std::runtime_error("at least two arguments must be provided");
    }

    jsi::String prefix = args[0].asString(rt);
    jsi::String suffix = args[1].asString(rt);

    jstring prefix_jstring = toJni(jenv, rt, prefix);
    jstring suffix_jstring = toJni(jenv, rt, suffix);

    jstring result_jstring = callConcatenate(jenv, prefix_jstring, suffix_jstring);

    return toJsi(jenv, rt, result_jstring);
}

To reiterate, this refactored version of concatenate is five times more readable than the original, in which the JNI and string conversion was done in a single function. You are now ready to run the code with yarn android. You should see Hi, everybody!, just as on iOS.

Again, I congratulate you if you made it this far. The Android side has always been a bit harder. In the next article, we will use Promises to support asynchronous code. This will let us write more powerful code.