Add option to track when we're showing blankness during fast scrolling

Summary:
If tracking is enabled and the sampling check passes on a scroll or layout event,
we compare the scroll offset to the layout of the rendered items. If the items don't cover
the visible area of the list, we fire an `onFillRateExceeded` call with relevant stats for
logging the event through an analytics pipeline.

The measurement methodology is a little jank because everything is async, but it seems directionally
useful for getting ballpark numbers, catching regressions, and tracking improvements.

Benchmark testing shows a ~2014 MotoX starts hitting the fill rate limit at about 2500 px / sec,
which is pretty fast scrolling.

This also reworks our frame rate stuff so we can use a shared `SceneTracking` thing and track blankness
globally.

Reviewed By: bvaughn

Differential Revision: D4806867

fbshipit-source-id: 119bf177463c8c3aa51fa13d1a9d03b1a96042aa
This commit is contained in:
Spencer Ahrens
2017-04-07 00:48:49 -07:00
committed by Facebook Github Bot
parent b5327dd388
commit f72d9dd08b
6 changed files with 387 additions and 3 deletions

View File

@@ -0,0 +1,176 @@
/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*
* @providesModule FillRateHelper
* @flow
*/
'use strict';
const performanceNow = require('fbjs/lib/performanceNow');
const warning = require('fbjs/lib/warning');
export type FillRateExceededInfo = {
event: {
sample_type: string,
blankness: number,
blank_pixels_top: number,
blank_pixels_bottom: number,
scroll_offset: number,
visible_length: number,
scroll_speed: number,
first_frame: Object,
last_frame: Object,
},
aggregate: {
avg_blankness: number,
min_speed_when_blank: number,
avg_speed_when_blank: number,
avg_blankness_when_any_blank: number,
fraction_any_blank: number,
all_samples_timespan_sec: number,
fill_rate_sample_counts: {[key: string]: number},
},
};
type FrameMetrics = {inLayout?: boolean, length: number, offset: number};
let _listeners: Array<(FillRateExceededInfo) => void> = [];
let _sampleRate = null;
/**
* A helper class for detecting when the maximem fill rate of `VirtualizedList` is exceeded.
* By default the sampling rate is set to zero and this will do nothing. If you want to collect
* samples (e.g. to log them), make sure to call `FillRateHelper.setSampleRate(0.0-1.0)`.
*
* Listeners and sample rate are global for all `VirtualizedList`s - typical usage will combine with
* `SceneTracker.getActiveScene` to determine the context of the events.
*/
class FillRateHelper {
_getFrameMetrics: (index: number) => ?FrameMetrics;
_anyBlankCount = 0;
_anyBlankMinSpeed = Infinity;
_anyBlankSpeedSum = 0;
_sampleCounts = {};
_fractionBlankSum = 0;
_samplesStartTime = 0;
static addFillRateExceededListener(
callback: (FillRateExceededInfo) => void
): {remove: () => void} {
warning(
_sampleRate !== null,
'Call `FillRateHelper.setSampleRate` before `addFillRateExceededListener`.'
);
_listeners.push(callback);
return {
remove: () => {
_listeners = _listeners.filter((listener) => callback !== listener);
},
};
}
static setSampleRate(sampleRate: number) {
_sampleRate = sampleRate;
}
static enabled(): boolean {
return (_sampleRate || 0) > 0.0;
}
constructor(getFrameMetrics: (index: number) => ?FrameMetrics) {
this._getFrameMetrics = getFrameMetrics;
}
computeInfoSampled(
sampleType: string,
props: {
data: Array<any>,
getItemCount: (data: Array<any>) => number,
initialNumToRender: number,
},
state: {
first: number,
last: number,
},
scrollMetrics: {
offset: number,
velocity: number,
visibleLength: number,
},
): ?FillRateExceededInfo {
if (!FillRateHelper.enabled() || (_sampleRate || 0) <= Math.random()) {
return null;
}
const start = performanceNow();
if (!this._samplesStartTime) {
this._samplesStartTime = start;
}
const {offset, velocity, visibleLength} = scrollMetrics;
let blankTop = 0;
let first = state.first;
let firstFrame = this._getFrameMetrics(first);
while (first <= state.last && (!firstFrame || !firstFrame.inLayout)) {
firstFrame = this._getFrameMetrics(first);
first++;
}
if (firstFrame) {
blankTop = Math.min(visibleLength, Math.max(0, firstFrame.offset - offset));
}
let blankBottom = 0;
let last = state.last;
let lastFrame = this._getFrameMetrics(last);
while (last >= state.first && (!lastFrame || !lastFrame.inLayout)) {
lastFrame = this._getFrameMetrics(last);
last--;
}
if (lastFrame) {
const bottomEdge = lastFrame.offset + lastFrame.length;
blankBottom = Math.min(visibleLength, Math.max(0, offset + visibleLength - bottomEdge));
}
this._sampleCounts.all = (this._sampleCounts.all || 0) + 1;
this._sampleCounts[sampleType] = (this._sampleCounts[sampleType] || 0) + 1;
const blankness = (blankTop + blankBottom) / visibleLength;
if (blankness > 0) {
const scrollSpeed = Math.abs(velocity);
if (scrollSpeed && sampleType === 'onScroll') {
this._anyBlankMinSpeed = Math.min(this._anyBlankMinSpeed, scrollSpeed);
}
this._anyBlankSpeedSum += scrollSpeed;
this._anyBlankCount++;
this._fractionBlankSum += blankness;
const event = {
sample_type: sampleType,
blankness: blankness,
blank_pixels_top: blankTop,
blank_pixels_bottom: blankBottom,
scroll_offset: offset,
visible_length: visibleLength,
scroll_speed: scrollSpeed,
first_frame: {...firstFrame},
last_frame: {...lastFrame},
};
const aggregate = {
avg_blankness: this._fractionBlankSum / this._sampleCounts.all,
min_speed_when_blank: this._anyBlankMinSpeed,
avg_speed_when_blank: this._anyBlankSpeedSum / this._anyBlankCount,
avg_blankness_when_any_blank: this._fractionBlankSum / this._anyBlankCount,
fraction_any_blank: this._anyBlankCount / this._sampleCounts.all,
all_samples_timespan_sec: (performanceNow() - this._samplesStartTime) / 1000.0,
fill_rate_sample_counts: {...this._sampleCounts},
compute_time: performanceNow() - start,
};
const info = {event, aggregate};
_listeners.forEach((listener) => listener(info));
return info;
}
return null;
}
}
module.exports = FillRateHelper;

