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.
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.
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.
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
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
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
0.0. If the scroll position is negative (upwards overscroll), scale is increased from
What performance should we expect from this implementation? The main performance bottleneck is probably the number of passes over the bridge.
On busy apps, this will prevent us from running at 60 FPS. Let’s improve.
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.
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
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
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
0.0. If the scroll position is negative (upwards overscroll), scale is natively increased from
We expect performance to be much better since this implementation is tailored for reducing passes over the bridge.
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.
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.
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
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
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.
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.
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:
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.
Create your free account to unlock your custom reading experience.