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:
- 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.
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).
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:
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
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
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.
3. When a React Native component left out a property
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.