Skip to content

Commit f72d9dd

Browse files
sahrensfacebook-github-bot
authored andcommitted
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
1 parent b5327dd commit f72d9dd

File tree

6 files changed

+387
-3
lines changed

6 files changed

+387
-3
lines changed

Libraries/Lists/FillRateHelper.js

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/**
2+
* Copyright (c) 2015-present, Facebook, Inc.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the BSD-style license found in the
6+
* LICENSE file in the root directory of this source tree. An additional grant
7+
* of patent rights can be found in the PATENTS file in the same directory.
8+
*
9+
* @providesModule FillRateHelper
10+
* @flow
11+
*/
12+
13+
'use strict';
14+
15+
const performanceNow = require('fbjs/lib/performanceNow');
16+
const warning = require('fbjs/lib/warning');
17+
18+
export type FillRateExceededInfo = {
19+
event: {
20+
sample_type: string,
21+
blankness: number,
22+
blank_pixels_top: number,
23+
blank_pixels_bottom: number,
24+
scroll_offset: number,
25+
visible_length: number,
26+
scroll_speed: number,
27+
first_frame: Object,
28+
last_frame: Object,
29+
},
30+
aggregate: {
31+
avg_blankness: number,
32+
min_speed_when_blank: number,
33+
avg_speed_when_blank: number,
34+
avg_blankness_when_any_blank: number,
35+
fraction_any_blank: number,
36+
all_samples_timespan_sec: number,
37+
fill_rate_sample_counts: {[key: string]: number},
38+
},
39+
};
40+
41+
type FrameMetrics = {inLayout?: boolean, length: number, offset: number};
42+
43+
let _listeners: Array<(FillRateExceededInfo) => void> = [];
44+
let _sampleRate = null;
45+
46+
/**
47+
* A helper class for detecting when the maximem fill rate of `VirtualizedList` is exceeded.
48+
* By default the sampling rate is set to zero and this will do nothing. If you want to collect
49+
* samples (e.g. to log them), make sure to call `FillRateHelper.setSampleRate(0.0-1.0)`.
50+
*
51+
* Listeners and sample rate are global for all `VirtualizedList`s - typical usage will combine with
52+
* `SceneTracker.getActiveScene` to determine the context of the events.
53+
*/
54+
class FillRateHelper {
55+
_getFrameMetrics: (index: number) => ?FrameMetrics;
56+
_anyBlankCount = 0;
57+
_anyBlankMinSpeed = Infinity;
58+
_anyBlankSpeedSum = 0;
59+
_sampleCounts = {};
60+
_fractionBlankSum = 0;
61+
_samplesStartTime = 0;
62+
63+
static addFillRateExceededListener(
64+
callback: (FillRateExceededInfo) => void
65+
): {remove: () => void} {
66+
warning(
67+
_sampleRate !== null,
68+
'Call `FillRateHelper.setSampleRate` before `addFillRateExceededListener`.'
69+
);
70+
_listeners.push(callback);
71+
return {
72+
remove: () => {
73+
_listeners = _listeners.filter((listener) => callback !== listener);
74+
},
75+
};
76+
}
77+
78+
static setSampleRate(sampleRate: number) {
79+
_sampleRate = sampleRate;
80+
}
81+
82+
static enabled(): boolean {
83+
return (_sampleRate || 0) > 0.0;
84+
}
85+
86+
constructor(getFrameMetrics: (index: number) => ?FrameMetrics) {
87+
this._getFrameMetrics = getFrameMetrics;
88+
}
89+
90+
computeInfoSampled(
91+
sampleType: string,
92+
props: {
93+
data: Array<any>,
94+
getItemCount: (data: Array<any>) => number,
95+
initialNumToRender: number,
96+
},
97+
state: {
98+
first: number,
99+
last: number,
100+
},
101+
scrollMetrics: {
102+
offset: number,
103+
velocity: number,
104+
visibleLength: number,
105+
},
106+
): ?FillRateExceededInfo {
107+
if (!FillRateHelper.enabled() || (_sampleRate || 0) <= Math.random()) {
108+
return null;
109+
}
110+
const start = performanceNow();
111+
if (!this._samplesStartTime) {
112+
this._samplesStartTime = start;
113+
}
114+
const {offset, velocity, visibleLength} = scrollMetrics;
115+
let blankTop = 0;
116+
let first = state.first;
117+
let firstFrame = this._getFrameMetrics(first);
118+
while (first <= state.last && (!firstFrame || !firstFrame.inLayout)) {
119+
firstFrame = this._getFrameMetrics(first);
120+
first++;
121+
}
122+
if (firstFrame) {
123+
blankTop = Math.min(visibleLength, Math.max(0, firstFrame.offset - offset));
124+
}
125+
let blankBottom = 0;
126+
let last = state.last;
127+
let lastFrame = this._getFrameMetrics(last);
128+
while (last >= state.first && (!lastFrame || !lastFrame.inLayout)) {
129+
lastFrame = this._getFrameMetrics(last);
130+
last--;
131+
}
132+
if (lastFrame) {
133+
const bottomEdge = lastFrame.offset + lastFrame.length;
134+
blankBottom = Math.min(visibleLength, Math.max(0, offset + visibleLength - bottomEdge));
135+
}
136+
this._sampleCounts.all = (this._sampleCounts.all || 0) + 1;
137+
this._sampleCounts[sampleType] = (this._sampleCounts[sampleType] || 0) + 1;
138+
const blankness = (blankTop + blankBottom) / visibleLength;
139+
if (blankness > 0) {
140+
const scrollSpeed = Math.abs(velocity);
141+
if (scrollSpeed && sampleType === 'onScroll') {
142+
this._anyBlankMinSpeed = Math.min(this._anyBlankMinSpeed, scrollSpeed);
143+
}
144+
this._anyBlankSpeedSum += scrollSpeed;
145+
this._anyBlankCount++;
146+
this._fractionBlankSum += blankness;
147+
const event = {
148+
sample_type: sampleType,
149+
blankness: blankness,
150+
blank_pixels_top: blankTop,
151+
blank_pixels_bottom: blankBottom,
152+
scroll_offset: offset,
153+
visible_length: visibleLength,
154+
scroll_speed: scrollSpeed,
155+
first_frame: {...firstFrame},
156+
last_frame: {...lastFrame},
157+
};
158+
const aggregate = {
159+
avg_blankness: this._fractionBlankSum / this._sampleCounts.all,
160+
min_speed_when_blank: this._anyBlankMinSpeed,
161+
avg_speed_when_blank: this._anyBlankSpeedSum / this._anyBlankCount,
162+
avg_blankness_when_any_blank: this._fractionBlankSum / this._anyBlankCount,
163+
fraction_any_blank: this._anyBlankCount / this._sampleCounts.all,
164+
all_samples_timespan_sec: (performanceNow() - this._samplesStartTime) / 1000.0,
165+
fill_rate_sample_counts: {...this._sampleCounts},
166+
compute_time: performanceNow() - start,
167+
};
168+
const info = {event, aggregate};
169+
_listeners.forEach((listener) => listener(info));
170+
return info;
171+
}
172+
return null;
173+
}
174+
}
175+
176+
module.exports = FillRateHelper;

