Skip to content

Commit

Permalink
Use Batchinator in WindowedListView
Browse files Browse the repository at this point in the history
Summary: Cleans things up and also defers rendering rows if there is an interaction happening.

Reviewed By: achen1

Differential Revision: D3817231

fbshipit-source-id: fd08d0ca7cb6c203178f27bfc5a0f55469135c3a
  • Loading branch information
sahrens authored and Facebook Github Bot 1 committed Sep 7, 2016
1 parent 322c160 commit 6fb1495
Showing 1 changed file with 100 additions and 81 deletions.
181 changes: 100 additions & 81 deletions Libraries/Experimental/WindowedListView.js
Expand Up @@ -32,6 +32,7 @@
*/
'use strict';

const Batchinator = require('Batchinator');
const IncrementalGroup = require('IncrementalGroup');
const React = require('React');
const ScrollView = require('ScrollView');
Expand Down Expand Up @@ -149,13 +150,14 @@ type Props = {
*/
recomputeRowsBatchingPeriod: number,
/**
* Called when rows will be mounted/unmounted. Mounted rows always form a contiguous block so it is expressed as a
* range of start plus count.
* Called when rows will be mounted/unmounted. Mounted rows always form a contiguous block so it
* is expressed as a range of start plus count.
*/
onMountedRowsWillChange?: (firstIdx: number, count: number) => void,
/**
* Change this when you want to make sure the WindowedListView will re-render, for example when the result of
* `renderScrollComponent` might change. It will be compared in `shouldComponentUpdate`.
* Change this when you want to make sure the WindowedListView will re-render, for example when
* the result of `renderScrollComponent` might change. It will be compared in
* `shouldComponentUpdate`.
*/
shouldUpdateToken?: string,
};
Expand All @@ -168,6 +170,11 @@ type State = {
class WindowedListView extends React.Component {
props: Props;
state: State;
/**
* Recomputing which rows to render is batched up and run asynchronously to avoid wastful updates,
* e.g. from multiple layout updates in rapid succession.
*/
_computeRowsToRenderBatcher: Batchinator;
_firstVisible: number = -1;
_lastVisible: number = -1;
_scrollOffsetY: number = 0;
Expand All @@ -178,8 +185,6 @@ class WindowedListView extends React.Component {
_rowFramesDirty: boolean = false;
_hasCalledOnEndReached: boolean = false;
_willComputeRowsToRender: boolean = false;
_timeoutHandle: number = 0;
_incrementPending: boolean = false;
_viewableRows: Array<number> = [];
_cellsInProgress: Set<string> = new Set();
_scrollRef: ?Object;
Expand All @@ -191,7 +196,7 @@ class WindowedListView extends React.Component {
viewablePercentThreshold: 50,
renderScrollComponent: (props) => <ScrollView {...props} />,
disableIncrementalRendering: false,
recomputeRowsBatchingPeriod: 10, // This should capture most events that will happen in one frame
recomputeRowsBatchingPeriod: 10, // This should capture most events that happen within a frame
};

constructor(props: Props) {
Expand All @@ -200,6 +205,10 @@ class WindowedListView extends React.Component {
this.props.numToRenderAhead < this.props.maxNumToRender,
'WindowedListView: numToRenderAhead must be less than maxNumToRender'
);
this._computeRowsToRenderBatcher = new Batchinator(
() => this._computeRowsToRender(this.props),
this.props.recomputeRowsBatchingPeriod,
);
this.state = {
firstRow: 0,
lastRow: Math.min(this.props.data.length, this.props.initialNumToRender) - 1,
Expand All @@ -225,21 +234,27 @@ class WindowedListView extends React.Component {
const newDataSubset = newProps.data.slice(newState.firstRow, newState.lastRow + 1);
const prevDataSubset = this.props.data.slice(this.state.firstRow, this.state.lastRow + 1);
if (newDataSubset.length !== prevDataSubset.length) {
DEBUG && infoLog(' yes, subset length: ', {newLen: newDataSubset.length, oldLen: prevDataSubset.length});
DEBUG && infoLog(
' yes, subset length: ',
{newLen: newDataSubset.length, oldLen: prevDataSubset.length}
);
return true;
}
for (let idx = 0; idx < newDataSubset.length; idx++) {
if (newDataSubset[idx].rowData !== prevDataSubset[idx].rowData ||
newDataSubset[idx].rowKey !== prevDataSubset[idx].rowKey) {
DEBUG && infoLog(' yes, data change: ', {idx, new: newDataSubset[idx], old: prevDataSubset[idx]});
DEBUG && infoLog(
' yes, data change: ',
{idx, new: newDataSubset[idx], old: prevDataSubset[idx]}
);
return true;
}
}
DEBUG && infoLog(' knope');
return false;
}
componentWillReceiveProps() {
this._enqueueComputeRowsToRender();
this._computeRowsToRenderBatcher.schedule();
}
_onMomentumScrollEnd = (e: Object) => {
this._onScroll(e);
Expand All @@ -252,7 +267,7 @@ class WindowedListView extends React.Component {
// 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.
if (this._cellsInProgress.size === 0) {
this._enqueueComputeRowsToRender();
this._computeRowsToRenderBatcher.schedule();
}
if (this.props.onViewableRowsChanged && Object.keys(this._rowFrames).length) {
const viewableRows = ViewabilityHelper.computeViewableRows(
Expand All @@ -273,24 +288,27 @@ class WindowedListView extends React.Component {
_onNewLayout = (params: {rowKey: string, layout: Object}) => {
const {rowKey, layout} = params;
if (DEBUG) {
const layoutPrev = this._rowFrames[rowKey] || {};
const prev = this._rowFrames[rowKey] || {};
infoLog(
'record layout for row: ',
{k: rowKey, h: layout.height, y: layout.y, x: layout.x, hp: layoutPrev.height, yp: layoutPrev.y}
{k: rowKey, h: layout.height, y: layout.y, x: layout.x, hp: prev.height, yp: prev.y}
);
if (this._rowFrames[rowKey]) {
const deltaY = Math.abs(this._rowFrames[rowKey].y - layout.y);
const deltaH = Math.abs(this._rowFrames[rowKey].height - layout.height);
if (deltaY > 2 || deltaH > 2) {
const dataEntry = this.props.data.find((datum) => datum.rowKey === rowKey);
console.warn('layout jump: ', {dataEntry, prevLayout: this._rowFrames[rowKey], newLayout: layout});
console.warn(
'layout jump: ',
{dataEntry, prevLayout: this._rowFrames[rowKey], newLayout: layout}
);
}
}
}
this._rowFrames[rowKey] = {...layout, offscreenLayoutDone: true};
this._rowFramesDirty = true;
if (this._cellsInProgress.size === 0) {
this._enqueueComputeRowsToRender();
this._computeRowsToRenderBatcher.schedule();
}
};
_onWillUnmountCell = (rowKey: string) => {
Expand All @@ -300,8 +318,8 @@ class WindowedListView extends React.Component {
}
};
/**
* This is used to keep track of cells that are in the process of rendering. If any cells are in progress, then
* other updates are skipped because they will just be wasted work.
* This is used to keep track of cells that are in the process of rendering. If any cells are in
* progress, then other updates are skipped because they will just be wasted work.
*/
_onProgressChange = ({rowKey, inProgress}: {rowKey: string, inProgress: boolean}) => {
if (inProgress) {
Expand All @@ -310,26 +328,8 @@ class WindowedListView extends React.Component {
this._cellsInProgress.delete(rowKey);
}
};
/**
* Recomputing which rows to render is batched up and run asynchronously to avoid wastful updates, e.g. from multiple
* layout updates in rapid succession.
*/
_enqueueComputeRowsToRender(): void {
if (!this._willComputeRowsToRender) {
this._willComputeRowsToRender = true; // batch up computations
clearTimeout(this._timeoutHandle);
this._timeoutHandle = setTimeout(
() => {
this._willComputeRowsToRender = false;
this._incrementPending = false;
this._computeRowsToRender(this.props);
},
this.props.recomputeRowsBatchingPeriod
);
}
}
componentWillUnmount() {
clearTimeout(this._timeoutHandle);
this._computeRowsToRenderBatcher.dispose();
}
_computeRowsToRender(props: Object): void {
const totalRows = props.data.length;
Expand Down Expand Up @@ -367,8 +367,9 @@ class WindowedListView extends React.Component {
}
this._updateVisibleRows(firstVisible, lastVisible);

// Unfortuantely, we can't use <Incremental> to simplify our increment logic in this function because we need to
// make sure that cells are rendered in the right order one at a time when scrolling back up.
// Unfortuantely, we can't use <Incremental> to simplify our increment logic in this function
// because we need to make sure that cells are rendered in the right order one at a time when
// scrolling back up.

const numRendered = lastRow - this.state.firstRow + 1;
// Our last row target that we will approach incrementally
Expand All @@ -378,14 +379,10 @@ class WindowedListView extends React.Component {
totalRows - 1, // Don't render past the end
);
// Increment the last row one at a time per JS event loop
if (!this._incrementPending) {
if (targetLastRow > this.state.lastRow) {
lastRow++;
this._incrementPending = true;
} else if (targetLastRow < this.state.lastRow) {
lastRow--;
this._incrementPending = true;
}
if (targetLastRow > this.state.lastRow) {
lastRow++;
} else if (targetLastRow < this.state.lastRow) {
lastRow--;
}
// Once last row is set, figure out the first row
const firstRow = Math.max(
Expand Down Expand Up @@ -415,7 +412,8 @@ class WindowedListView extends React.Component {
const rowsShouldChange = firstRow !== this.state.firstRow || lastRow !== this.state.lastRow;
if (this._rowFramesDirty || rowsShouldChange) {
if (rowsShouldChange) {
props.onMountedRowsWillChange && props.onMountedRowsWillChange(firstRow, lastRow - firstRow + 1);
props.onMountedRowsWillChange &&
props.onMountedRowsWillChange(firstRow, lastRow - firstRow + 1);
infoLog(
'WLV: row render range will change:',
{firstRow, firstVis: this._firstVisible, lastVis: this._lastVisible, lastRow},
Expand All @@ -441,21 +439,27 @@ class WindowedListView extends React.Component {
const rowFrames = this._rowFrames;
const rows = [];
let spacerHeight = 0;
// 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.
// 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 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);
// Render mode is sticky while the component is mounted.
for (let ii = firstRow; ii <= lastRow; ii++) {
const rowKey = this.props.data[ii].rowKey;
if (this._rowRenderMode[rowKey] === 'sync' || (disableIncrementalRendering && this._rowRenderMode[rowKey] !== 'async')) {
if (
this._rowRenderMode[rowKey] === 'sync' ||
(disableIncrementalRendering && this._rowRenderMode[rowKey] !== 'async')
) {
this._rowRenderMode[rowKey] = 'sync';
} else {
this._rowRenderMode[rowKey] = 'async';
Expand All @@ -466,9 +470,9 @@ class WindowedListView extends React.Component {
if (!rowFrames[rowKey]) {
break; // if rowFrame missing, no following ones will exist so quit early
}
// Look for the first row where offscreen layout is done (only true for mounted rows) or it will be rendered
// synchronously and set the spacer height such that it will offset all the unmounted rows before that one using
// the saved frame data.
// Look for the first row where offscreen layout is done (only true for mounted rows) or it
// will be rendered synchronously and set the spacer height such that it will offset all the
// unmounted rows before that one using the saved frame data.
if (rowFrames[rowKey].offscreenLayoutDone || this._rowRenderMode[rowKey] === 'sync') {
if (ii > 0) {
const prevRowKey = this.props.data[ii - 1].rowKey;
Expand All @@ -479,15 +483,19 @@ class WindowedListView extends React.Component {
}
}
let showIndicator = false;
if (spacerHeight > (this.state.boundaryIndicatorHeight || 0) && this.props.renderWindowBoundaryIndicator) {
if (
spacerHeight > (this.state.boundaryIndicatorHeight || 0) &&
this.props.renderWindowBoundaryIndicator
) {
showIndicator = true;
spacerHeight -= this.state.boundaryIndicatorHeight || 0;
}
DEBUG && infoLog('render top spacer with height ', spacerHeight);
rows.push(<View key="sp-top" style={{height: spacerHeight}} />);
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/
// destroying it. Should see if there is a better spinner option that is not as expensive.
// Always render it, even if removed, so that we can get the height right away and don't waste
// time creating/ destroying it. Should see if there is a better spinner option that is not as
// expensive.
rows.push(
<View
style={!showIndicator && styles.remove}
Expand Down Expand Up @@ -590,26 +598,28 @@ type CellProps = {
*/
asyncRowPerfEventName: ?string,
/**
* Initially false to indicate the cell should be rendered "offscreen" with position: absolute so that incremental
* rendering doesn't cause things to jump around. Once onNewLayout is called after offscreen rendering has completed,
* includeInLayout will be set true and the finished cell can be dropped into place.
* Initially false to indicate the cell should be rendered "offscreen" with position: absolute so
* that incremental rendering doesn't cause things to jump around. Once onNewLayout is called
* after offscreen rendering has completed, includeInLayout will be set true and the finished cell
* can be dropped into place.
*
* This is coordinated outside this component so the parent can syncronize this re-render with managing the
* placeholder sizing.
* This is coordinated outside this component so the parent can syncronize this re-render with
* managing the placeholder sizing.
*/
includeInLayout: boolean,
/**
* Updates the parent with the latest layout. Only called when incremental rendering is done and triggers the parent
* to re-render this row with includeInLayout true.
* Updates the parent with the latest layout. Only called when incremental rendering is done and
* triggers the parent to re-render this row with includeInLayout true.
*/
onNewLayout: (params: {rowKey: string, layout: Object}) => void,
/**
* Used to track when rendering is in progress so the parent can avoid wastedful re-renders that are just going to be
* invalidated once the cell finishes.
* Used to track when rendering is in progress so the parent can avoid wastedful re-renders that
* are just going to be invalidated once the cell finishes.
*/
onProgressChange: (progress: {rowKey: string, inProgress: boolean}) => void,
/**
* Used to invalidate the layout so the parent knows it needs to compensate for the height in the placeholder size.
* Used to invalidate the layout so the parent knows it needs to compensate for the height in the
* placeholder size.
*/
onWillUnmount: (rowKey: string) => void,
};
Expand All @@ -625,9 +635,12 @@ class CellRenderer extends React.Component {
componentWillMount() {
if (this.props.asyncRowPerfEventName) {
this._perfUpdateID = g_perf_update_id++;
this._asyncCookie = Systrace.beginAsyncEvent(this.props.asyncRowPerfEventName + this._perfUpdateID);
this._asyncCookie = Systrace.beginAsyncEvent(
this.props.asyncRowPerfEventName + this._perfUpdateID
);
// $FlowFixMe(>=0.28.0)
infoLog(`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) {
this._includeInLayoutLatch = true;
Expand All @@ -650,21 +663,27 @@ class CellRenderer extends React.Component {
invariant(!this._offscreenRenderDone, 'should only finish rendering once');
this._offscreenRenderDone = true;

// If this is not called before calling onNewLayout, the number of inProgress cells will remain non-zero,
// and thus the onNewLayout call will not fire the needed state change update.
// If this is not called before calling onNewLayout, the number of inProgress cells will remain
// non-zero, and thus the onNewLayout call will not fire the needed state change update.
this.props.onProgressChange({rowKey: this.props.rowKey, inProgress: false});

// If an onLayout event hasn't come in yet, then we skip here and assume it will come in later. This happens
// when Incremental is disabled and _onOffscreenRenderDone is called faster than layout can happen.
this._lastLayout && this.props.onNewLayout({rowKey: this.props.rowKey, layout: this._lastLayout});
// If an onLayout event hasn't come in yet, then we skip here and assume it will come in later.
// This happens when Incremental is disabled and _onOffscreenRenderDone is called faster than
// layout can happen.
this._lastLayout &&
this.props.onNewLayout({rowKey: this.props.rowKey, layout: this._lastLayout});

DEBUG && infoLog('\n >>>>> display row ' + this.props.rowIndex + '\n\n\n');
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);
// 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
);
// $FlowFixMe(>=0.28.0)
infoLog(`perf_asynctest_${this.props.asyncRowPerfEventName}_end ${this._perfUpdateID} ${Date.now()}`);
infoLog(`perf_asynctest_${this.props.asyncRowPerfEventName}_end ${this._perfUpdateID} ` +
`${Date.now()}`);
}
}
_onOffscreenRenderDone = () => {
Expand Down

0 comments on commit 6fb1495

Please sign in to comment.