on
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:
- FindClass uses forward-slashes in place of full-stops (
.) to identify a class. - When you locate a class, you can create an object instance with AllocObject.
- Locating a method requires you specify the method name, the parameters, and its return type.
- We are calling an “object method” because the return type, String, is an object.
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.