Libraries/Lists/VirtualizedList.js

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
'use strict';
1313

1414
const Batchinator = require('Batchinator');
15+
const FillRateHelper = require('FillRateHelper');
1516
const React = require('React');
1617
const ReactNative = require('ReactNative');
1718
const RefreshControl = require('RefreshControl');
@@ -27,6 +28,7 @@ const {computeWindowedRenderLimits} = require('VirtualizeUtils');
2728
import type {ViewabilityConfig, ViewToken} from 'ViewabilityHelper';
2829

2930
type Item = any;
31+
3032
type renderItemType = (info: {item: Item, index: number}) => ?React.Element<any>;
3133

3234
type RequiredProps = {
@@ -301,6 +303,7 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, State> {
301303
'to support native onScroll events with useNativeDriver',
302304
);
303305

306+
this._fillRateHelper = new FillRateHelper(this._getFrameMetrics);
304307
this._updateCellsToRenderBatcher = new Batchinator(
305308
this._updateCellsToRender,
306309
this.props.updateCellsBatchingPeriod,
@@ -366,6 +369,7 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, State> {
366369
}
367370
}
368371
}
372+
369373
render() {
370374
const {ListFooterComponent, ListHeaderComponent} = this.props;
371375
const {data, disableVirtualization, horizontal} = this.props;
@@ -481,6 +485,7 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, State> {
481485
_hasWarned = {};
482486
_highestMeasuredFrameIndex = 0;
483487
_headerLength = 0;
488+
_fillRateHelper: FillRateHelper;
484489
_frames = {};
485490
_footerLength = 0;
486491
_scrollMetrics = {
@@ -520,6 +525,7 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, State> {
520525
} else {
521526
this._frames[cellKey].inLayout = true;
522527
}
528+
this._sampleFillRate('onCellLayout');
523529
}
524530

525531
_onCellUnmount = (cellKey: string) => {
@@ -606,6 +612,15 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, State> {
606612
this._updateCellsToRenderBatcher.schedule();
607613
};
608614

615+
_sampleFillRate(sampleType: string) {
616+
this._fillRateHelper.computeInfoSampled(
617+
sampleType,
618+
this.props,
619+
this.state,
620+
this._scrollMetrics,
621+
);
622+
}
623+
609624
_onScroll = (e: Object) => {
610625
if (this.props.onScroll) {
611626
this.props.onScroll(e);
@@ -629,6 +644,9 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, State> {
629644
const velocity = dOffset / dt;
630645
this._scrollMetrics = {contentLength, dt, offset, timestamp, velocity, visibleLength};
631646
const {data, getItemCount, onEndReached, onEndReachedThreshold, windowSize} = this.props;
647+
648+
this._sampleFillRate('onScroll');
649+
632650
this._updateViewableItems(data);
633651
if (!data) {
634652
return;
@@ -667,6 +685,7 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, State> {
667685
this._viewabilityHelper.recordInteraction();
668686
this.props.onScrollBeginDrag && this.props.onScrollBeginDrag(e);
669687
};
688+
670689
_updateCellsToRender = () => {
671690
const {data, disableVirtualization, getItemCount, onEndReachedThreshold} = this.props;
672691
this._updateViewableItems(data);
@@ -717,7 +736,9 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, State> {
717736
}
718737
};
719738

720-
_getFrameMetrics = (index: number): ?{length: number, offset: number, index: number} => {
739+
_getFrameMetrics = (
740+
index: number,
741+
): ?{length: number, offset: number, index: number, inLayout?: boolean} => {
721742
const {data, getItem, getItemCount, getItemLayout, keyExtractor} = this.props;
722743
invariant(getItemCount(data) > index, 'Tried to get frame for out of range index ' + index);
723744
const item = getItem(data, index);
@@ -767,7 +788,9 @@ class CellRenderer extends React.Component {
767788
const {renderItem, getItemLayout} = parentProps;
768789
invariant(renderItem, 'no renderItem!');
769790
const element = renderItem({item, index});
770-
if (getItemLayout && !parentProps.debug) {
791+
if (getItemLayout &&
792+
!parentProps.debug &&
793+
!FillRateHelper.enabled()) {
771794
return element;
772795
}
773796
// NOTE: that when this is a sticky header, `onLayout` will get automatically extracted and
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* Copyright (c) 2015-present, Facebook, Inc.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the BSD-style license found in the
6+
* LICENSE file in the root directory of this source tree. An additional grant
7+
* of patent rights can be found in the PATENTS file in the same directory.
8+
*
9+
*/
10+
'use strict';
11+
12+
jest.unmock('FillRateHelper');
13+
14+
const FillRateHelper = require('FillRateHelper');
15+
16+
let rowFramesGlobal;
17+
const dataGlobal = [{key: 'a'}, {key: 'b'}, {key: 'c'}, {key: 'd'}];
18+
function getFrameMetrics(index: number) {
19+
const frame = rowFramesGlobal[dataGlobal[index].key];
20+
return {length: frame.height, offset: frame.y, inLayout: frame.inLayout};
21+
}
22+
23+
function computeResult({helper, props, state, scroll}) {
24+
return helper.computeInfoSampled(
25+
'test',
26+
{
27+
data: dataGlobal,
28+
fillRateTrackingSampleRate: 1,
29+
getItemCount: (data2) => data2.length,
30+
initialNumToRender: 10,
31+
...(props || {}),
32+
},
33+
{first: 0, last: 1, ...(state || {})},
34+
{offset: 0, visibleLength: 100, ...(scroll || {})},
35+
);
36+
}
37+
38+
describe('computeInfoSampled', function() {
39+
beforeEach(() => {
40+
FillRateHelper.setSampleRate(1);
41+
});
42+
43+
it('computes correct blankness of viewport', function() {
44+
const helper = new FillRateHelper(getFrameMetrics);
45+
rowFramesGlobal = {
46+
a: {y: 0, height: 50, inLayout: true},
47+
b: {y: 50, height: 50, inLayout: true},
48+
};
49+
let result = computeResult({helper});
50+
expect(result).toBeNull();
51+
result = computeResult({helper, state: {last: 0}});
52+
expect(result.event.blankness).toBe(0.5);
53+
result = computeResult({helper, scroll: {offset: 25}});
54+
expect(result.event.blankness).toBe(0.25);
55+
result = computeResult({helper, scroll: {visibleLength: 400}});
56+
expect(result.event.blankness).toBe(0.75);
57+
result = computeResult({helper, scroll: {offset: 100}});
58+
expect(result.event.blankness).toBe(1);
59+
expect(result.aggregate.avg_blankness).toBe(0.5);
60+
});
61+
62+
it('skips frames that are not in layout', function() {
63+
const helper = new FillRateHelper(getFrameMetrics);
64+
rowFramesGlobal = {
65+
a: {y: 0, height: 10, inLayout: false},
66+
b: {y: 10, height: 30, inLayout: true},
67+
c: {y: 40, height: 40, inLayout: true},
68+
d: {y: 80, height: 20, inLayout: false},
69+
};
70+
const result = computeResult({helper, state: {last: 3}});
71+
expect(result.event.blankness).toBe(0.3);
72+
});
73+
74+
it('sampling rate can disable', function() {
75+
const helper = new FillRateHelper(getFrameMetrics);
76+
rowFramesGlobal = {
77+
a: {y: 0, height: 40, inLayout: true},
78+
b: {y: 40, height: 40, inLayout: true},
79+
};
80+
let result = computeResult({helper});
81+
expect(result.event.blankness).toBe(0.2);
82+
83+
FillRateHelper.setSampleRate(0);
84+
85+
result = computeResult({helper});
86+
expect(result).toBeNull();
87+
});
88+
89+
it('can handle multiple listeners and unsubscribe', function() {
90+
const listeners = [jest.fn(), jest.fn(), jest.fn()];
91+
const subscriptions = listeners.map(
92+
(listener) => FillRateHelper.addFillRateExceededListener(listener)
93+
);
94+
subscriptions[1].remove();
95+
const helper = new FillRateHelper(getFrameMetrics);
96+
rowFramesGlobal = {
97+
a: {y: 0, height: 40, inLayout: true},
98+
b: {y: 40, height: 40, inLayout: true},
99+
};
100+
const result = computeResult({helper});
101+
expect(result.event.blankness).toBe(0.2);
102+
expect(listeners[0]).toBeCalledWith(result);
103+
expect(listeners[1]).not.toBeCalled();
104+
expect(listeners[2]).toBeCalledWith(result);
105+
});
106+
});

0 commit comments

Comments
 (0)