mirror of
https://github.com/zhigang1992/react-native.git
synced 2026-05-15 09:27:05 +08:00
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:
committed by
Facebook Github Bot
parent
b5327dd388
commit
f72d9dd08b
176
Libraries/Lists/FillRateHelper.js
Normal file
176
Libraries/Lists/FillRateHelper.js
Normal 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;
|
||||
@@ -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
|
||||
|
||||
106
Libraries/Lists/__tests__/FillRateHelper-test.js
Normal file
106
Libraries/Lists/__tests__/FillRateHelper-test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
|
||||
41
Libraries/Utilities/SceneTracker.js
Normal file
41
Libraries/Utilities/SceneTracker.js
Normal 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;
|
||||
30
Libraries/Utilities/__tests__/SceneTracker-test.js
Normal file
30
Libraries/Utilities/__tests__/SceneTracker-test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user