airplanes take off against the wind
ListView performance in React Native is one of my favorite open issues. Significant progress is being made, but this problem does not seem to have a single one-stop solution that can handle every use-case thrown at it. One thing that keeps holding us back is the asynchronicity of the React Native bridge. Is it possible to eliminate it from the equation?
I’m on my flight back from ReactConf 2017. Spending 3 full days with many giants of our industry has been one of the most inspiring experiences I’ve ever had. I finally got to place real faces on all the profiles I follow on Twitter!
I’ve always been intrigued by the challenge of ListViews in React Native. And what do you know.. I’m in the room with 3 of the most influential people in this space: Brent Vatne — one of the leaders of Expo and a driving force behind the React Native community. Spencer Ahrens — from the Facebook React Native core team, who recently released FlatList. And Brian Vaughn — the person behind React Virtualized, the best implementation I’ve seen for this problem for the web.
There are many use-cases for ListViews, and different use-cases require different optimizations. Current implementations in React Native deal well with complex lists having a wide variety of cells, that are scrolled without jumping around too much. Compare the Facebook Feed to your phone’s Contact List.
When a user has a few hundreds or thousands of contacts and is browsing in attempt to find a specific one, they use the list quite differently. The scroll pattern would be more erratic, with large leaps and faster swipes . If we borrow from another world, you could say the user is trying to do random access instead of sequential :)
This is also where React Native has the most difficulty. Scroll takes place in the native realm, but rendering of the rows takes place asynchronously in the JavaScript realm. Data between them is queued on the bridge. If you scroll fast enough, render requests will eventually wait in line, resulting in blank spaces midst scroll.
Fiber is believed by many to be promising — with the ability to control render priority and cancel pending renders that are no longer needed. It is definitely interesting, but we’re going to experiment with something else entirely.
We’re going to try to cut the bridge out of the equation altogether. We’ve taken this approach before when dealing with other performance problems. Animations can finally run at 60 FPS using Animated’s native driver that minimizes bridge traffic… I’ve just shown a cool approach in ReactConf for doing user interactions at 60 FPS with a declarative physics library… We know that declarative API can be a powerful weapon.
If there’s no bridge, there are also no renders from JavaScript while we’re scrolling. This means we’ll have to rely entirely on recycling old rows. This is a standard approach in native ListViews like the iOS UITableView. Luckily, the Contact List scenario is also perfect for recycling — the rows are nearly identical. We can use this for our advantage.
Loyal followers among you may remember that I’ve already played with this approach about 9 months ago (see Recycling Rows For High Performance React Native List Views). The problem with the previous attempt was that we still relied on React and JavaScript to reconcile row content updates.
Simple. We’re just going to update rows without React.
If you look closely in the React Native docs, this ability was actually documented a while ago under the name “Direct Manipulation”. This API has never found its place and pretty much disappeared from the world. But digging through the old NativeMethodsMixin code can shine some light on how this can be done.
First, in our library, we’re going to use React to render a pool of rows that is just enough to cover one screen-fold. This will only happen during initialization so there’s no impact on scroll performance. Using React at this point will provide the developer with the flexibility to define the layout of the row template with JSX.
Next, we’re going to ask the developer to define the row template in JSX and declare explicitly how data from the data source will bind into it. This is where the declarative API comes into play:
If you look closely at the example above, you’ll notice we’re using TextInput components to display strings instead of using Text. There’s a reason behind this that we’ll go into a bit later (it will be resolved, no worries).
Our declarative API uses ref to define how various field IDs from the template bind into props of specific components. In this case, we bind the string template fields to the “text” prop of TextInput.
The next thing we’re going to do is pass the entire data source to the native realm. This may sound scary at first but will actually make very little impact on performance. Even with 5000 contacts, the total amount of data in the data source is small. Every contact has a couple of strings attached to it. The bridge has very high throughput, so sending it all at once during initialization will not make a big impact:
Next, we’ll ask the user to provide another declaration. This time of how fields from the data source map to fields IDs in our template. We’ll add this as another prop given to BindingListView:
As you remember, when we defined the data source above, each row in the data source had two fields: “initials” and “name”.
Time to switch over to the native side. We’re going to work on iOS and use Objective-C. Since we need a recycling native ListView, we will simply rely on UITableView directly. It’s part of the native iOS SDK and it provides native view recycling out of the box.
This is the API used to implement the recycling:
Whenever UITableView could not recycle (this will only happen on the first screen-fold), we need to provide it with a new view. We are taking this view from the pool we’ve created in advance.
How do we populate the pool? This is actually a cool trick. The JSX row pool we’ve defined during initialization is provided to our ListView as React children. This will give us a convenient hook point in native where we can “steal” the views into the pool instead of actually adding them as subviews:
Remember that before, when we couldn’t recycle a view and had to allocate one, we’ve taken one of the unused cells waiting in the pool.
And now.. time for the main act. How do we bind the data source data to the native view from native?
The secret is synchronouslyUpdateViewOnUIThread — a native API for direct manipulation which is part of the React Native UIManager.
The problem with synchronouslyUpdateViewOnUIThread is that it only works with props. With a TextInput, the text in the component is supplied as a prop and not as a child. This makes it much easier to use.
With Text components, the raw text is a child node, which complicates everything a whole lot. The template declaration in this case is still simple and is very similar to before:
Notice that the prop name we use here is now “children”.
Now the big problem, how will we support binding to a raw text child from native? I actually spent quite a long time trying to figure this out. This is the best solution I’ve found so far (dispatch_async):
This is not perfect because we’re relying on a different native thread in the process (the UIManager shadow queue). It’s still much more efficient than going over the bridge, but still, I’ll be happy if we’ll do everything eventually from the main thread only. If you think of a way to do this from the main thread — let me know!
My flight is almost over.. That was a great way to spend it :)
A full working example connecting all the code snippets above is available as usual on GitHub:
wix/BindingListView_BindingListView - React Native ListView experimental implementation supporting direct view binding_github.com
The repo contains an example Contact List with 5000 items that you can run on your phone. Tip: run it on a real device and not on a simulator. I’m confident you’ll agree that the scroll performance is among the best you’ve seen, while keeping memory consumption constant and minimal. I believe that this example is the closest we’ll get to pure-native scroll performance with React Native.
It’s definitely good enough in terms of user experience and has zero blank spaces even when you scroll really fast or scroll to top with a press on the status bar.
Here’s a breakdown of the main files in the repo:
I hope this approach will drive more research in this direction.
If you’re interested in ListView performance and looking for a challenge — try to think how to add support for variable height cells. Maybe we could drive yoga (the native layout engine) directly by ourselves to update layout during the bind process.