In the previous entry, I showed you how to use the bare JSI to write a native iOS module for React Native. In this article, I will show you the same for Android. Once completed, you will know the basics of writing bare-metal React Native libraries using JSI.
Make sure to follow the previous tutorial, including the setup of the skeleton project:
npx create-react-native-library@latest NativeMod
CMake setup
With that completed, open android/build.gradle to configure CMakeLists.
Android NDK includes CMake
as the default C++ built tool. Splice the below into the
Gradle file. The project will have an android section already, so edit it to fit in with your own file.
android {
namespace "com.nativemod"
compileSdkVersion getExtOrDefault("compileSdkVersion")
defaultConfig {
minSdkVersion getExtOrDefault("minSdkVersion")
externalNativeBuild {
cmake {
arguments "-DANDROID_STL=c++_shared",
"-DREACT_ANDROID_DIR=${project.extensions.getByName("react").reactNativeDir.file("ReactAndroid").get().asFile}"
}
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
buildFeatures {
prefab=true
}
externalNativeBuild {
cmake {
path "CMakeLists.txt"
}
}
}
The two important sections are externalNativeBuild. The first one sets REACT_ANDROID_DIR.
This is to give CMake a starting point to search libraries for - it uses the absolute path of the
React Native’s Android directory. On the second, prefab must be set. From what I can tell,
React Native libraries need to be compiled statically, and prefab enables this. Disabling
it causes build problems.
Now copy the below to android/CMakeLists.txt:
project(nativemod)
cmake_minimum_required(VERSION 3.13)
set(PACKAGE_NAME nativemod)
set(CMAKE_VERBOSE_MAKEFILE ON)
set(CMAKE_CXX_STANDARD 20)
file(GLOB_RECURSE android_files RELATIVE ${CMAKE_SOURCE_DIR}
"src/cxx/**.cpp"
"src/cxx/**.c"
)
find_package(ReactAndroid REQUIRED CONFIG)
add_library(${PACKAGE_NAME} SHARED
${android_files}
${react_native_files}
)
target_link_libraries(${PACKAGE_NAME}
ReactAndroid::jsi
)
This file took me longer to write than I’d like to admit, and I refined it just for this post. I did my best to do the work myself, without asking an LLM, but I ended up doing so at certain stages. That said, I got a decent understanding which is worth sharing with you. The first six lines set some basic metadata, such as the package name. For instance, we set the C++ standard to C++ 20.
We have two file GLOB_RECURSE commands.
Note the RELATIVE parameters. The file command says to search
using * (glob) relative to the current project’s directory, adding
the .cpp and .c source files in src. We will write them soon.
Next is find_package. CMakeList has a dependency system, and this declares that we depend on ReactAndroid. You can see it here in the React Native source code.
The add_library command tells CMake to build a shared (.so) library. It is not adding a library, rather, it’s declaring one. It will become nativemod.so. The library is the lynchpin of our project. You’ll see how Java uses it next. Finally target_link_libraries adds the JSI library, required to link our project with the runtime.
Java and JNI
With the build system set up, we can start writing Java and C/C++.
Wire up the TurboModule by declaring it in android/src/main/java/com/nativemod/NativeModPackage.kt:
package com.nativemod
import com.facebook.react.BaseReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.model.ReactModuleInfo
import com.facebook.react.module.model.ReactModuleInfoProvider
import java.util.HashMap
class NativeModPackage : BaseReactPackage() {
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
return if (name == NativeModModule.NAME) {
NativeModModule(reactContext)
} else {
null
}
}
override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
mapOf(
NativeModModule.NAME to ReactModuleInfo(
name = NativeModModule.NAME,
className = NativeModModule.NAME,
canOverrideExistingModule = false,
needsEagerInit = false,
isCxxModule = false,
isTurboModule = true
)
)
}
}
That is written in Kotlin - but the next file is in Java. I find Java’s native interface easier to use.
Create the implementation in android/src/main/java/com/nativemod/NativeModModule.java as follows:
package com.nativemod;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.turbomodule.core.CallInvokerHolderImpl;
public class NativeModModule extends NativeNativeModSpec {
public static final String NAME = NativeNativeModSpec.NAME;
static {
System.loadLibrary("nativemod");
}
public NativeModModule(ReactApplicationContext reactContext) {
super(reactContext);
var jsContext = reactContext.getJavaScriptContextHolder();
install(jsContext.get());
}
private native void install(long jsRuntimePointer);
}
Finally we are using some interesting Java features. The first to note is the loadLibrary call.
We of course declared the library in CMakeLists earlier, with CMake producing nativemod.so.
Java then loads it into memory when the JVM starts running.
The second is the native modifier on install. It has no body. Instead, we will
write the body later in C and C++. The parameter jsRuntimePointer is a 64-bit integer, which
is in fact the pointer jsi::Runtime*. By receiving the pointer as a long in Java, and casting it to a pointer to Runtime,
we can manipulate the runtime to use our native implementation.
Now implement the body of install in android/src/cxx/NativeModEntry.c.
#include <jni.h>
#include <stdio.h>
#include "NativeModImpl.h"
JNIEXPORT void JNICALL Java_com_nativemod_NativeModModule_install(JNIEnv * jenv, jobject thisObj, jlong runtime_ptr) {
installJsi((void*)runtime_ptr);
}
You’ll see the package name and method name in the function definition. It follows the JNI format for method names. It lets the JVM locate the implementation of install in nativemod.so. Note that install calls installJsi in NativeModImpl.h. We declare that in android/src/cxx/NativeModImpl.h as follows:
#pragma once
void installJsi(void* runtimePtr);
And finally, the Hello, world! code - almost identical to the previous tutorial.
Copy the following to android/src/cxx/NativeModImpl.cpp:
#include <jsi/jsi.h>
using namespace facebook;
extern "C" void installJsi(void *runtimePtr) {
auto* runtime = static_cast<jsi::Runtime*>(runtimePtr);
jsi::Runtime& rt = *runtime;
auto doSomethingNative = [](jsi::Runtime &rt, const jsi::Value &thisValue, const jsi::Value *args, size_t) -> jsi::Value {
jsi::String helloString = jsi::String::createFromUtf8(rt, "Hello from your phone!");
rt.global()
.getPropertyAsObject(rt, "console")
.getPropertyAsFunction(rt, "log")
.call(rt, helloString);
return jsi::Value::undefined();
};
auto doSomethingNativeJs = jsi::Function::createFromHostFunction(
rt,
jsi::PropNameID::forAscii(rt, "doSomethingNative"),
0,
doSomethingNative
);
rt.global().setProperty(rt, "doSomethingNative", std::move(doSomethingNativeJs));
}
I found I also needed to update App.tsx to get the module to load (line 3):
import { useEffect } from 'react';
import { Text, View, StyleSheet } from 'react-native';
import "native-mod"; // Changed this
export default function App() {
useEffect(() => {
(global as any).doSomethingNative();
});
return (
<View style={styles.container}>
<Text>{"Hi, everybody!"}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
});
Our files are all set up. Change directory to example/ and run yarn android to build, install, and run your example application.
When it is ready, you’ll see Hi, everybody in your app. Open DevTools, choose Console, and you should see it print Hello from your phone!.
If you got it working, I sincerely congratulate you. The example here is very simple, but getting the details with CMake, Java Native Interface, et al. was challenging. No doubt you noticed using JSI in Android is more complicated with than in iOS; With Objective-C++ in iOS, it is easy to integrate the C++ JSI into our native module. With the JVM, we must go through JNI to give our C++ code access to the Runtime pointer. But once this is done, you can use the Runtime to your liking.
With both iOS and Android sorted out, Part One of the JSI series is complete. In part two, we’ll explore practical ways to get your native library to do useful work with your native classes - including support for asynchronous code.