React Native Performance — An Updated Example

13,392 reads

I’m working on my performance talk for React Amsterdam 2017, which is based on the post “Performance Limitations of React Native and How to Overcome Them”. I’ve decided to freshen up the example we’ll be discussing in order to walk through some exciting new API available today.

Some background

In this post, we’ll be assuming you are already familiar with the basic architecture of React Native — the native realm, the JavaScript realm and the bridge connecting the two. If not, this topic is discussed in detail in the previous post.

Let’s also remember the main conclusion from the previous post that will guide our performance-oriented discussion:

In order to architect performant React Native apps, we must keep passes over the bridge to a minimum.

So.. Time to jump into our new interesting use-case which, as it turns out, is not trivial to implement with React Native in a performant manner.

A new real life example

Our use-case is inspired by the home screen of the Wix.com app. The screen holds a feed of cards and contains a large header image welcoming the user to the app. This is what the UX looks like:

Notice two interesting effects. The first — as the user is scrolling down the list of cards, the header image dissolves slowly into the gray background. The second — if the user scrolls up and is already at the top of the list, for the sake of continuity of movement the header image zooms slightly until it bounces back (this is called an overscroll effect).

Effects like these may seem minor, but are part of the last mile that makes a magical user experience and separates the mediocre apps from the great ones. We’ll have to be careful with the implementation though. If it doesn’t run smoothly at 60 FPS, the effect might backfire and degrade user immersion instead of enhancing it.

The general component layout of the screen is very simple:

Notice that the header image is not part of the ListView because it remains static and doesn’t scroll with the list content. Also notice we are still using the older ListView implementation instead of the recommended FlatList. This is due to some extra flexibility ListView provides that isn’t yet available in FlatList.

Our first implementation — onScroll events in JavaScript

As usual, we’ll start with the straightforward approach. If we want the effects to be tied to scroll position, we can listen on scroll events. Every time the scroll position changes, our event listener will fire and we’ll be able to re-render the header image and apply the appropriate effect.

The setup is somewhat tricky because ListView doesn’t expose the internal ScrollView used to scroll the content. It works by allowing us to provide our own custom ScrollView using the renderScrollComponent prop.

Applying the effects to the header image is as simple as defining style properties. We’ll use opacity to control the dissolve effect and transform.scale to control the zoom effect. We’ll store the values for both in local component state and re-render by calling setState .

This is the complete implementation:

As you can see, on every onScroll event we decide which effect to apply based on scroll position ( contentOffset ). If the scroll position is positive (downwards scroll), opacity is decreased from 1.0 to 0.0 . If the scroll position is negative (upwards overscroll), scale is increased from 1.0 to 1.4 .

First attempt — performance analysis

What performance should we expect from this implementation? The main performance bottleneck is probably the number of passes over the bridge.

Let’s count passes by analyzing what’s running in the JavaScript realm (purple on the left) and what’s running in the native realm (black on the right):

Just like any other view event, scroll events originate in the native realm. Our JavaScript logic runs in the JavaScript realm. Once we re-render, the new view properties must be applied to the actual native views back in the native realm. This means we’re passing over the bridge twice for every frame.

On busy apps, this will prevent us from running at 60 FPS. Let’s improve.

A second implementation attempt — native scroll listener

React Native is very flexible regarding which components are implemented in JavaScript and which components are implemented in pure native. A native component can contain a JavaScript component and a JavaScript component can contain a native one.

Whenever we have a performance issue, we can usually pull this rabbit out of the hat. Porting one of our components to native can usually solve the problem. Let’s try to apply this principle here.

Our performance bottleneck stems from implementing our scroll listener in JavaScript. Since scroll events originate in the native realm, executing our onScroll logic in JavaScript will always incur overhead. Let’s move the onScroll logic to native.

The purpose of onScroll is to update view properties (opacity and scale). We can save this trip over the bridge as well by closing the entire loop in native. Instead of changing the opacity and scale of the header image directly, we can wrap the image with a native container component —conveniently named NativeWrapper — that will change its own properties. This will work because both opacity and scale of a container affect its children as well. This NativeWrapper component will contain the native implementation of our onScroll logic.

Our new layout:

The only challenge so far is hooking our native scroll listener to the correct ScrollView. The ScrollView is part of the ListView and is neither a child or parent of our NativeWrapper . We need to be able to pinpoint it from within the native implementation in order to connect the listener. Every React component has a node handle — sometimes called a React tag. This is just a number that uniquely identifies this component instance. We can find this number using ReactNative.findNodeHandle and simply pass it as prop to NativeWrapper .

