Повторное использование ранее размещенных в памяти строк, которые при прокрутке выходят за пределы экрана, ? широко распространенная техника оптимизации использования компонента ListView, изначально реализованная в iOS и Android. Реализация ListView как компонента React Native по умолчанию не содержит непосредственно эту оптимизацию, но имеет ряд других приятных преимуществ. Тем не менее, это отличный образец, достойный изучения. Рассмотрение этой реализации в рамках изучения React также будет интересным мысленным экспериментом.

Списки являются важной частью разработки мобильных приложений


Списки – это сердце и душа мобильных приложений. Множество приложений отображают списки: это и список публикаций в вашей ленте приложения Facebook, и списки бесед в Messenger, и список сообщений электронной почты Gmail, и список фотографий в Instagram, и список твитов в Twitter и т.д.

Когда ваши списки становятся более сложными, со значительным количеством источников данных, тысячами строк, медиа-файлами, требующими большого объема памяти, их разработка также становится сложнее.

С одной стороны, вы хотите сохранить скорость работы вашего приложения, т.к. прокручивание со скоростью 60 FPS стало золотым стандартом нативного опыта взаимодействия (UX). С другой стороны, вы хотите сохранить низкое потребление памяти, потому что мобильные устройства не располагают избыточными ресурсами. Не всегда просто выполнить оба эти условия.



Поиск идеальной реализации элемента ListView


Основополагающим правилом разработки программного обеспечение является то, что нельзя предусмотреть оптимизацию для любого сценария. Рассмотрим пример из другой области: не существует базы данных, идеально подходящей для хранения любых данных. Возможно, вы знакомы с базами данных SQL, которые отлично подходят для некоторых вариантов использования, и с базами данных NoSQL, которые оптимальны для других ситуаций. Вы вряд ли будете разрабатывать собственную базу данных, поэтому, как разработчику программного обеспечения, вам необходимо выбрать подходящее средство для решения конкретной задачи.

То же правило применимо к представлению списков: вы вряд ли сможете найти способ реализации представления списков, который бы не только подходил для любого варианта использования, но и сохранял при этом высокую скорость FPS и низкую потребность в объеме памяти.

Грубо говоря, можно выделить два типа вариантов использования списков в мобильном приложении:

• Практически одинаковые строки с очень большим источником данных. Строки списка контактов выглядят одинаково и имеют одинаковую структуру. Мы хотим, чтобы пользователи могли быстро просматривать строки до тех пор, пока не найдут то, что ищут. Пример: адресная книга.
• Сильно отличающиеся строки и небольшой источник данных. Здесь все строки разные и содержат разный объем текста. Некоторые содержат медиа. В большинстве случаев пользователи будут читать сообщения последовательно, а не просматривать весь поток. Пример: сообщения в чате.
Преимущество разделения на разные варианты использования состоит в том, что можно предложить разные техники оптимизации для каждого варианта.

Готовое представление списков React Native


К React Native прилагается отличная готовая реализация ListView. Она содержит некоторые очень разумные оптимизации, такие как «ленивая загрузка» рядов, появляющихся на экране при прокрутке, сокращающая число перерисовок до минимума, и прорисовка строк в разных событийных циклах.

Другое интересное свойство готовой реализации ListView ? это то, что она полностью реализована на JavaScript над нативным компонентом ScrollView, который входит в React Native. Если вы имели опыт разработки для iOS или Android, этот факт может показаться странным. В основе их нативного комплекта средств разработки (SDK) лежат проверенные временем реализации представлений списков — UITableView для iOS и ListView для Android. Примечательно, что ни одну из них команда React Native решила не использовать.

Возможно, на это есть разные причины, но я предполагаю, что это связано с ранее определенными вариантами использования. UITableView для iOS и ListView для Android используют похожие техники оптимизации, отлично работающие для первого варианта использования ? для списков с практически одинаковыми строками и очень большим источником данных. Готовое представление ListView React Native оптимизировано для второго варианта.

Главным списком в экосистеме Facebook является лента публикаций Facebook. Приложение Facebook было реализовано в iOS и Android задолго до появления React Native. Возможно, изначально реализация ленты действительно основывалась на нативных реализациях UITableView в iOS и ListView в Android, и как вы можете себе представить, работала не так хорошо, как ожидалось. Лента является классическим примером второго варианта использования. Строки сильно отличаются, т.к. все публикации разные ? они отличаются объемом контента, содержат разные типы медиа-файлов и имеют разную структуру. Пользователи последовательно читают публикации в ленте и обычно не прокручивают сотни строк за один раз.

