The native iOS SDK has over 15,000 API’s. React Native provides access to a tiny fraction. Wouldn’t it be fun to access all of them from Javascript without wrapping each one individually with a native manager? Let’s discuss how to do so and why it might even be a little useful.
During the last week or so I’ve been working on detox, our emerging graybox e2e testing framework (I’ll write about it separately when it’s ready). One of the challenges there is exposing to Javascript a native iOS library that has over a hundred API’s. If you’ve ever implemented a native wrapper in React Native, you know it’s a tedious process filled with boilerplate code.
There had to be a better way to access the full breadth of this native API from Javascript. I wasn’t about to wrap each API individually just so I can access it from the other side of the bridge.
If you’re unfamiliar with the bridge concept of React Native, see the high-level overview in my previous post.
Reflection to the rescue
Reflection in software engineering is the ability of a computer program to examine, introspect, and modify its own structure and behavior at runtime. It’s a powerful meta programming tool that allows our code to manipulate the code being run. Luckily, both Objective-C in iOS and Java in Android support reflection to a usable extent.
This became our plan:
- Our Javascript code will specify which native method should be invoked by specifying it as a simple string. The arguments for this method will be provided as an array (assuming they’re serializable).
- Since this entire specification is now serializable, we can send it over the React Native bridge to the native side.
- Our helper library in the native side will parse the specification and use reflection in runtime to locate the method by string on the relevant class. If the method exists, we’ll use reflection to invoke it. The idea behind this dynamic approach is that our native helper doesn’t need to know in advance which methods we’re planning to execute.
- Assuming the native return value is serializable, we’ll send it back over the React Native bridge and consume it in Javascript.
Let’s use this technique on a real life example
If you’ve ever used React Native’s ScrollView, you may have noticed that it doesn’t have a getScrollOffset method. The only way to get the scroll offset is to subscribe to scroll update events and remember the value — and that’s hackish (and generates unnecessary traffic over the bridge).
React Native’s ScrollView is a wrapper over the native UIScrollView — which naturally does contain API to read the content offset. Let’s use our new technique to execute this native getter method directly from Javascript:
The first line implements step 1 in our plan — the Javascript code specifies (as a string) that we want to use the native contentOffset method.
The second line implements step 2 in our plan — it takes the specification and sends it over the bridge to be executed. Notice how it returns a promise with the result since the code will be executed asynchronously in native.
But what happens when things aren’t serializable?
The scroll offset is a simple serializable object — just X and Y. Which means it can easily pass over the bridge as our plan requires.
This might not always be the case. When I tried playing with the scroll offset example, I originally planned scrollComponent to be our React ScrollView component instance. It turns out that the React ScrollView does not extend UIScrollView directly, it contains it as a child available through a native getter named scrollView.
Not a problem, right? Let’s just make another prior native execution to get a reference to the native UIScrollView from our React component:
Unfortunately, this won’t work. The problem is that scrollView is a complex object — it’s a reference to the native UIScrollView and it isn’t serializable as our approach mandates.
To work around this issue, we’ll do a very cool trick. Instead of sending over a single command to execute in the native side, let’s make our technique more powerful by allowing to send over multiple commands that depend on each other. Since all commands will be executed together in the native side, they will be able to pass complex objects between them! Since these complex objects won’t need to go back over the bridge to JS, they won’t have to be serializable.
Our new approach delivers the following implementation that will work:
As you can see, we now have a single execution that involves two different calls in the native side. The result of the first call isn’t serialized as a promise back to JS, it’s simply given as part of our specification to the second call.
This approach worked wonderfully for detox. So wonderfully in fact, that we’ve decided to release it as a separate standalone open source project:
Using the technique introduced in this library, you can access any native API directly from Javascript without wrapping it first. The library is fully documented with a working example project showing 3 different use-cases for things you can now do in JS that you previously couldn’t.
Where could this be useful?
This actually isn’t a straightforward question. Although this technique sounds very cool at first, I wouldn’t recommend it for wide use. In most cases, when you need to access native API that isn’t available in JS, the best approach would be to wrap it as a native module in React Native.
These are the cases where I would recommend using this new technique:
1. When there’s too much native API and you’re feeling lazy
This is the problem I had. I wanted to systematically expose over a hundred native API’s as a Javascript library. Wrapping each one natively in a manager, and maintaining this wrapper as the native API changes, would have driven me insane. It’s much more efficient to maintain a pure Javascript library instead.
There are currently over 15,000 API’s in Apple’s native iOS SDK. This number is growing at an average of 1,400 new API’s per each iOS version released — this is faster than we can ever wrap them.
2. When you want to change code over the air
One of the amazing benefits of React Native and having your business logic implemented in Javascript — is the ability to change your app’s code remotely. This is important for fixing bugs quickly and maintaining a continuous delivery model.
Traditionally, when your business logic was implemented natively, you would have needed to release a whole new binary to fix even the slightest bug. And then wait for a manual review by Apple. And then hope your users are updating their apps.
Since our approach invokes native code from pure Javascript, in the unfortunate case of a bug, you’ll be able to update remotely with awesome tools like Microsoft CodePush.
3. When a React Native component left out a property
Overall, the React Native team did a great job wrapping the basic native components you need. They expose in Javascript most of the functionality that is natively supported.
Occasionally, properties have been left out. And this is annoying. Because adding native code to an already existing native wrapper is an ugly task.
This happened to us a few times in our production app at Wix.com, and in every case our previous solutions weren’t pretty. We’ve needed to read the current scroll offset, which ScrollView doesn’t support, but the native UIScrollView does. We’ve needed to read the current text cursor position, which TextInput doesn’t support, but the native UITextView does. We’ve needed to move the activity indicator, which RefreshControl doesn’t support, but UIRefreshControl does. And so forth.
In order to verify that the technique is sound, I’ve implemented all 3 of these use-cases in the example project of the open source library.
A final note about performance
When introducing this technique, I’ve received a few questions about its performance that I’d like to address. When talking about performance, it’s important to understand what are we comparing to.
If we’re comparing this technique to an app implemented purely in native, we’re going to have significant performance degradation. Serializing our invocation specifications over the React Native bridge is expensive. If we have to do so for every single command an app runs, we’re going to have a bad time. It wouldn’t be a wise idea to implement a full Objective-C app in Javascript invoking the same Objective-C syntax.
If we’re comparing this technique to how React Native performs native calls on wrapped managers, performance is going to be in the same ballpark. When a native manager is accessed from Javascript, the data is serialized and passed over the bridge as well. It is true that making the same invocation over and over will incur some overhead of parsing the specification in native, but this can be easily mitigated by introducing specification-caching in our invocation library. This is a cool idea for a PR if you’re interested in contributing.