adaptive render window throughput

Summary:
Incremental rendering is a tradeoff between throughput and responsiveness because it yields. When we have plenty of
buffer (say 50% of the target), we render incrementally to keep the app responsive. If we are dangerously low on buffer
(say below 25%) we always disable incremental to try to catch up as fast as possible. In between, we only disable
incremental while actively scrolling since it's unlikely the user will try to press a button while scrolling.

This also optimizes some things then incremental is switching back and forth.

I played around with making the render window itself adaptive, but it seems pretty futile to predict - once the user
decides to scroll quickly in some direction, it's pretty much too late and increasing the render window size won't help
because we're already limited by the render throughput at that point.

Reviewed By: ericvicenti

Differential Revision: D3250916

fbshipit-source-id: 930d418522a3bf3e20083e60f6eb6f891497a2b8
This commit is contained in:
Spencer Ahrens 2016-05-18 17:05:50 -07:00 коммит произвёл Facebook Github Bot 2
Родитель 514677525a
Коммит 838d8d4094
2 изменённых файлов: 80 добавлений и 42 удалений

Просмотреть файл

@ -14,6 +14,8 @@
const InteractionManager = require('InteractionManager'); const InteractionManager = require('InteractionManager');
const React = require('React'); const React = require('React');
const infoLog = require('infoLog');
const DEBUG = false; const DEBUG = false;
/** /**
@ -121,12 +123,12 @@ class Incremental extends React.Component<DefaultProps, Props, State> {
} }
getName(): string { getName(): string {
var ctx = this.context.incrementalGroup || {}; const ctx = this.context.incrementalGroup || {};
return ctx.groupId + ':' + this._incrementId + '-' + this.props.name; return ctx.groupId + ':' + this._incrementId + '-' + this.props.name;
} }
componentWillMount() { componentWillMount() {
var ctx = this.context.incrementalGroup; const ctx = this.context.incrementalGroup;
if (!ctx) { if (!ctx) {
return; return;
} }
@ -134,15 +136,15 @@ class Incremental extends React.Component<DefaultProps, Props, State> {
InteractionManager.runAfterInteractions({ InteractionManager.runAfterInteractions({
name: 'Incremental:' + this.getName(), name: 'Incremental:' + this.getName(),
gen: () => new Promise(resolve => { gen: () => new Promise(resolve => {
if (!this._mounted) { if (!this._mounted || this._rendered) {
resolve(); resolve();
return; return;
} }
DEBUG && console.log('set doIncrementalRender for ' + this.getName()); DEBUG && infoLog('set doIncrementalRender for ' + this.getName());
this.setState({doIncrementalRender: true}, resolve); this.setState({doIncrementalRender: true}, resolve);
}), }),
}).then(() => { }).then(() => {
DEBUG && console.log('call onDone for ' + this.getName()); DEBUG && infoLog('call onDone for ' + this.getName());
this._mounted && this.props.onDone && this.props.onDone(); this._mounted && this.props.onDone && this.props.onDone();
}).catch((ex) => { }).catch((ex) => {
ex.message = `Incremental render failed for ${this.getName()}: ${ex.message}`; ex.message = `Incremental render failed for ${this.getName()}: ${ex.message}`;
@ -154,7 +156,7 @@ class Incremental extends React.Component<DefaultProps, Props, State> {
if (this._rendered || // Make sure that once we render once, we stay rendered even if incrementalGroupEnabled gets flipped. if (this._rendered || // Make sure that once we render once, we stay rendered even if incrementalGroupEnabled gets flipped.
!this.context.incrementalGroupEnabled || !this.context.incrementalGroupEnabled ||
this.state.doIncrementalRender) { this.state.doIncrementalRender) {
DEBUG && console.log('render ' + this.getName()); DEBUG && infoLog('render ' + this.getName());
this._rendered = true; this._rendered = true;
return this.props.children; return this.props.children;
} }

Просмотреть файл

@ -43,6 +43,7 @@ const ViewabilityHelper = require('ViewabilityHelper');
const clamp = require('clamp'); const clamp = require('clamp');
const deepDiffer = require('deepDiffer'); const deepDiffer = require('deepDiffer');
const infoLog = require('infoLog');
const invariant = require('invariant'); const invariant = require('invariant');
const nullthrows = require('nullthrows'); const nullthrows = require('nullthrows');
@ -177,13 +178,14 @@ class WindowedListView extends React.Component {
_firstVisible: number = -1; _firstVisible: number = -1;
_lastVisible: number = -1; _lastVisible: number = -1;
_scrollOffsetY: number = 0; _scrollOffsetY: number = 0;
_isScrolling: boolean = false;
_frameHeight: number = 0; _frameHeight: number = 0;
_rowFrames: Array<Object> = []; _rowFrames: Array<Object> = [];
_rowFramesDirty: boolean = false; _rowFramesDirty: boolean = false;
_hasCalledOnEndReached: bool = false; _hasCalledOnEndReached: boolean = false;
_willComputeRowsToRender: bool = false; _willComputeRowsToRender: boolean = false;
_timeoutHandle: number = 0; _timeoutHandle: number = 0;
_incrementPending: bool = false; _incrementPending: boolean = false;
_viewableRows: Array<number> = []; _viewableRows: Array<number> = [];
_cellsInProgress: Set<number> = new Set(); _cellsInProgress: Set<number> = new Set();
_scrollRef: ?Object; _scrollRef: ?Object;
@ -196,7 +198,7 @@ class WindowedListView extends React.Component {
viewablePercentThreshold: 50, viewablePercentThreshold: 50,
renderScrollComponent: (props) => <ScrollView {...props} />, renderScrollComponent: (props) => <ScrollView {...props} />,
disableIncrementalRendering: false, disableIncrementalRendering: false,
recomputeRowsBatchingPeriod: 100, recomputeRowsBatchingPeriod: 10, // This should capture most events that will happen in one frame
}; };
constructor(props: Props) { constructor(props: Props) {
@ -244,8 +246,13 @@ class WindowedListView extends React.Component {
} }
this._computeRowsToRender(newProps); this._computeRowsToRender(newProps);
} }
_onMomentumScrollEnd = (e: Object) => {
this._onScroll(e);
};
_onScroll = (e: Object) => { _onScroll = (e: Object) => {
this._scrollOffsetY = e.nativeEvent.contentOffset.y; const newScrollY = e.nativeEvent.contentOffset.y;
this._isScrolling = this._scrollOffsetY !== newScrollY;
this._scrollOffsetY = newScrollY;
this._frameHeight = e.nativeEvent.layoutMeasurement.height; this._frameHeight = e.nativeEvent.layoutMeasurement.height;
// We don't want to enqueue any updates if any cells are in the middle of an incremental render, // We don't want to enqueue any updates if any cells are in the middle of an incremental render,
// because it would just be wasted work. // because it would just be wasted work.
@ -271,7 +278,7 @@ class WindowedListView extends React.Component {
const {rowIndex, layout} = params; const {rowIndex, layout} = params;
if (DEBUG) { if (DEBUG) {
const layoutPrev = this._rowFrames[rowIndex] || {}; const layoutPrev = this._rowFrames[rowIndex] || {};
console.log( infoLog(
'record layout for row: ', 'record layout for row: ',
{i: rowIndex, h: layout.height, y: layout.y, x: layout.x, hp: layoutPrev.height, yp: layoutPrev.y} {i: rowIndex, h: layout.height, y: layout.y, x: layout.x, hp: layoutPrev.height, yp: layoutPrev.y}
); );
@ -404,7 +411,10 @@ class WindowedListView extends React.Component {
if (this._rowFramesDirty || rowsShouldChange) { if (this._rowFramesDirty || rowsShouldChange) {
if (rowsShouldChange) { if (rowsShouldChange) {
this.props.onMountedRowsWillChange && this.props.onMountedRowsWillChange(firstRow, lastRow - firstRow + 1); this.props.onMountedRowsWillChange && this.props.onMountedRowsWillChange(firstRow, lastRow - firstRow + 1);
console.log('WLV: row render range will change:', {firstRow, lastRow}); infoLog(
'WLV: row render range will change:',
{firstRow, firstVis: this._firstVisible, lastVis: this._lastVisible, lastRow},
);
} }
this._rowFramesDirty = false; this._rowFramesDirty = false;
this.setState({firstRow, lastRow}); this.setState({firstRow, lastRow});
@ -442,7 +452,7 @@ class WindowedListView extends React.Component {
showIndicator = true; showIndicator = true;
spacerHeight -= this.state.boundaryIndicatorHeight || 0; spacerHeight -= this.state.boundaryIndicatorHeight || 0;
} }
DEBUG && console.log('render top spacer with height ', spacerHeight); DEBUG && infoLog('render top spacer with height ', spacerHeight);
rows.push(<View key="sp-top" style={{height: spacerHeight}} />); rows.push(<View key="sp-top" style={{height: spacerHeight}} />);
if (this.props.renderWindowBoundaryIndicator) { if (this.props.renderWindowBoundaryIndicator) {
// Always render it, even if removed, so that we can get the height right away and don't waste time creating/ // Always render it, even if removed, so that we can get the height right away and don't waste time creating/
@ -461,6 +471,17 @@ class WindowedListView extends React.Component {
</View> </View>
); );
} }
// Incremental rendering is a tradeoff between throughput and responsiveness. When we have plenty of buffer (say 50%
// of the target), we render incrementally to keep the app responsive. If we are dangerously low on buffer (say
// below 25%) we always disable incremental to try to catch up as fast as possible. In the middle, we only disable
// incremental while scrolling since it's unlikely the user will try to press a button while scrolling. We also
// ignore the "buffer" size when we are bumped up against the edge of the available data.
const firstBuffer = firstRow === 0 ? Infinity : this._firstVisible - firstRow;
const lastBuffer = lastRow === this.props.data.length - 1 ? Infinity : lastRow - this._lastVisible;
const minBuffer = Math.min(firstBuffer, lastBuffer);
const disableIncrementalRendering = this.props.disableIncrementalRendering ||
(this._isScrolling && minBuffer < this.props.numToRenderAhead * 0.5) ||
(minBuffer < this.props.numToRenderAhead * 0.25);
for (let idx = firstRow; idx <= lastRow; idx++) { for (let idx = firstRow; idx <= lastRow; idx++) {
const key = '' + (this.props.enableDangerousRecycling ? (idx % this.props.maxNumToRender) : idx); const key = '' + (this.props.enableDangerousRecycling ? (idx % this.props.maxNumToRender) : idx);
rows.push( rows.push(
@ -470,7 +491,7 @@ class WindowedListView extends React.Component {
rowIndex={idx} rowIndex={idx}
onNewLayout={this._onNewLayout} onNewLayout={this._onNewLayout}
onWillUnmount={this._onWillUnmountCell} onWillUnmount={this._onWillUnmountCell}
includeInLayout={this.props.disableIncrementalRendering || includeInLayout={disableIncrementalRendering ||
(this._rowFrames[idx] && this._rowFrames[idx].offscreenLayoutDone)} (this._rowFrames[idx] && this._rowFrames[idx].offscreenLayoutDone)}
onProgressChange={this._onProgressChange} onProgressChange={this._onProgressChange}
asyncRowPerfEventName={this.props.asyncRowPerfEventName} asyncRowPerfEventName={this.props.asyncRowPerfEventName}
@ -517,6 +538,7 @@ class WindowedListView extends React.Component {
contentInset, contentInset,
ref: (ref) => { this._scrollRef = ref; }, ref: (ref) => { this._scrollRef = ref; },
onScroll: this._onScroll, onScroll: this._onScroll,
onMomentumScrollEnd: this._onMomentumScrollEnd,
children: rows, children: rows,
})} })}
</IncrementalGroup> </IncrementalGroup>
@ -570,8 +592,9 @@ type CellProps = {
}; };
class CellRenderer extends React.Component { class CellRenderer extends React.Component {
props: CellProps; props: CellProps;
_containerRef: View;
_offscreenRenderDone = false; _offscreenRenderDone = false;
_timer = 0; _timeout = 0;
_lastLayout: ?Object = null; _lastLayout: ?Object = null;
_perfUpdateID: number = 0; _perfUpdateID: number = 0;
_asyncCookie: any; _asyncCookie: any;
@ -580,7 +603,7 @@ class CellRenderer extends React.Component {
if (this.props.asyncRowPerfEventName) { if (this.props.asyncRowPerfEventName) {
this._perfUpdateID = g_perf_update_id++; this._perfUpdateID = g_perf_update_id++;
this._asyncCookie = Systrace.beginAsyncEvent(this.props.asyncRowPerfEventName + this._perfUpdateID); this._asyncCookie = Systrace.beginAsyncEvent(this.props.asyncRowPerfEventName + this._perfUpdateID);
console.log(`perf_asynctest_${this.props.asyncRowPerfEventName}_start ${this._perfUpdateID} ${Date.now()}`); infoLog(`perf_asynctest_${this.props.asyncRowPerfEventName}_start ${this._perfUpdateID} ${Date.now()}`);
} }
if (this.props.includeInLayout) { if (this.props.includeInLayout) {
this._includeInLayoutLatch = true; this._includeInLayoutLatch = true;
@ -599,9 +622,7 @@ class CellRenderer extends React.Component {
layout, layout,
}); });
}; };
_onOffscreenRenderDone = () => { _updateParent() {
DEBUG && console.log('_onOffscreenRenderDone for row ' + this.props.rowIndex);
this._timer = setTimeout(() => { // Flush any pending layout events.
invariant(!this._offscreenRenderDone, 'should only finish rendering once'); invariant(!this._offscreenRenderDone, 'should only finish rendering once');
this._offscreenRenderDone = true; this._offscreenRenderDone = true;
@ -613,15 +634,24 @@ class CellRenderer extends React.Component {
// when Incremental is disabled and _onOffscreenRenderDone is called faster than layout can happen. // when Incremental is disabled and _onOffscreenRenderDone is called faster than layout can happen.
this._lastLayout && this.props.onNewLayout({rowIndex: this.props.rowIndex, layout: this._lastLayout}); this._lastLayout && this.props.onNewLayout({rowIndex: this.props.rowIndex, layout: this._lastLayout});
DEBUG && console.log('\n >>>>> display row ' + this.props.rowIndex + '\n\n\n'); DEBUG && infoLog('\n >>>>> display row ' + this.props.rowIndex + '\n\n\n');
if (this.props.asyncRowPerfEventName) { if (this.props.asyncRowPerfEventName) {
// Note this doesn't include the native render time but is more accurate than also including the JS render
// time of anything that has been queued up.
Systrace.endAsyncEvent(this.props.asyncRowPerfEventName + this._perfUpdateID, this._asyncCookie); Systrace.endAsyncEvent(this.props.asyncRowPerfEventName + this._perfUpdateID, this._asyncCookie);
console.log(`perf_asynctest_${this.props.asyncRowPerfEventName}_end ${this._perfUpdateID} ${Date.now()}`); infoLog(`perf_asynctest_${this.props.asyncRowPerfEventName}_end ${this._perfUpdateID} ${Date.now()}`);
}
}
_onOffscreenRenderDone = () => {
DEBUG && infoLog('_onOffscreenRenderDone for row ' + this.props.rowIndex);
if (this._includeInLayoutLatch) {
this._updateParent(); // rendered straight into layout, so no need to flush
} else {
this._timeout = setTimeout(() => this._updateParent(), 1); // Flush any pending layout events.
} }
}, 1);
}; };
componentWillUnmount() { componentWillUnmount() {
clearTimeout(this._timer); clearTimeout(this._timeout);
this.props.onProgressChange({rowIndex: this.props.rowIndex, inProgress: false}); this.props.onProgressChange({rowIndex: this.props.rowIndex, inProgress: false});
this.props.onWillUnmount(this.props.rowIndex); this.props.onWillUnmount(this.props.rowIndex);
} }
@ -629,26 +659,32 @@ class CellRenderer extends React.Component {
if (newProps.includeInLayout && !this.props.includeInLayout) { if (newProps.includeInLayout && !this.props.includeInLayout) {
invariant(this._offscreenRenderDone, 'Should never try to add to layout before render done'); invariant(this._offscreenRenderDone, 'Should never try to add to layout before render done');
this._includeInLayoutLatch = true; // Once we render in layout, make sure it sticks. this._includeInLayoutLatch = true; // Once we render in layout, make sure it sticks.
this.refs.container.setNativeProps({style: styles.include}); this._containerRef.setNativeProps({style: styles.include});
} }
} }
shouldComponentUpdate(newProps) { shouldComponentUpdate(newProps) {
return newProps.data !== this.props.data; return newProps.data !== this.props.data;
} }
_setRef = (ref) => {
this._containerRef = ref;
};
render() { render() {
let debug; let debug;
if (DEBUG) { if (DEBUG) {
console.log('render cell ' + this.props.rowIndex); infoLog('render cell ' + this.props.rowIndex);
const Text = require('Text'); const Text = require('Text');
debug = <Text style={{backgroundColor: 'lightblue'}}> debug = <Text style={{backgroundColor: 'lightblue'}}>
Row: {this.props.rowIndex} Row: {this.props.rowIndex}
</Text>; </Text>;
} }
const style = (this._includeInLayoutLatch || this.props.includeInLayout) ? styles.include : styles.remove; const style = this._includeInLayoutLatch ? styles.include : styles.remove;
return ( return (
<IncrementalGroup onDone={this._onOffscreenRenderDone} name={`CellRenderer_${this.props.rowIndex}`}> <IncrementalGroup
disable={this._includeInLayoutLatch}
onDone={this._onOffscreenRenderDone}
name={`WLVCell_${this.props.rowIndex}`}>
<View <View
ref="container" ref={this._setRef}
style={style} style={style}
onLayout={this._onLayout}> onLayout={this._onLayout}>
{debug} {debug}