Now that the JavaScript side is all set up, it’s time to get our hands dirty with some Objective-C.

Porting the onScroll logic itself to Objective-C is easy. The code actually looks almost exactly the same. Instead of calling setState to update the view properties, we can simply change the view properties directly since we’re running in the native realm.

The only challenge here once again is hooking our native scroll listener. Once the numeric node handle of the ScrollView arrives via props, we need to translate it back to a view object reference in order to access the underlying ScrollView instance. This involves a little borrowed boilerplate that uses the UIManager to do the translation.

This is the Objective-C implementation:

As you can see, the onScroll implementation is almost identical to the JavaScript one. If the scroll position is positive (downwards scroll), opacity is natively decreased from 1.0 to 0.0 . If the scroll position is negative (upwards overscroll), scale is natively increased from 1.0 to 1.4 .

Second attempt — performance analysis

We expect performance to be much better since this implementation is tailored for reducing passes over the bridge.

Let’s count passes by analyzing what’s running in the JavaScript realm (purple on the left) and what’s running in the native realm (black on the right):

It’s not surprising that we’ve indeed eliminated all the passes over the bridge after initialization. Both handling of the scroll events and updating of the view properties accordingly now take place in the native realm.

We’re still not happy though. This implementation may be performant, but it’s rather complex and requires native expertise. Can we do the same from JavaScript?

Third time’s a charm — declarative API for the win

So, can we do the same from JavaScript? This is the million dollar question for React Native. For the framework to be truly useful, we must find ways to resolve these performance issues without resorting to native code.

This is one area where a lot of progress has been made since the previous post. The key to reducing passes over the bridge is declarative API. It allows us to declare behaviors in advance in JavaScript and serialize the entire declaration and send it once over the bridge during initialization. From this point on, a general purpose native driver — one that you don’t need to write yourself — will execute the behavior in the native realm according to the declared specification.

The behavior we want consists of two parts — listening to scroll position changes and updating view properties (opacity and scale). For the latter, we know that Animated, the excellent animation library that’s part of the core, provides good support.

What is less known is that Animated can drive an Animated.Value based on scroll events as well.

How does that work? Let’s jump into the complete implementation:

We’ve started by defining an Animated.Value that we’ll use to hold the scroll position at any given time. We’re providing our ListView with a custom ScrollView component — an Animated.ScrollView instead of a regular ScrollView. The Animated.ScrollView lets us declare that our Animated.Value is driven by the contentOffset property of the native onScroll event.

From this point forward, we can use the standard Animated approach of interpolating view properties based on an Animated.Value . This requires changing our header image to Animated.Image and allows us to interpolate both the opacity and transform.scale from scroll position.

Note that the entire implementation is declarative. We no longer have an imperative onScroll function that performs calculations for our effects.

Also note that we’ve specified useNativeDriver . The implementation of the Animated library in recent versions of React Native finally contains a native driver that can execute the entire declaration from the native realm without using the bridge.

Third attempt — performance analysis

As usual, we’ll count passes by analyzing what’s running in the JavaScript realm (purple on the left) and what’s running in the native realm (black on the right):

The only part running in the JavaScript realm is the initialization of our declaration. The entire declared behavior is serialized and sent over the bridge once in order to configure the general-purpose native driver of Animated.

After the driver has been configured, it takes care of the frame by frame for us in the native realm without additional passes over the bridge. This section has been grayed out since it’s no longer under the responsibility of our code.

Comparing the three implementations

The full code of all three implementations is available on GitHub:

The demo project lets you choose which implementation to use so they can be compared side by side. Try to run it on a real device. Judging performance on a simulator is usually inaccurate.

Since it’s just a simple demo app, there is very little unrelated bridge activity. You will probably not notice any difference between implementations. To assist with this, the demo app provides a toggle to simulate stress conditions with plenty of activity. As you can see, turning it on will make the performance differences much more evident:

Summary

Architecting performant React Native app is not always straightforward. You should normally start with the naive approach, but if you begin to notice performance issues with your app, always consider the number of passes over the bridge.

As the framework matures, common tasks that are prone to high bridge traffic are addressed with declarative API that is designed to reduce passes over the bridge. The Animated library is a good example. Another interesting example is react-native-interactable which I’ve demonstrated in ReactConf 2017. To learn more about it, check out the post “Moving Beyond Animations to User Interactions at 60 FPS in React Native”.

If you hit a wall and can’t find a declarative API that resolves your performance issue, you can always fall back on a native implementation. Bring a native developer or two into your team and port the problematic area to native like we did in the second implementation. It’s not always pretty, but it works.

Tags