During a React Native project for one of our clients we added some custom Android and iOS libraries to our code and wanted to call a few exposed methods. In such a case, React Native requires you to write a wrapper class to call those public APIs. It was a small boilerplate nuisance and these wrappers would be unnecessary if we made a generic method call bridging API. Also, using such an API wrapper you can call any (obscure) available Android API that is not wrapped yet. Let's see how far we can get!

Goal

For the following proof-of-concept, the goal is simple: we want to write some JS statements that closely resemble API calls on the native platform. The JS API should be dynamically generated on the JS side based on the native API properties, and calling methods and returning responses back to the JS thread should work. To scope it a bit and to get a nice example, we want to do this for Android's Toast API. We are fully aware that there is already a wrapped API by React Native, this code is only used as proof of my implementation.

So, my imagined API to show an 'Awesome' toast message when run is:

  const Toast = createBridge('android.widget.Toast');
  const context = getReference('context');
  Toast.makeText(context, 'Awesome', Toast.LENGTH_SHORT).show();

The createBridge function should create a Toast object on the JS side, based on the public API that is exposed by the android.widget.Toast class. That object should have at least the function makeText that performs a call on the native side, converting the parameters from JS to native types. The returned object should again be transformed to a JS object with a show function. To get a reference to the current Android context a helper function should be available.

Implementation

The first step is to dynamically create a JS object that resembles an API on the native side. Java reflection is used to introspect the exposed API static public members and create a representation that can be passed over the React Native bridge. The native side looks like this:

// ...

public class AndroidJSAPIModule extends ReactContextBaseJavaModule {
    @Override
    public String getName() { return "AndroidJSAPI"; }

    @ReactMethod
    public void reflect(String className, Promise promise) {
        try {
            Class<?> clazz = ClassUtils.getClass(className);
            ReadableMap classReflection = jsAPI.reflect(clazz);
            promise.resolve(classReflection);
        } catch (Exception e) {
            promise.reject(e);
        }
    }

    // ...
}

The @ReactMethod annotation makes the reflect method accessible via the React Native bridge. Normally, you would use this to expose your custom wrapped native code. The first issue pops up here: the API is asynchronous - which is a good thing from a performance perspective, but not very clear for synchronous, imperative statements being called. There might be some tricks to hide this fact from JS code, but that would end up as another leaky abstraction, so we settled with some async handling in JS.

The JS side for this module ends up like this:

import { NativeModules } from 'react-native';

const JSAPI = NativeModules.AndroidJSAPI;

async function createBridge(className) {
  const reflection = await JSAPI.reflect(className);
  return createWrapper(reflection);
}

function createWrapper(reflection) {
  const ret = {};
  for (let method of reflection.methods) {
    ret[method.name] = wrapMethod(reflection.objectId, reflection.className, method, ret[method.name]);
  }
  for (let field of reflection.fields) {
    ret[field.name] = field.value;
  }
  return ret;
}

// ...

We call the reflect method to get a JSON object with static member information (fields, methods and argument type information to refer to a specific method call), and create a JS object with functions and properties that resembles the API. async/await is used to write clean code for handling the asynchronous nature of the React Native method bridge. We want to support Java method overloading so we are passing the previous JS function with the same name to the JS function generator. We can call the previous function that might better match the arguments provided in JS.

Now we need to expose direct method invocation via the React Native bridge:

    @ReactMethod
    public void methodCall(Integer objectId, String className, String staticMethod, ReadableArray arguments, Promise promise) {
        try {
            Object receiver = objectId > 0 ? registry.get(objectId) : null;
            Class<?> clazz = ClassUtils.getClass(className);
            Method method = clazz.getMethod(staticMethod, jsAPI.createParameterTypes(arguments));
            Object result = method.invoke(receiver, jsAPI.createParameters(arguments));

            int resultObjectId = registry.add(result);
            WritableMap classReflection = jsAPI.reflect(result);
            classReflection.putInt("objectId", resultObjectId);
            promise.resolve(classReflection);

        } catch (Exception e) {
            promise.reject(e);
        }
    }

A lot of things are happening here. If provided, the objectId parameter is turned into an actual object reference via a simple object registry. Then the method is looked up via the Class instance based on the parameter types sent back from JS and invoked with the provided parameter values. The result is assumed to be an object and added to the registry. The reflection of that object is returned, together with the objectId reference to the object, so the JS side can eventually call methods on that instance.

This methodCall method is used in the JS side as follows:

function wrapMethod(objectId = 0, className, methodReflection, prevWrappedFn) {
  return async function () {
    // ...
    const reflection = await JSAPI.methodCall(objectId, className, methodReflection.name, typedArguments);
    return createWrapper(reflection);
  }
}

The method reflection is used to refer to a method with a specific name and typed arguments, which are dynamically matched with JS types. There is quite some information loss in the current implementation of JS-to-Java type matching (which is not shown here for brevity), but for the Toast example it works.

Result

So, combining all these pieces we can run the following code wrapped in an async function:

(async function () {
  const Toast = await AndroidBridge.createBridge('android.widget.Toast');
  (await Toast.makeText(AndroidBridge.context, 'Awesome', Toast.LENGTH_SHORT)).show();
})();

This indeed shows a nice 'Awesome' message for a short period of time. We added a special AndroidBridge.context reference to point to the current context instance inside of the activity. Also, we have to wrap each createBridge and dynamically generated call using await because the async API requires it.

Conclusion

Building a dynamically generated JS API using React Native's bridge is possible (at least for Android). It is probably against the design philosophy of React Native to use such a bridge, though, as it will never be as performant as calling precompiled native statements from a @ReactMethod annotated method.

But the positive part is that this implementation shows that you can access any (custom) Android API on the platform simply from JS, which is an often heard missing part of React Native. It is up to developers to decide if it's worth sacrificing performance for faster development.

Of course, this solution could be improved in many places:

  • Finish type conversions as they are incomplete
  • Implement constructor call from JS for non-static APIs
  • Smarter object registry that actually releases references to created objects
  • Drop async/await as it is unnatural for statements that are usually synchronous
  • Add IDE hinting for dynamic JS
  • Add some performance optimizations

You can check out the current proof of concept in this repo.