Так почему бы нам не рассмотреть повторное использование?


Если второй вариант использования — списки с сильно отличающимися строками и небольшим источником данных – подходит именно к вашему случаю, то стоит подумать о выборе готовой реализации ListView. Если ваш случай описывается первым вариантом использования, и вы не удовлетворены работой готовой реализации, мы бы порекомендовали поэкспериментировать с альтернативными вариантами.

Напоминаю, первый вариант использования ? это списки с практически одинаковыми строками и очень большим источником данных. Для этого сценария основной техникой оптимизации, доказавшей свою эффективность, является повторное использование строк.
Так как наш источник данных потенциально очень большой, очевидно, что мы не можем хранить все строки в памяти одновременно. Чтобы минимизировать потребляемую память, мы будем хранить только те строки, которые в настоящий момент отображаются на экране. Строки, которые в результате прокручивания уже не видны, будут освобождаться, а новые строки, которые становятся видимыми, будут размещаться в памяти.

Однако для постоянного освобождения и размещения в памяти строк при прокручивании требуется очень интенсивная работа процессора. Используя этот нативный подход, мы можем не достигнуть желаемой скорости в 60 FPS. К счастью, в данном варианте использования строки практически одинаковые. Это означает, что вместо освобождения строки, прокрученной за пределы экрана, мы можем сделать из нее новую строку, просто заменив отображаемые в ней данные на данные из новой строки, тем самым избегая новых размещений в памяти.

Перейдем к практической части. Подготовим пример для того, чтобы поэкспериментировать с этим вариантом использования. Пример будет содержать 3 000 строк данных одинаковой структуры:

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<ROWS_IN_DATA_SOURCE; i++) dataSource.push(`This is the data for row # ${i+1}`);
	

	export default class RecyclingExample extends Component {
	 render() {
	 return (
	 <View style={{flex: 1, paddingTop: 20,}}>
	 <RecyclingListView
	 renderRow={this.renderRow}
	 numRows={dataSource.length}
	 rowHeight={50}
	 />
	 </View>
	 );
	 }
	 renderRow(rowID) {
	 return (
	 <Text style={{
	 width: Dimensions.get('window').width,
	 height: 50,
	 backgroundColor: '#ffffff'
	 }}>{dataSource[rowID]}</Text>
	 );
view rawRecyclingExample.js hosted with   by GitHub
	 }
	}

Использование нативной реализации UITableView


Как отмечалось ранее, в нативных SDK для iOS и Android существуют надежные реализации, выполняющие перезаписывание строк. Сосредоточимся на iOS и будем использовать UITableView.

Вы можете удивиться, почему мы не пытаемся реализовать эту технику полностью на JavaScript. Это интересный вопрос, который заслуживает подробного описания в нескольких отдельных записях в блоге. Однако, если коротко, то для того, чтобы перезаписывать строки должным образом, мы должны всегда знать текущее смещение прокрутки, так как при прокрутке строки должны перезаписываться. События прокрутки возникают в нативной зоне, и, чтобы уменьшить количество переходов через RN-мост, имеет смысл отслеживать их в ней же.

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

Сама обертка будет выполняться в RNTableView.m, и в основном будет заниматься передачей свойств и использованием их в нужных местах. Нет необходимости вдаваться в подробности следующей реализации, так как в ней все еще отсутствуют некоторые интересные части.

#import "RNTableView.h"
	#import "RCTConvert.h"
	#import "RCTEventDispatcher.h"
	#import "RCTUtils.h"
	#import "UIView+React.h"
	

	@interface RNTableView()<UITableViewDataSource, UITableViewDelegate>
	@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

Ключевая концепция — соединение нативной среды и JS


Мы хотим, чтобы наши строки были компонентами React, определенными в JavaScript, поскольку в этом заключается вся бизнес-логика. Но также мы хотим, чтобы они легко настраивались. Так как настоящая логика перезаписи работает в нативной среде, нам необходимо каким-то образом «передавать» в нее эти компоненты из JS.

Лучше всего передавать компоненты React как дочерние в наш нативный компонент. Используя нативный компонент из JS, добавляя строки в JSX в качестве дочерних компонентов, мы заставляем React Native преобразовать их в представления UIView, которые будут представлены нативному компоненту.

