From 205a35ad37ac99b6a1dbf8c0758ff2d063c54c3e Mon Sep 17 00:00:00 2001 From: Krzysztof Magiera Date: Fri, 20 Nov 2015 08:02:35 -0800 Subject: [PATCH] View recycling in JS. Summary: public Native view recycling implementation based on limited pools of views. In this diff I introduced new UIManager method: dropViews. Instead of removing views from tag->view maps when they are detached we keep them there until we get a call to dropViews with the appropriate tag. JS may keep a pool of object and selectively decide not to enqueue drop for certain views. Then instead of removing those views it may decide to reuse tag that has been previously allocated for a view that is no longer in use. Special handling is required for layout-only nodes as they only can transition from layout-only to non-layout-only (reverse transition hasn't been implemented). Because of that we'd loose benefits of view flattening if we decide to recycle existing non-layout-only view as a layout-only one. This diff provides only a simple and manual method for configuring pools by calling `ReactNativeViewPool.configure` with a dict from native view name to the view count. Note that we may not want recycle all the views (e.g. when we render mapview we don't want to keep it in memory after it's detached) Reviewed By: davidaurelio Differential Revision: D2677289 fb-gh-sync-id: 29f44ce5b01db3ec353522af051b6a50924614a2 --- .../ReactNative/ReactNativeBaseComponent.js | 21 +- Libraries/ReactNative/ReactNativeMount.js | 2 + .../ReactNativeReconcileTransaction.js | 9 +- Libraries/ReactNative/ReactNativeViewPool.js | 265 ++++++++++++++++++ .../uimanager/NativeViewHierarchyManager.java | 15 +- .../NativeViewHierarchyOptimizer.java | 4 + .../react/uimanager/UIManagerModule.java | 18 +- .../uimanager/UIManagerModuleConstants.java | 8 + .../UIManagerModuleConstantsHelper.java | 2 + .../react/uimanager/UIViewOperationQueue.java | 23 ++ .../facebook/react/uimanager/ViewProps.java | 2 +- 11 files changed, 342 insertions(+), 27 deletions(-) create mode 100644 Libraries/ReactNative/ReactNativeViewPool.js diff --git a/Libraries/ReactNative/ReactNativeBaseComponent.js b/Libraries/ReactNative/ReactNativeBaseComponent.js index cfd3df3e8..b187a39f0 100644 --- a/Libraries/ReactNative/ReactNativeBaseComponent.js +++ b/Libraries/ReactNative/ReactNativeBaseComponent.js @@ -16,6 +16,7 @@ var ReactNativeAttributePayload = require('ReactNativeAttributePayload'); var ReactNativeEventEmitter = require('ReactNativeEventEmitter'); var ReactNativeStyleAttributes = require('ReactNativeStyleAttributes'); var ReactNativeTagHandles = require('ReactNativeTagHandles'); +var ReactNativeViewPool = require('ReactNativeViewPool'); var ReactMultiChild = require('ReactMultiChild'); var RCTUIManager = require('NativeModules').UIManager; @@ -88,6 +89,7 @@ ReactNativeBaseComponent.Mixin = { unmountComponent: function() { deleteAllListeners(this._rootNodeID); this.unmountChildren(); + ReactNativeViewPool.release(this); this._rootNodeID = null; }, @@ -204,24 +206,7 @@ ReactNativeBaseComponent.Mixin = { mountComponent: function(rootID, transaction, context) { this._rootNodeID = rootID; - var tag = ReactNativeTagHandles.allocateTag(); - - if (__DEV__) { - deepFreezeAndThrowOnMutationInDev(this._currentElement.props); - } - - var updatePayload = ReactNativeAttributePayload.create( - this._currentElement.props, - this.viewConfig.validAttributes - ); - - var nativeTopRootID = ReactNativeTagHandles.getNativeTopRootIDFromNodeID(rootID); - RCTUIManager.createView( - tag, - this.viewConfig.uiViewClassName, - nativeTopRootID ? ReactNativeTagHandles.rootNodeIDToTag[nativeTopRootID] : null, - updatePayload - ); + var tag = ReactNativeViewPool.acquire(this); this._registerListenersUponCreation(this._currentElement.props); this.initializeChildren( diff --git a/Libraries/ReactNative/ReactNativeMount.js b/Libraries/ReactNative/ReactNativeMount.js index 02dbcb40f..aeef4cba7 100644 --- a/Libraries/ReactNative/ReactNativeMount.js +++ b/Libraries/ReactNative/ReactNativeMount.js @@ -15,6 +15,7 @@ var RCTUIManager = require('NativeModules').UIManager; var ReactElement = require('ReactElement'); var ReactNativeTagHandles = require('ReactNativeTagHandles'); +var ReactNativeViewPool = require('ReactNativeViewPool'); var ReactPerf = require('ReactPerf'); var ReactReconciler = require('ReactReconciler'); var ReactUpdateQueue = require('ReactUpdateQueue'); @@ -216,6 +217,7 @@ var ReactNativeMount = { ReactNativeMount.unmountComponentAtNode(containerTag); // call back into native to remove all of the subviews from this container RCTUIManager.removeRootView(containerTag); + ReactNativeViewPool.clearPoolForRootView(containerTag); }, /** diff --git a/Libraries/ReactNative/ReactNativeReconcileTransaction.js b/Libraries/ReactNative/ReactNativeReconcileTransaction.js index 309630e3c..aff41081d 100644 --- a/Libraries/ReactNative/ReactNativeReconcileTransaction.js +++ b/Libraries/ReactNative/ReactNativeReconcileTransaction.js @@ -14,6 +14,7 @@ var CallbackQueue = require('CallbackQueue'); var PooledClass = require('PooledClass'); var Transaction = require('Transaction'); +var ReactNativeViewPool = require('ReactNativeViewPool'); /** * Provides a `CallbackQueue` queue for collecting `onDOMReady` callbacks during @@ -35,12 +36,18 @@ var ON_DOM_READY_QUEUEING = { } }; +var RN_VIEW_POOL_WRAPPER = { + close: function() { + ReactNativeViewPool.onReconcileTransactionClose(); + }, +}; + /** * Executed within the scope of the `Transaction` instance. Consider these as * being member methods, but with an implied ordering while being isolated from * each other. */ -var TRANSACTION_WRAPPERS = [ON_DOM_READY_QUEUEING]; +var TRANSACTION_WRAPPERS = [ON_DOM_READY_QUEUEING, RN_VIEW_POOL_WRAPPER]; /** * Currently: diff --git a/Libraries/ReactNative/ReactNativeViewPool.js b/Libraries/ReactNative/ReactNativeViewPool.js new file mode 100644 index 000000000..968449d8b --- /dev/null +++ b/Libraries/ReactNative/ReactNativeViewPool.js @@ -0,0 +1,265 @@ +/** + * 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 ReactNativeViewPool + * @flow + */ +'use strict'; + +var ReactNativeTagHandles = require('ReactNativeTagHandles'); +var ReactNativeAttributePayload = require('ReactNativeAttributePayload'); +var RCTUIManager = require('NativeModules').UIManager; +var Platform = require('Platform'); + +var deepFreezeAndThrowOnMutationInDev = require('deepFreezeAndThrowOnMutationInDev'); +var emptyFunction = require('emptyFunction'); +var flattenStyle = require('flattenStyle'); + +var EMPTY_POOL = [[]]; + +var ENABLED = !!RCTUIManager.dropViews; + +/* indicies used for _addToPool arrays */ +var TAGS_IDX = 0; +var KEYS_IDX = 1; +var PROPS_IDX = 2; + +var _pools = {}; +var _poolSize = {}; + +var layoutOnlyProps = RCTUIManager.layoutOnlyProps; + +function isCollapsableForStyle(style) { + var flatStyle = flattenStyle(style); + for (var styleKey in flatStyle) { + if (layoutOnlyProps[styleKey] !== true) { + return false; + } + } + return true; +} + +function isCollapsable(viewRef) { + var props = viewRef._currentElement.props; + if (props.collapsable !== undefined && !props.collapsable) { + return false; + } + var validAttributes = viewRef.viewConfig.validAttributes; + for (var propKey in props) { + if (!!validAttributes[propKey] && propKey !== 'style' && propKey !== 'collapsable') { + return false; + } + } + return !props.style || isCollapsableForStyle(viewRef._currentElement.props.style); +} + +function enqueueCreate(viewRef, rootTag) { + var tag = ReactNativeTagHandles.allocateTag(); + + if (__DEV__) { + deepFreezeAndThrowOnMutationInDev(viewRef._currentElement.props); + } + + var updatePayload = ReactNativeAttributePayload.create( + viewRef._currentElement.props, + viewRef.viewConfig.validAttributes + ); + + RCTUIManager.createView( + tag, + viewRef.viewConfig.uiViewClassName, + rootTag, + updatePayload + ); + + return tag; +} + +function getViewTag(viewRef) { + return ReactNativeTagHandles.mostRecentMountedNodeHandleForRootNodeID(viewRef._rootNodeID); +} + +function getViewProps(viewRef) { + return viewRef._currentElement.props; +} + +function getViewValidAttributes(viewRef) { + return viewRef.viewConfig.validAttributes; +} + +function getRootViewTag(viewRef) { + var nativeTopRootID = ReactNativeTagHandles.getNativeTopRootIDFromNodeID(viewRef._rootNodeID); + return ReactNativeTagHandles.rootNodeIDToTag[nativeTopRootID]; +} + +function poolKey(viewRef) { + var viewClass = viewRef.viewConfig.uiViewClassName; + if (Platform.OS === 'android' && viewClass === 'RCTView') { + return isCollapsable(viewRef) ? 'CollapsedRCTView' : 'RCTView'; + } + return viewClass; +} + +class ReactNativeViewPool { + constructor() { + this._pool = {}; + this._poolQueue = {}; + this._addToPool = [[],[],[]]; + this._viewsToDelete = []; + if (__DEV__) { + this._recycleStats = {}; + this._deleteStats = {}; + } + } + + onReconcileTransactionClose() { + // flush all deletes, move object from pool_queue to the actual pool + if (this._viewsToDelete.length > 0) { + RCTUIManager.dropViews(this._viewsToDelete); + } + var addToPoolTags = this._addToPool[TAGS_IDX]; + var addToPoolKeys = this._addToPool[KEYS_IDX]; + var addToPoolProps = this._addToPool[PROPS_IDX]; + for (var i = addToPoolTags.length - 1; i >= 0; i--) { + var nativeTag = addToPoolTags[i]; + var key = addToPoolKeys[i]; + var props = addToPoolProps[i]; + var views = this._pool[key] || [[],[]]; + views[0].push(nativeTag); + views[1].push(props); + this._pool[key] = views; + } + this._viewsToDelete = []; + this._addToPool = [[],[],[]]; + this._poolQueue = {}; + } + + acquire(viewRef, rootTag) { + var key = poolKey(viewRef); + if ((this._pool[key] || EMPTY_POOL)[0].length) { + var views = this._pool[key]; + var nativeTag = views[0].pop(); + var oldProps = views[1].pop(); + var updatePayload = ReactNativeAttributePayload.diff( + oldProps, + getViewProps(viewRef), + getViewValidAttributes(viewRef) + ); + if (__DEV__) { + this._recycleStats[key] = (this._recycleStats[key] || 0) + 1; + } + + if (updatePayload) { + RCTUIManager.updateView( + nativeTag, + viewRef.viewConfig.uiViewClassName, + updatePayload + ); + } + return nativeTag; + } else { + // If there is no view available for the given pool key, we just enqueue call to create one + return enqueueCreate(viewRef, rootTag); + } + } + + release(viewRef) { + var key = poolKey(viewRef); + var nativeTag = getViewTag(viewRef); + var pooledCount = (this._pool[key] || EMPTY_POOL)[0].length + (this._poolQueue[key] || 0); + if (pooledCount < (_poolSize[key] || 0)) { + // we have room in the pool for this view + // we can add it to the queue so that it will be added to the actual pull in + // onReconcileTransactionClose + this._addToPool[TAGS_IDX].push(nativeTag); + this._addToPool[KEYS_IDX].push(key); + this._addToPool[PROPS_IDX].push(getViewProps(viewRef)); + this._poolQueue[key] = (this._poolQueue[key] || 0) + 1; + } else { + if (__DEV__) { + if (_poolSize[key]) { + this._deleteStats[key] = (this._deleteStats[key] || 0) + 1; + } + } + this._viewsToDelete.push(nativeTag); + } + } + + clear() { + for (var key in this._pool) { + var poolTags = this._pool[key][0]; + for (var i = poolTags.length - 1; i >= 0; i--) { + this._viewsToDelete.push(poolTags[i]); + } + } + var addToPoolTags = this._addToPool[0]; + for (var i = addToPoolTags.length - 1; i >= 0; i--) { + this._viewsToDelete.push(addToPoolTags[i]); + } + this._addToPool = [[],[],[]]; + this.onReconcileTransactionClose(); + } + + printStats() { + if (__DEV__) { + console.log('Stats', this._recycleStats, this._deleteStats); + } + } +} + +module.exports = { + + onReconcileTransactionClose: function() { + if (ENABLED) { + for (var pool in _pools) { + _pools[pool].onReconcileTransactionClose(); + } + } + }, + + acquire: function(viewRef) { + var rootTag = getRootViewTag(viewRef); + if (ENABLED) { + var pool = _pools[rootTag]; + if (!pool) { + pool = _pools[rootTag] = new ReactNativeViewPool(); + } + return pool.acquire(viewRef, rootTag); + } else { + return enqueueCreate(viewRef, rootTag); + } + }, + + release: ENABLED ? function(viewRef) { + var pool = _pools[getRootViewTag(viewRef)]; + if (pool) { + pool.release(viewRef); + } + } : emptyFunction, + + clearPoolForRootView: ENABLED ? function(rootID) { + var pool = _pools[rootID]; + if (pool) { + pool.clear(); + delete _pools[rootID]; + } + } : emptyFunction, + + configure: function(pool_size) { + _poolSize = pool_size; + }, + + printStats: function() { + if (__DEV__) { + console.log('Pool size', _poolSize); + for (var pool in _pools) { + _pools[pool].onReconcileTransactionClose(); + } + } + }, +}; diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyManager.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyManager.java index 68a1e567f..0b7b7e1f0 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyManager.java @@ -349,7 +349,7 @@ import com.facebook.react.touch.JSResponderHandler; viewsToAdd, tagsToDelete)); } - dropView(viewToDestroy); + detachView(viewToDestroy); } } } @@ -378,10 +378,15 @@ import com.facebook.react.touch.JSResponderHandler; view.setId(tag); } + public void dropView(int tag) { + mTagsToViews.remove(tag); + mTagsToViewManagers.remove(tag); + } + /** * Releases all references to given native View. */ - private void dropView(View view) { + private void detachView(View view) { UiThreadUtil.assertOnUiThread(); if (!mRootTags.get(view.getId())) { // For non-root views we notify viewmanager with {@link ViewManager#onDropInstance} @@ -396,13 +401,11 @@ import com.facebook.react.touch.JSResponderHandler; for (int i = viewGroupManager.getChildCount(viewGroup) - 1; i >= 0; i--) { View child = viewGroupManager.getChildAt(viewGroup, i); if (mTagsToViews.get(child.getId()) != null) { - dropView(child); + detachView(child); } } viewGroupManager.removeAllViews(viewGroup); } - mTagsToViews.remove(view.getId()); - mTagsToViewManagers.remove(view.getId()); } public void removeRootView(int rootViewTag) { @@ -412,7 +415,7 @@ import com.facebook.react.touch.JSResponderHandler; "View with tag " + rootViewTag + " is not registered as a root view"); } View rootView = mTagsToViews.get(rootViewTag); - dropView(rootView); + detachView(rootView); mRootTags.delete(rootViewTag); mRootViewsContext.remove(rootViewTag); } diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyOptimizer.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyOptimizer.java index fd3946eeb..e01fab28d 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyOptimizer.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyOptimizer.java @@ -90,6 +90,10 @@ public class NativeViewHierarchyOptimizer { } } + public void handleDropViews(int[] viewTagsToDrop, int length) { + mUIViewOperationQueue.enqueueDropViews(viewTagsToDrop, length); + } + /** * Handles native children cleanup when css node is removed from hierarchy */ diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModule.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModule.java index a1d498e1c..6482a209b 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModule.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModule.java @@ -261,6 +261,23 @@ public class UIManagerModule extends ReactContextBaseJavaModule implements } } + @ReactMethod + public void dropViews(ReadableArray viewTags) { + int size = viewTags.size(), realViewsCount = 0; + int realViewTags[] = new int[size]; + for (int i = 0; i < size; i++) { + int tag = viewTags.getInt(i); + ReactShadowNode cssNode = mShadowNodeRegistry.getNode(tag); + if (!cssNode.isVirtual()) { + realViewTags[realViewsCount++] = tag; + } + mShadowNodeRegistry.removeNode(tag); + } + if (realViewsCount > 0) { + mNativeViewHierarchyOptimizer.handleDropViews(realViewTags, realViewsCount); + } + } + @ReactMethod public void updateView(int tag, String className, ReadableMap props) { ViewManager viewManager = mViewManagers.get(className); @@ -405,7 +422,6 @@ public class UIManagerModule extends ReactContextBaseJavaModule implements private void removeShadowNode(ReactShadowNode nodeToRemove) { mNativeViewHierarchyOptimizer.handleRemoveNode(nodeToRemove); - mShadowNodeRegistry.removeNode(nodeToRemove.getReactTag()); for (int i = nodeToRemove.getChildCount() - 1; i >= 0; i--) { removeShadowNode(nodeToRemove.getChildAt(i)); } diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModuleConstants.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModuleConstants.java index 3287ebf78..eab1c3489 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModuleConstants.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModuleConstants.java @@ -154,4 +154,12 @@ import com.facebook.react.uimanager.events.TouchEventType; return constants; } + + public static Map getLayoutOnlyPropsConstants() { + HashMap constants = new HashMap<>(); + for (String propName : ViewProps.LAYOUT_ONLY_PROPS) { + constants.put(propName, Boolean.TRUE); + } + return constants; + } } diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModuleConstantsHelper.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModuleConstantsHelper.java index bce7c3736..d5b566529 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModuleConstantsHelper.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModuleConstantsHelper.java @@ -25,6 +25,7 @@ import com.facebook.react.common.MapBuilder; private static final String CUSTOM_BUBBLING_EVENT_TYPES_KEY = "customBubblingEventTypes"; private static final String CUSTOM_DIRECT_EVENT_TYPES_KEY = "customDirectEventTypes"; + private static final String LAYOUT_ONLY_PROPS = "layoutOnlyProps"; /** * Generates map of constants that is then exposed by {@link UIManagerModule}. The constants map @@ -75,6 +76,7 @@ import com.facebook.react.common.MapBuilder; constants.put(CUSTOM_BUBBLING_EVENT_TYPES_KEY, bubblingEventTypesConstants); constants.put(CUSTOM_DIRECT_EVENT_TYPES_KEY, directEventTypesConstants); + constants.put(LAYOUT_ONLY_PROPS, UIManagerModuleConstants.getLayoutOnlyPropsConstants()); return constants; } diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIViewOperationQueue.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIViewOperationQueue.java index 739558938..bb9cea705 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIViewOperationQueue.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIViewOperationQueue.java @@ -142,6 +142,25 @@ public class UIViewOperationQueue { } } + private final class DropViewsOperation extends ViewOperation { + + private final int[] mViewTagsToDrop; + private final int mArrayLength; + + public DropViewsOperation(int[] viewTagsToDrop, int length) { + super(-1); + mViewTagsToDrop = viewTagsToDrop; + mArrayLength = length; + } + + @Override + public void execute() { + for (int i = 0; i < mArrayLength; i++) { + mNativeViewHierarchyManager.dropView(mViewTagsToDrop[i]); + } + } + } + private final class ManageChildrenOperation extends ViewOperation { private final @Nullable int[] mIndicesToRemove; @@ -502,6 +521,10 @@ public class UIViewOperationQueue { initialProps)); } + public void enqueueDropViews(int[] viewTagsToDrop, int length) { + mOperations.add(new DropViewsOperation(viewTagsToDrop, length)); + } + public void enqueueUpdateProperties(int reactTag, String className, CatalystStylesDiffMap props) { mOperations.add(new UpdatePropertiesOperation(reactTag, props)); } diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.java index 7e87a8419..d19ea165e 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.java @@ -84,7 +84,7 @@ public class ViewProps { Spacing.BOTTOM }; - private static final HashSet LAYOUT_ONLY_PROPS = new HashSet<>( + /*package*/ static final HashSet LAYOUT_ONLY_PROPS = new HashSet<>( Arrays.asList( ALIGN_SELF, ALIGN_ITEMS,