View File

@@ -12,6 +12,7 @@
'use strict';
const Batchinator = require('Batchinator');
const FillRateHelper = require('FillRateHelper');
const React = require('React');
const ReactNative = require('ReactNative');
const RefreshControl = require('RefreshControl');
@@ -27,6 +28,7 @@ const {computeWindowedRenderLimits} = require('VirtualizeUtils');
import type {ViewabilityConfig, ViewToken} from 'ViewabilityHelper';
type Item = any;
type renderItemType = (info: {item: Item, index: number}) => ?React.Element<any>;
type RequiredProps = {
@@ -301,6 +303,7 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, State> {
'to support native onScroll events with useNativeDriver',
);
this._fillRateHelper = new FillRateHelper(this._getFrameMetrics);
this._updateCellsToRenderBatcher = new Batchinator(
this._updateCellsToRender,
this.props.updateCellsBatchingPeriod,
@@ -366,6 +369,7 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, State> {
}
}
}
render() {
const {ListFooterComponent, ListHeaderComponent} = this.props;
const {data, disableVirtualization, horizontal} = this.props;
@@ -481,6 +485,7 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, State> {
_hasWarned = {};
_highestMeasuredFrameIndex = 0;
_headerLength = 0;
_fillRateHelper: FillRateHelper;
_frames = {};
_footerLength = 0;
_scrollMetrics = {
@@ -520,6 +525,7 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, State> {
} else {
this._frames[cellKey].inLayout = true;
}
this._sampleFillRate('onCellLayout');
}
_onCellUnmount = (cellKey: string) => {
@@ -606,6 +612,15 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, State> {
this._updateCellsToRenderBatcher.schedule();
};
_sampleFillRate(sampleType: string) {
this._fillRateHelper.computeInfoSampled(
sampleType,
this.props,
this.state,
this._scrollMetrics,
);
}
_onScroll = (e: Object) => {
if (this.props.onScroll) {
this.props.onScroll(e);
@@ -629,6 +644,9 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, State> {
const velocity = dOffset / dt;
this._scrollMetrics = {contentLength, dt, offset, timestamp, velocity, visibleLength};
const {data, getItemCount, onEndReached, onEndReachedThreshold, windowSize} = this.props;
this._sampleFillRate('onScroll');
this._updateViewableItems(data);
if (!data) {
return;
@@ -667,6 +685,7 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, State> {
this._viewabilityHelper.recordInteraction();
this.props.onScrollBeginDrag && this.props.onScrollBeginDrag(e);
};
_updateCellsToRender = () => {
const {data, disableVirtualization, getItemCount, onEndReachedThreshold} = this.props;
this._updateViewableItems(data);
@@ -717,7 +736,9 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, State> {
}
};
_getFrameMetrics = (index: number): ?{length: number, offset: number, index: number} => {
_getFrameMetrics = (
index: number,
): ?{length: number, offset: number, index: number, inLayout?: boolean} => {
const {data, getItem, getItemCount, getItemLayout, keyExtractor} = this.props;
invariant(getItemCount(data) > index, 'Tried to get frame for out of range index ' + index);
const item = getItem(data, index);
@@ -767,7 +788,9 @@ class CellRenderer extends React.Component {
const {renderItem, getItemLayout} = parentProps;
invariant(renderItem, 'no renderItem!');
const element = renderItem({item, index});
if (getItemLayout && !parentProps.debug) {
if (getItemLayout &&
!parentProps.debug &&
!FillRateHelper.enabled()) {
return element;
}
// NOTE: that when this is a sticky header, `onLayout` will get automatically extracted and

View File

@@ -0,0 +1,106 @@
/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*
*/
'use strict';
jest.unmock('FillRateHelper');
const FillRateHelper = require('FillRateHelper');
let rowFramesGlobal;
const dataGlobal = [{key: 'a'}, {key: 'b'}, {key: 'c'}, {key: 'd'}];
function getFrameMetrics(index: number) {
const frame = rowFramesGlobal[dataGlobal[index].key];
return {length: frame.height, offset: frame.y, inLayout: frame.inLayout};
}
function computeResult({helper, props, state, scroll}) {
return helper.computeInfoSampled(
'test',
{
data: dataGlobal,
fillRateTrackingSampleRate: 1,
getItemCount: (data2) => data2.length,
initialNumToRender: 10,
...(props || {}),
},
{first: 0, last: 1, ...(state || {})},
{offset: 0, visibleLength: 100, ...(scroll || {})},
);
}
describe('computeInfoSampled', function() {
beforeEach(() => {
FillRateHelper.setSampleRate(1);
});
it('computes correct blankness of viewport', function() {
const helper = new FillRateHelper(getFrameMetrics);
rowFramesGlobal = {
a: {y: 0, height: 50, inLayout: true},
b: {y: 50, height: 50, inLayout: true},
};
let result = computeResult({helper});
expect(result).toBeNull();
result = computeResult({helper, state: {last: 0}});
expect(result.event.blankness).toBe(0.5);
result = computeResult({helper, scroll: {offset: 25}});
expect(result.event.blankness).toBe(0.25);
result = computeResult({helper, scroll: {visibleLength: 400}});
expect(result.event.blankness).toBe(0.75);
result = computeResult({helper, scroll: {offset: 100}});
expect(result.event.blankness).toBe(1);
expect(result.aggregate.avg_blankness).toBe(0.5);
});
it('skips frames that are not in layout', function() {
const helper = new FillRateHelper(getFrameMetrics);
rowFramesGlobal = {
a: {y: 0, height: 10, inLayout: false},
b: {y: 10, height: 30, inLayout: true},
c: {y: 40, height: 40, inLayout: true},
d: {y: 80, height: 20, inLayout: false},
};
const result = computeResult({helper, state: {last: 3}});
expect(result.event.blankness).toBe(0.3);
});
it('sampling rate can disable', function() {
const helper = new FillRateHelper(getFrameMetrics);
rowFramesGlobal = {
a: {y: 0, height: 40, inLayout: true},
b: {y: 40, height: 40, inLayout: true},
};
let result = computeResult({helper});
expect(result.event.blankness).toBe(0.2);
FillRateHelper.setSampleRate(0);
result = computeResult({helper});
expect(result).toBeNull();
});
it('can handle multiple listeners and unsubscribe', function() {
const listeners = [jest.fn(), jest.fn(), jest.fn()];
const subscriptions = listeners.map(
(listener) => FillRateHelper.addFillRateExceededListener(listener)
);
subscriptions[1].remove();
const helper = new FillRateHelper(getFrameMetrics);
rowFramesGlobal = {
a: {y: 0, height: 40, inLayout: true},
b: {y: 40, height: 40, inLayout: true},
};
const result = computeResult({helper});
expect(result.event.blankness).toBe(0.2);
expect(listeners[0]).toBeCalledWith(result);
expect(listeners[1]).not.toBeCalled();
expect(listeners[2]).toBeCalledWith(result);
});
});

View File

@@ -16,6 +16,7 @@ const BugReporting = require('BugReporting');
const FrameRateLogger = require('FrameRateLogger');
const NativeModules = require('NativeModules');
const ReactNative = require('ReactNative');
const SceneTracker = require('SceneTracker');
const infoLog = require('infoLog');
const invariant = require('fbjs/lib/invariant');
@@ -56,6 +57,8 @@ const sections: Runnables = {};
const tasks: Map<string, TaskProvider> = new Map();
let componentProviderInstrumentationHook: ComponentProviderInstrumentationHook =
(component: ComponentProvider) => component();
let _frameRateLoggerSceneListener = null;
/**
* `AppRegistry` is the JS entry point to running all React Native apps. App
@@ -173,7 +176,12 @@ const AppRegistry = {
'This error can also happen due to a require() error during ' +
'initialization or failure to call AppRegistry.registerComponent.\n\n'
);
FrameRateLogger.setContext(appKey);
if (!_frameRateLoggerSceneListener) {
_frameRateLoggerSceneListener = SceneTracker.addActiveSceneChangedListener(
(scene) => FrameRateLogger.setContext(scene.name)
);
}
SceneTracker.setActiveScene({name: appKey});
runnables[appKey].run(appParameters);
},

View File

@@ -0,0 +1,41 @@
/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*
* @providesModule SceneTracker
* @flow
*/
'use strict';
type Scene = {name: string};
let _listeners: Array<(scene: Scene) => void> = [];
let _activeScene = {name: 'default'};
const SceneTracker = {
setActiveScene(scene: Scene) {
_activeScene = scene;
_listeners.forEach((listener) => listener(_activeScene));
},
getActiveScene(): Scene {
return _activeScene;
},
addActiveSceneChangedListener(callback: (scene: Scene) => void): {remove: () => void} {
_listeners.push(callback);
return {
remove: () => {
_listeners = _listeners.filter((listener) => callback !== listener);
},
};
},
};
module.exports = SceneTracker;

View File

@@ -0,0 +1,30 @@
/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*
*/
'use strict';
jest.unmock('SceneTracker');
const SceneTracker = require('SceneTracker');
describe('setActiveScene', function() {
it('can handle multiple listeners and unsubscribe', function() {
const listeners = [jest.fn(), jest.fn(), jest.fn()];
const subscriptions = listeners.map(
(listener) => SceneTracker.addActiveSceneChangedListener(listener)
);
subscriptions[1].remove();
const newScene = {name: 'scene1'};
SceneTracker.setActiveScene(newScene);
expect(listeners[0]).toBeCalledWith(newScene);
expect(listeners[1]).not.toBeCalled();
expect(listeners[2]).toBeCalledWith(newScene);
});
});