Фокус в том, что не нужно создавать компоненты из всех строк источника данных. Так как наша основная цель — это повторное использование строк, то для отображения на экране потребуется лишь небольшое количество. Предположим, что на экране одновременно отображаются 20 строк. Это значение можно получить, если разделить высоту экрана (736 логических точек для iPhone 6 Plus) на высоту каждой строки (в данном случае 50), получить приблизительное значение 15, а затем добавить еще несколько дополнительных строк.

Когда эти 20 строк передаются нашему компоненту в качестве дочерних элементов subview для инициализации, они еще не отображаются. Мы их удерживаем в банке «неиспользуемых ячеек».

Далее следует самое интересное. Нативная перезапись в UITableView работает с помощью метода «dequeueReusableCell». Если ячейку можно перезаписать (из строки, не отображающейся на экране), с помощью этого метода перезаписанную ячейку также можно вернуть. Если ячейка не может быть перезаписана, наш код должен будет разместить в памяти новую. Размещение новых ячеек происходит только в начале, до того, как мы заполним экран видимыми строками. Итак, как разместить в памяти новую ячейку? Мы просто возьмем одну из неиспользуемых ячеек в нашем банке:

	- (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;
	}

Последним элементом нашего паззла станет заполнение новой перезаписанной/созданной ячейки данными из источника данных. Так как наши ряды являются компонентами React, переведем этот процесс в терминологию React: необходимо присвоить компоненту строки новые свойства на основе правильной строки из источника данных, которую мы хотим отобразить.

Так как изменение свойств происходит в среде JS, нам необходимо сделать это непосредственно в JavaScript. Это означает, что необходимо вернуть привязку для одного из наших рядов. Мы можем сделать это, передав событие из нативной среды в JS:

Это полная реализация функции, содержит все недостающие части.
	- (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;
	}

Соединим все вместе


Далее, для итоговой реализации RecyclingListView.js нам нужна обвязка нашего нативного компонента в 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<ROWS_FOR_RECYCLING; i++) binding.push(-1);
	 this.state = {
	 binding: binding // childIndex -> rowID
	 };
	 }
	 render() {
	 const bodyComponents = [];
	 for (let i=0; i<ROWS_FOR_RECYCLING; i++) {
	 bodyComponents.push(
	 <ReboundRenderer
	 key={'r_' + i}
	 boundTo={this.state.binding[i]}
	 render={this.props.renderRow}
	 />
	 );
	 }
	 return (
	 <View style={{flex: 1}}>
	 <RNTableView
	 style={{flex: 1}}
	 onChange={this.onBind.bind(this)}
	 rowHeight={this.props.rowHeight}
	 numRows={this.props.numRows}
	 >
	 {bodyComponents}
	 </RNTableView>
	 </View>
	 );
	 }
	 onBind(event) {
	 const {target, childIndex, rowID, sectionID} = event.nativeEvent;
	 this.state.binding[childIndex] = rowID;
	 this.setState({
	 binding: this.state.binding
	 });
	 }
	}

Еще одна оптимизация, которую мы хотим добавить ? это минимизация количества перерисовок. Т.е. мы хотим, чтобы строка перерисовывалась только при ее перезаписи и изменении привязки.

Для этого нам понадобится ReboundRenderer. В качестве параметра этого простого JS-компонента принимается индекс строки источника данных, к которому этот компонент привязан в данный момент (параметр «boundTo»). Он перерисовывается только при смене привязки (используя стандартную оптимизацию 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<any> {
	 console.log('ReboundRenderer render() boundTo=' + this.props.boundTo);
	 return this.props.render(this.props.boundTo);
	 },
	});
	

	module.exports = ReboundRenderer;

Полностью рабочий пример, содержащий по большей части приведенный здесь код, можно найти в этом хранилище.
Хранилище также содержит описания нескольких других экспериментов, которые могут вас заинтересовать. Эксперимент tableview-children.ios.js также относится к данному случаю.

Тал Кол – full-stack-разработчик, специализирующийся на разработке нативных мобильных приложений для iOS и Android. React Native ? его новое увлечение. Тал был соучредителем двух технологических компаний, одна из них теперь принадлежит платформе для создания сайтов Wix.com.

Оригинал статьи: блог инженеров компании Wix.
Поделиться с друзьями
-->

Комментарии (2)


  1. symbix
    02.08.2016 17:57

    Не по теме, но…
    Вы б убрали div class=«clr» с картинки, стыд же! :)


  1. raveclassic
    03.08.2016 10:32
    +1

    Пожалуйста, исправьте форматирование кода!
    Вроде, статья-то интересная, но, глядя на «это», всякое желание вникать отпадает намертво.