Reusing Strings for High Performance React Native ListViews
- Tutorial
Reusing previously stored lines in the memory that go beyond the screen when scrolling is a widespread technique for optimizing the use of the ListView component, originally implemented in iOS and Android. The implementation of ListView as a component of React Native by default does not directly contain this optimization, but it has a number of other pleasant advantages. However, this is a great example worth exploring. Considering this implementation as part of a React study will also be an interesting thought experiment.
Lists are the heart and soul of mobile apps. Many applications display lists: this is a list of publications in your Facebook application feed, and chat lists in Messenger, and a list of Gmail emails, and a list of photos on Instagram, and a list of tweets on Twitter, etc.
When your lists become more complex, with a significant number of data sources, thousands of lines, media files requiring a large amount of memory, their development also becomes more difficult.
On the one hand, you want to maintain the speed of your application, because 60 FPS scrolling has become the gold standard for native interaction experience (UX). On the other hand, you want to keep low memory consumption, because mobile devices do not have excessive resources. It is not always easy to fulfill both of these conditions.
The fundamental rule of software development is that optimization cannot be foreseen for any scenario. Let's look at an example from another area: there is no database ideally suited for storing any data. You may be familiar with SQL databases that are great for some use cases, and with NoSQL databases that are optimal for other situations. You are unlikely to develop your own database, therefore, as a software developer, you need to choose the appropriate tool for solving a specific problem.
The same rule applies to the presentation of lists: you are unlikely to find a way to implement the presentation of lists that would not only be suitable for any use case, but at the same time maintain a high FPS speed and low memory requirement.
Roughly speaking, there are two types of options for using lists in a mobile application:
• Almost the same lines with a very large data source. Contact list lines look the same and have the same structure. We want users to be able to quickly browse strings until they find what they are looking for. Example: Address Book.
• Very different rows and a small data source. Here, all lines are different and contain different amounts of text. Some contain media. In most cases, users will read messages sequentially rather than view the entire stream. Example: chat messages.
The advantage of dividing into different use cases is that you can offer different optimization techniques for each option.
React Native comes with a great off-the-shelf ListView implementation. It contains some very reasonable optimizations, such as “lazy loading” of the rows that appear on the screen when scrolling, reducing the number of redraws to a minimum, and drawing lines in different event cycles.
Another interesting property of the finished implementation of ListView is that it is fully implemented in JavaScript over the native ScrollView component, which is part of React Native. If you had development experience for iOS or Android, this fact may seem strange. Their native development kit (SDK) is based on time-tested implementation of list views - UITableView for iOS and ListView for Android. It is noteworthy that none of them the React Native team decided not to use.
There may be various reasons for this, but I assume that this is due to previously defined use cases. UITableView for iOS and ListView for Android use similar optimization techniques that work perfectly for the first use case - for lists with almost identical rows and a very large data source. The finished ListView React Native is optimized for the second option.
The main list in the Facebook ecosystem is the Facebook post feed. The Facebook application was implemented in iOS and Android long before the advent of React Native. Perhaps the original implementation of the tape was really based on native implementations of UITableView on iOS and ListView on Android, and as you can imagine, it did not work as well as expected. The tape is a classic example of a second use case. The lines are very different, because all publications are different - they differ in the volume of content, contain different types of media files and have a different structure. Users sequentially read publications in the feed and usually don’t scroll through hundreds of lines at a time.
If the second use case - lists with very different strings and a small data source - is right for your case, then you should consider choosing a ready-made ListView implementation. If your case is described by the first use case, and you are not satisfied with the work of the finished implementation, we would recommend experimenting with alternative options.
I remind you that the first use case is lists with almost identical rows and a very large data source. For this scenario, the main optimization technique that has proven to be effective is string reuse.
Since our data source is potentially very large, it is obvious that we cannot store all rows in memory at the same time. To minimize memory consumption, we will store only those lines that are currently displayed on the screen. Lines that are no longer visible as a result of scrolling will be freed, and new lines that become visible will be placed in memory.
However, to constantly free and store lines in memory during scrolling, very intensive processor work is required. Using this native approach, we may not achieve the desired speed of 60 FPS. Fortunately, in this use case, the strings are almost the same. This means that instead of freeing a line scrolled off the screen, we can make a new line out of it by simply replacing the data displayed in it with data from a new line, thereby avoiding new memory locations.
Let's move on to the practical part. Let's prepare an example in order to experiment with this use case. The example will contain 3,000 rows of data of the same structure:
As noted earlier, native SDKs for iOS and Android have robust implementations that perform line rewriting. Focus on iOS and use UITableView.
You may wonder why we are not trying to implement this technique entirely in JavaScript. This is an interesting question that deserves a detailed description in several separate blog entries. However, in short, in order to overwrite the lines properly, we should always know the current scroll offset, since when scrolling the lines should be overwritten. Scrolling events occur in the native zone, and to reduce the number of transitions through the RN-bridge , it makes sense to track them in it.
The wrapper itself will be executed in RNTableView.m, and will mainly deal with the transfer of properties and their use in the right places. There is no need to go into the details of the next implementation, since it still lacks some interesting parts.
We want our lines to be React components defined in JavaScript, because that is the whole business logic. But also we want them to be easy to configure. Since the real rewrite logic works in the native environment, we need to somehow “transfer” these components from JS to it.
It is best to pass React components as children into our native component. Using the native component from JS, adding strings to JSX as child components, we force React Native to convert them to UIView views that will be presented to the native component.
The trick is that you don’t need to create components from all rows of the data source. Since our main goal is to reuse strings, only a small amount is needed to display on the screen. Assume that 20 lines are displayed simultaneously on the screen. This value can be obtained by dividing the screen height (736 logical points for the iPhone 6 Plus) by the height of each line (in this case 50), getting an approximate value of 15, and then adding a few additional lines.
When these 20 lines are passed to our component as children of the subview for initialization, they are not yet displayed. We keep them in the bank of “unused cells”.
The following is the most interesting. Native rewriting in UITableView works using the "dequeueReusableCell" method. If a cell can be overwritten (from a line that does not appear on the screen), using this method, a rewritten cell can also be returned. If the cell cannot be overwritten, our code will have to place a new one in memory. The placement of new cells occurs only at the beginning, before we fill the screen with visible lines. So how to place a new cell in memory? We just take one of the unused cells in our bank:
The last element of our puzzle will be filling in a new overwritten / created cell with data from the data source. Since our series are React components, we will translate this process into React terminology: you need to assign the component of the row new properties based on the correct row from the data source that we want to display.
Since property changes occur in the JS environment, we need to do this directly in JavaScript. This means that you need to return the binding for one of our series. We can do this by passing the event from the native environment to JS:
This is a complete implementation of the function, it contains all the missing parts.
Further, for the final implementation of RecyclingListView.js, we need the binding of our native component in JavaScript:
Another optimization we want to add is to minimize the number of redraws. Those. we want the line to be redrawn only when it is overwritten and the binding is changed.
For this we need a ReboundRenderer. As a parameter of this simple JS-component, the row index of the data source to which this component is currently bound is taken (parameter “boundTo”). It redraws only when changing the binding (using standard optimization shouldComponentUpdate):
A fully working example, containing for the most part the code given here, can be found in this repository .
The repository also contains descriptions of several other experiments that may interest you. The tableview-children.ios.js experiment also applies to this case.
Tal Kol is a full-stack developer specializing in developing native mobile apps for iOS and Android. React Native is his new hobby. Tal was a co-founder of two technology companies, one of which now belongs to the platform for creating Wix.com sites .
Original article: Wix engineers blog .
Lists are an important part of mobile app development
Lists are the heart and soul of mobile apps. Many applications display lists: this is a list of publications in your Facebook application feed, and chat lists in Messenger, and a list of Gmail emails, and a list of photos on Instagram, and a list of tweets on Twitter, etc.
When your lists become more complex, with a significant number of data sources, thousands of lines, media files requiring a large amount of memory, their development also becomes more difficult.
On the one hand, you want to maintain the speed of your application, because 60 FPS scrolling has become the gold standard for native interaction experience (UX). On the other hand, you want to keep low memory consumption, because mobile devices do not have excessive resources. It is not always easy to fulfill both of these conditions.
Finding the perfect implementation of a ListView
The fundamental rule of software development is that optimization cannot be foreseen for any scenario. Let's look at an example from another area: there is no database ideally suited for storing any data. You may be familiar with SQL databases that are great for some use cases, and with NoSQL databases that are optimal for other situations. You are unlikely to develop your own database, therefore, as a software developer, you need to choose the appropriate tool for solving a specific problem.
The same rule applies to the presentation of lists: you are unlikely to find a way to implement the presentation of lists that would not only be suitable for any use case, but at the same time maintain a high FPS speed and low memory requirement.
Roughly speaking, there are two types of options for using lists in a mobile application:
• Almost the same lines with a very large data source. Contact list lines look the same and have the same structure. We want users to be able to quickly browse strings until they find what they are looking for. Example: Address Book.
• Very different rows and a small data source. Here, all lines are different and contain different amounts of text. Some contain media. In most cases, users will read messages sequentially rather than view the entire stream. Example: chat messages.
The advantage of dividing into different use cases is that you can offer different optimization techniques for each option.
Ready-made React Native Lists
React Native comes with a great off-the-shelf ListView implementation. It contains some very reasonable optimizations, such as “lazy loading” of the rows that appear on the screen when scrolling, reducing the number of redraws to a minimum, and drawing lines in different event cycles.
Another interesting property of the finished implementation of ListView is that it is fully implemented in JavaScript over the native ScrollView component, which is part of React Native. If you had development experience for iOS or Android, this fact may seem strange. Their native development kit (SDK) is based on time-tested implementation of list views - UITableView for iOS and ListView for Android. It is noteworthy that none of them the React Native team decided not to use.
There may be various reasons for this, but I assume that this is due to previously defined use cases. UITableView for iOS and ListView for Android use similar optimization techniques that work perfectly for the first use case - for lists with almost identical rows and a very large data source. The finished ListView React Native is optimized for the second option.
The main list in the Facebook ecosystem is the Facebook post feed. The Facebook application was implemented in iOS and Android long before the advent of React Native. Perhaps the original implementation of the tape was really based on native implementations of UITableView on iOS and ListView on Android, and as you can imagine, it did not work as well as expected. The tape is a classic example of a second use case. The lines are very different, because all publications are different - they differ in the volume of content, contain different types of media files and have a different structure. Users sequentially read publications in the feed and usually don’t scroll through hundreds of lines at a time.
So why don't we consider reuse?
If the second use case - lists with very different strings and a small data source - is right for your case, then you should consider choosing a ready-made ListView implementation. If your case is described by the first use case, and you are not satisfied with the work of the finished implementation, we would recommend experimenting with alternative options.
I remind you that the first use case is lists with almost identical rows and a very large data source. For this scenario, the main optimization technique that has proven to be effective is string reuse.
Since our data source is potentially very large, it is obvious that we cannot store all rows in memory at the same time. To minimize memory consumption, we will store only those lines that are currently displayed on the screen. Lines that are no longer visible as a result of scrolling will be freed, and new lines that become visible will be placed in memory.
However, to constantly free and store lines in memory during scrolling, very intensive processor work is required. Using this native approach, we may not achieve the desired speed of 60 FPS. Fortunately, in this use case, the strings are almost the same. This means that instead of freeing a line scrolled off the screen, we can make a new line out of it by simply replacing the data displayed in it with data from a new line, thereby avoiding new memory locations.
Let's move on to the practical part. Let's prepare an example in order to experiment with this use case. The example will contain 3,000 rows of data of the same structure:
import React, { Component } from 'react';
import { Text, View, Dimensions } from 'react-native';
import RecyclingListView from './RecyclingListView';
const ROWS_IN_DATA_SOURCE = 3000;
const dataSource = [];
for (let i=0; i
);
}
renderRow(rowID) {
return (
{dataSource[rowID]}
);
view rawRecyclingExample.js hosted with by GitHub
}
}
Using the native implementation of UITableView
As noted earlier, native SDKs for iOS and Android have robust implementations that perform line rewriting. Focus on iOS and use UITableView.
You may wonder why we are not trying to implement this technique entirely in JavaScript. This is an interesting question that deserves a detailed description in several separate blog entries. However, in short, in order to overwrite the lines properly, we should always know the current scroll offset, since when scrolling the lines should be overwritten. Scrolling events occur in the native zone, and to reduce the number of transitions through the RN-bridge , it makes sense to track them in it.
Objective-C:
#import "RNTableViewManager.h"
#import "RNTableView.h"
@implementation RNTableViewManager
RCT_EXPORT_MODULE()
- (UIView *)view
{
return [[RNTableView alloc] initWithBridge:self.bridge];
}
RCT_EXPORT_VIEW_PROPERTY(rowHeight, float)
RCT_EXPORT_VIEW_PROPERTY(numRows, NSInteger)
@end
The wrapper itself will be executed in RNTableView.m, and will mainly deal with the transfer of properties and their use in the right places. There is no need to go into the details of the next implementation, since it still lacks some interesting parts.
#import "RNTableView.h"
#import "RCTConvert.h"
#import "RCTEventDispatcher.h"
#import "RCTUtils.h"
#import "UIView+React.h"
@interface RNTableView()
@property (strong, nonatomic) UITableView *tableView;
@end
@implementation RNTableView
RCTBridge *_bridge;
RCTEventDispatcher *_eventDispatcher;
NSMutableArray *_unusedCells;
- (instancetype)initWithBridge:(RCTBridge *)bridge {
RCTAssertParam(bridge);
if ((self = [super initWithFrame:CGRectZero])) {
_eventDispatcher = bridge.eventDispatcher;
_bridge = bridge;
while ([_bridge respondsToSelector:NSSelectorFromString(@"parentBridge")] && [_bridge valueForKey:@"parentBridge"]) {
_bridge = [_bridge valueForKey:@"parentBridge"];
}
_unusedCells = [NSMutableArray array];
[self createTableView];
}
return self;
}
RCT_NOT_IMPLEMENTED(-initWithFrame:(CGRect)frame)
RCT_NOT_IMPLEMENTED(-initWithCoder:(NSCoder *)aDecoder)
- (void)layoutSubviews {
[self.tableView setFrame:self.frame];
}
- (void)createTableView {
_tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStylePlain];
_tableView.dataSource = self;
_tableView.delegate = self;
_tableView.backgroundColor = [UIColor whiteColor];
[self addSubview:_tableView];
}
- (void)setRowHeight:(float)rowHeight {
_tableView.estimatedRowHeight = rowHeight;
_rowHeight = rowHeight;
}
- (NSInteger)numberOfSectionsInTableView:(UITableView *)theTableView {
return 1;
}
- (NSInteger)tableView:(UITableView *)theTableView numberOfRowsInSection:(NSInteger)section {
return self.numRows;
}
-(CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
return self.rowHeight;
}
// здесь все еще отсутствуют интересные части
@end
Key Concept - Connecting Native Environment and JS
We want our lines to be React components defined in JavaScript, because that is the whole business logic. But also we want them to be easy to configure. Since the real rewrite logic works in the native environment, we need to somehow “transfer” these components from JS to it.
It is best to pass React components as children into our native component. Using the native component from JS, adding strings to JSX as child components, we force React Native to convert them to UIView views that will be presented to the native component.
The trick is that you don’t need to create components from all rows of the data source. Since our main goal is to reuse strings, only a small amount is needed to display on the screen. Assume that 20 lines are displayed simultaneously on the screen. This value can be obtained by dividing the screen height (736 logical points for the iPhone 6 Plus) by the height of each line (in this case 50), getting an approximate value of 15, and then adding a few additional lines.
When these 20 lines are passed to our component as children of the subview for initialization, they are not yet displayed. We keep them in the bank of “unused cells”.
The following is the most interesting. Native rewriting in UITableView works using the "dequeueReusableCell" method. If a cell can be overwritten (from a line that does not appear on the screen), using this method, a rewritten cell can also be returned. If the cell cannot be overwritten, our code will have to place a new one in memory. The placement of new cells occurs only at the beginning, before we fill the screen with visible lines. So how to place a new cell in memory? We just take one of the unused cells in our bank:
- (void)insertReactSubview:(UIView *)subview atIndex:(NSInteger)atIndex {
// пока не будем добавлять их как элементы subview, потому что нам не нужно их рисовать
// [super insertSubview:subview atIndex:atIndex];
[_unusedCells addObject:subview];
}
- (UIView*) getUnusedCell {
UIView* res = [_unusedCells lastObject];
[_unusedCells removeLastObject];
if (res != nil) {
res.tag = [_unusedCells count];
}
return res;
}
- (UITableViewCell *)tableView:(UITableView *)theTableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
static NSString *cellIdentifier = @"CustomCell";
TableViewCell *cell = (TableViewCell *)[theTableView dequeueReusableCellWithIdentifier:cellIdentifier];
if (cell == nil) {
cell = [[TableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:cellIdentifier];
cell.cellView = [self getUnusedCell];
NSLog(@"Allocated childIndex %d for row %d", (int)cell.cellView.tag, (int)indexPath.row);
} else {
NSLog(@"Recycled childIndex %d for row %d", (int)cell.cellView.tag, (int)indexPath.row);
}
// здесь все еще отсутствует интересная часть…
return cell;
}
The last element of our puzzle will be filling in a new overwritten / created cell with data from the data source. Since our series are React components, we will translate this process into React terminology: you need to assign the component of the row new properties based on the correct row from the data source that we want to display.
Since property changes occur in the JS environment, we need to do this directly in JavaScript. This means that you need to return the binding for one of our series. We can do this by passing the event from the native environment to JS:
This is a complete implementation of the function, it contains all the missing parts.
- (UITableViewCell *)tableView:(UITableView *)theTableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
static NSString *cellIdentifier = @"CustomCell";
TableViewCell *cell = (TableViewCell *)[theTableView dequeueReusableCellWithIdentifier:cellIdentifier];
if (cell == nil) {
cell = [[TableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:cellIdentifier];
cell.cellView = [self getUnusedCell];
NSLog(@"Allocated childIndex %d for row %d", (int)cell.cellView.tag, (int)indexPath.row);
} else {
NSLog(@"Recycled childIndex %d for row %d", (int)cell.cellView.tag, (int)indexPath.row);
}
// мы возвращаем событие в JS
NSDictionary *event = @{
@"target": cell.cellView.reactTag,
@"childIndex": @(cell.cellView.tag),
@"rowID": @(indexPath.row),
@"sectionID": @(indexPath.section),
};
[_eventDispatcher sendInputEventWithName:@"onChange" body:event];
return cell;
}
Put it all together
Further, for the final implementation of RecyclingListView.js, we need the binding of our native component in JavaScript:
import React, { Component } from 'react';
import { requireNativeComponent, View } from 'react-native';
import ReboundRenderer from './ReboundRenderer';
const RNTableViewChildren = requireNativeComponent('RNTableViewChildren', null);
const ROWS_FOR_RECYCLING = 20;
export default class RecyclingListView extends Component {
constructor(props) {
super(props);
const binding = [];
for (let i=0; i rowID
};
}
render() {
const bodyComponents = [];
for (let i=0; i
);
}
return (
{bodyComponents}
);
}
onBind(event) {
const {target, childIndex, rowID, sectionID} = event.nativeEvent;
this.state.binding[childIndex] = rowID;
this.setState({
binding: this.state.binding
});
}
}
Another optimization we want to add is to minimize the number of redraws. Those. we want the line to be redrawn only when it is overwritten and the binding is changed.
For this we need a ReboundRenderer. As a parameter of this simple JS-component, the row index of the data source to which this component is currently bound is taken (parameter “boundTo”). It redraws only when changing the binding (using standard optimization shouldComponentUpdate):
var React = require('React');
var ReboundRenderer = React.createClass({
propTypes: {
boundTo: React.PropTypes.number.isRequired,
render: React.PropTypes.func.isRequired,
},
shouldComponentUpdate: function(nextProps): boolean {
return nextProps.boundTo !== this.props.boundTo;
},
render: function(): ReactElement {
console.log('ReboundRenderer render() boundTo=' + this.props.boundTo);
return this.props.render(this.props.boundTo);
},
});
module.exports = ReboundRenderer;
A fully working example, containing for the most part the code given here, can be found in this repository .
The repository also contains descriptions of several other experiments that may interest you. The tableview-children.ios.js experiment also applies to this case.
Tal Kol is a full-stack developer specializing in developing native mobile apps for iOS and Android. React Native is his new hobby. Tal was a co-founder of two technology companies, one of which now belongs to the platform for creating Wix.com sites .
Original article: Wix engineers blog .