diff --git a/Libraries/Lists/FillRateHelper.js b/Libraries/Lists/FillRateHelper.js new file mode 100644 index 000000000..745e21cac --- /dev/null +++ b/Libraries/Lists/FillRateHelper.js @@ -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, + getItemCount: (data: Array) => 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; diff --git a/Libraries/Lists/VirtualizedList.js b/Libraries/Lists/VirtualizedList.js index 13d83ce22..93205d220 100644 --- a/Libraries/Lists/VirtualizedList.js +++ b/Libraries/Lists/VirtualizedList.js @@ -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; type RequiredProps = { @@ -301,6 +303,7 @@ class VirtualizedList extends React.PureComponent { '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 { } } } + render() { const {ListFooterComponent, ListHeaderComponent} = this.props; const {data, disableVirtualization, horizontal} = this.props; @@ -481,6 +485,7 @@ class VirtualizedList extends React.PureComponent { _hasWarned = {}; _highestMeasuredFrameIndex = 0; _headerLength = 0; + _fillRateHelper: FillRateHelper; _frames = {}; _footerLength = 0; _scrollMetrics = { @@ -520,6 +525,7 @@ class VirtualizedList extends React.PureComponent { } else { this._frames[cellKey].inLayout = true; } + this._sampleFillRate('onCellLayout'); } _onCellUnmount = (cellKey: string) => { @@ -606,6 +612,15 @@ class VirtualizedList extends React.PureComponent { 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 { 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 { 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 { } }; - _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 diff --git a/Libraries/Lists/__tests__/FillRateHelper-test.js b/Libraries/Lists/__tests__/FillRateHelper-test.js new file mode 100644 index 000000000..9a0792367 --- /dev/null +++ b/Libraries/Lists/__tests__/FillRateHelper-test.js @@ -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); + }); +}); diff --git a/Libraries/ReactNative/AppRegistry.js b/Libraries/ReactNative/AppRegistry.js index b68a293b2..6febc878b 100644 --- a/Libraries/ReactNative/AppRegistry.js +++ b/Libraries/ReactNative/AppRegistry.js @@ -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 = 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); }, diff --git a/Libraries/Utilities/SceneTracker.js b/Libraries/Utilities/SceneTracker.js new file mode 100644 index 000000000..568bf62aa --- /dev/null +++ b/Libraries/Utilities/SceneTracker.js @@ -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; diff --git a/Libraries/Utilities/__tests__/SceneTracker-test.js b/Libraries/Utilities/__tests__/SceneTracker-test.js new file mode 100644 index 000000000..c27a1b366 --- /dev/null +++ b/Libraries/Utilities/__tests__/SceneTracker-test.js @@ -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); + }); +});