/** * 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. */ package com.facebook.react.uimanager; import javax.annotation.Nullable; import android.util.SparseBooleanArray; import com.facebook.infer.annotation.Assertions; import com.facebook.react.bridge.ReadableMapKeySetIterator; /** * Class responsible for optimizing the native view hierarchy while still respecting the final UI * product specified by JS. Basically, JS sends us a hierarchy of nodes that, while easy to reason * about in JS, are very inefficient to translate directly to native views. This class sits in * between {@link UIManagerModule}, which directly receives view commands from JS, and * {@link UIViewOperationQueue}, which enqueues actual operations on the native view hierarchy. It * is able to take instructions from UIManagerModule and output instructions to the native view * hierarchy that achieve the same displayed UI but with fewer views. * * Currently this class is only used to remove layout-only views, that is to say views that only * affect the positions of their children but do not draw anything themselves. These views are * fairly common because 1) containers are used to do layouting via flexbox and 2) the return of * each Component#render() call in JS must be exactly one view, which means views are often wrapped * in a unnecessary layer of hierarchy. * * This optimization is implemented by keeping track of both the unoptimized JS hierarchy and the * optimized native hierarchy in {@link ReactShadowNode}. * * This optimization is important for view hierarchy depth (which can cause stack overflows during * view traversal for complex apps), memory usage, amount of time spent during GCs, * and time-to-display. * * Some examples of the optimizations this class will do based on commands from JS: * - Create a view with only layout props: a description of that view is created as a * {@link ReactShadowNode} in UIManagerModule, but this class will not output any commands to * create the view in the native view hierarchy. * - Update a layout-only view to have non-layout props: before issuing the updateShadowNode call * to the native view hierarchy, issue commands to create the view we optimized away move it into * the view hierarchy * - Manage the children of a view: multiple manageChildren calls for various parent views may be * issued to the native view hierarchy depending on where the views being added/removed are * attached in the optimized hierarchy */ public class NativeViewHierarchyOptimizer { private static final boolean ENABLED = true; private final UIViewOperationQueue mUIViewOperationQueue; private final ShadowNodeRegistry mShadowNodeRegistry; private final SparseBooleanArray mTagsWithLayoutVisited = new SparseBooleanArray(); public NativeViewHierarchyOptimizer( UIViewOperationQueue uiViewOperationQueue, ShadowNodeRegistry shadowNodeRegistry) { mUIViewOperationQueue = uiViewOperationQueue; mShadowNodeRegistry = shadowNodeRegistry; } /** * Handles a createView call. May or may not actually create a native view. */ public void handleCreateView( ReactShadowNode node, int rootViewTag, @Nullable CatalystStylesDiffMap initialProps) { if (!ENABLED) { int tag = node.getReactTag(); mUIViewOperationQueue.enqueueCreateView(rootViewTag, tag, node.getViewClass(), initialProps); return; } boolean isLayoutOnly = node.getViewClass().equals(ViewProps.VIEW_CLASS_NAME) && isLayoutOnlyAndCollapsable(initialProps); node.setIsLayoutOnly(isLayoutOnly); if (!isLayoutOnly) { mUIViewOperationQueue.enqueueCreateView( rootViewTag, node.getReactTag(), node.getViewClass(), initialProps); } } /** * Handles an updateView call. If a view transitions from being layout-only to not (or vice-versa) * this could result in some number of additional createView and manageChildren calls. If the * view is layout only, no updateView call will be dispatched to the native hierarchy. */ public void handleUpdateView( ReactShadowNode node, String className, CatalystStylesDiffMap props) { if (!ENABLED) { mUIViewOperationQueue.enqueueUpdateProperties(node.getReactTag(), className, props); return; } boolean needsToLeaveLayoutOnly = node.isLayoutOnly() && !isLayoutOnlyAndCollapsable(props); if (needsToLeaveLayoutOnly) { transitionLayoutOnlyViewToNativeView(node, props); } else if (!node.isLayoutOnly()) { mUIViewOperationQueue.enqueueUpdateProperties(node.getReactTag(), className, props); } } /** * Handles a manageChildren call. This may translate into multiple manageChildren calls for * multiple other views. * * NB: the assumption for calling this method is that all corresponding ReactShadowNodes have * been updated **but tagsToDelete have NOT been deleted yet**. This is because we need to use * the metadata from those nodes to figure out the correct commands to dispatch. This is unlike * all other calls on this class where we assume all operations on the shadow hierarchy have * already completed by the time a corresponding method here is called. */ public void handleManageChildren( ReactShadowNode nodeToManage, int[] indicesToRemove, int[] tagsToRemove, ViewAtIndex[] viewsToAdd, int[] tagsToDelete) { if (!ENABLED) { mUIViewOperationQueue.enqueueManageChildren( nodeToManage.getReactTag(), indicesToRemove, viewsToAdd, tagsToDelete); return; } // We operate on tagsToRemove instead of indicesToRemove because by the time this method is // called, these views have already been removed from the shadow hierarchy and the indices are // no longer useful to operate on for (int i = 0; i < tagsToRemove.length; i++) { int tagToRemove = tagsToRemove[i]; boolean delete = false; for (int j = 0; j < tagsToDelete.length; j++) { if (tagsToDelete[j] == tagToRemove) { delete = true; break; } } ReactShadowNode nodeToRemove = mShadowNodeRegistry.getNode(tagToRemove); removeNodeFromParent(nodeToRemove, delete); } for (int i = 0; i < viewsToAdd.length; i++) { ViewAtIndex toAdd = viewsToAdd[i]; ReactShadowNode nodeToAdd = mShadowNodeRegistry.getNode(toAdd.mTag); addNodeToNode(nodeToManage, nodeToAdd, toAdd.mIndex); } } /** * Handles an updateLayout call. All updateLayout calls are collected and dispatched at the end * of a batch because updateLayout calls to layout-only nodes can necessitate multiple * updateLayout calls for all its children. */ public void handleUpdateLayout(ReactShadowNode node) { if (!ENABLED) { mUIViewOperationQueue.enqueueUpdateLayout( Assertions.assertNotNull(node.getParent()).getReactTag(), node.getReactTag(), node.getScreenX(), node.getScreenY(), node.getScreenWidth(), node.getScreenHeight()); return; } applyLayoutBase(node); } /** * Processes the shadow hierarchy to dispatch all necessary updateLayout calls to the native * hierarchy. Should be called after all updateLayout calls for a batch have been handled. */ public void onBatchComplete() { mTagsWithLayoutVisited.clear(); } private void addNodeToNode(ReactShadowNode parent, ReactShadowNode child, int index) { int indexInNativeChildren = parent.getNativeOffsetForChild(parent.getChildAt(index)); boolean parentIsLayoutOnly = parent.isLayoutOnly(); boolean childIsLayoutOnly = child.isLayoutOnly(); // Switch on the four cases of: // add (layout-only|not layout-only) to (layout-only|not layout-only) if (!parentIsLayoutOnly && !childIsLayoutOnly) { addNonLayoutNodeToNonLayoutNode(parent, child, indexInNativeChildren); } else if (!childIsLayoutOnly) { addNonLayoutOnlyNodeToLayoutOnlyNode(parent, child, indexInNativeChildren); } else if (!parentIsLayoutOnly) { addLayoutOnlyNodeToNonLayoutOnlyNode(parent, child, indexInNativeChildren); } else { addLayoutOnlyNodeToLayoutOnlyNode(parent, child, indexInNativeChildren); } } /** * For handling node removal from manageChildren. In the case of removing a layout-only node, we * need to instead recursively remove all its children from their native parents. */ private void removeNodeFromParent(ReactShadowNode nodeToRemove, boolean shouldDelete) { ReactShadowNode nativeNodeToRemoveFrom = nodeToRemove.getNativeParent(); if (nativeNodeToRemoveFrom != null) { int index = nativeNodeToRemoveFrom.indexOfNativeChild(nodeToRemove); nativeNodeToRemoveFrom.removeNativeChildAt(index); mUIViewOperationQueue.enqueueManageChildren( nativeNodeToRemoveFrom.getReactTag(), new int[]{index}, null, shouldDelete ? new int[]{nodeToRemove.getReactTag()} : null); } else { for (int i = nodeToRemove.getChildCount() - 1; i >= 0; i--) { removeNodeFromParent(nodeToRemove.getChildAt(i), shouldDelete); } } } private void addLayoutOnlyNodeToLayoutOnlyNode( ReactShadowNode parent, ReactShadowNode child, int index) { ReactShadowNode parentParent = parent.getParent(); // If the parent hasn't been attached to its parent yet, don't issue commands to the native // hierarchy. We'll do that when the parent node actually gets attached somewhere. if (parentParent == null) { return; } int transformedIndex = index + parentParent.getNativeOffsetForChild(parent); if (parentParent.isLayoutOnly()) { addLayoutOnlyNodeToLayoutOnlyNode(parentParent, child, transformedIndex); } else { addLayoutOnlyNodeToNonLayoutOnlyNode(parentParent, child, transformedIndex); } } private void addNonLayoutOnlyNodeToLayoutOnlyNode( ReactShadowNode layoutOnlyNode, ReactShadowNode nonLayoutOnlyNode, int index) { ReactShadowNode parent = layoutOnlyNode.getParent(); // If the parent hasn't been attached to its parent yet, don't issue commands to the native // hierarchy. We'll do that when the parent node actually gets attached somewhere. if (parent == null) { return; } int transformedIndex = index + parent.getNativeOffsetForChild(layoutOnlyNode); if (parent.isLayoutOnly()) { addNonLayoutOnlyNodeToLayoutOnlyNode(parent, nonLayoutOnlyNode, transformedIndex); } else { addNonLayoutNodeToNonLayoutNode(parent, nonLayoutOnlyNode, transformedIndex); } } private void addLayoutOnlyNodeToNonLayoutOnlyNode( ReactShadowNode nonLayoutOnlyNode, ReactShadowNode layoutOnlyNode, int index) { // Add all of the layout-only node's children to its parent instead int currentIndex = index; for (int i = 0; i < layoutOnlyNode.getChildCount(); i++) { ReactShadowNode childToAdd = layoutOnlyNode.getChildAt(i); Assertions.assertCondition(childToAdd.getNativeParent() == null); if (childToAdd.isLayoutOnly()) { // Adding this layout-only child could result in adding multiple native views int childCountBefore = nonLayoutOnlyNode.getNativeChildCount(); addLayoutOnlyNodeToNonLayoutOnlyNode( nonLayoutOnlyNode, childToAdd, currentIndex); int childCountAfter = nonLayoutOnlyNode.getNativeChildCount(); currentIndex += childCountAfter - childCountBefore; } else { addNonLayoutNodeToNonLayoutNode(nonLayoutOnlyNode, childToAdd, currentIndex); currentIndex++; } } } private void addNonLayoutNodeToNonLayoutNode( ReactShadowNode parent, ReactShadowNode child, int index) { parent.addNativeChildAt(child, index); mUIViewOperationQueue.enqueueManageChildren( parent.getReactTag(), null, new ViewAtIndex[]{new ViewAtIndex(child.getReactTag(), index)}, null); } private void applyLayoutBase(ReactShadowNode node) { int tag = node.getReactTag(); if (mTagsWithLayoutVisited.get(tag)) { return; } mTagsWithLayoutVisited.put(tag, true); ReactShadowNode parent = node.getParent(); // We use screenX/screenY (which round to integer pixels) at each node in the hierarchy to // emulate what the layout would look like if it were actually built with native views which // have to have integral top/left/bottom/right values int x = node.getScreenX(); int y = node.getScreenY(); while (parent != null && parent.isLayoutOnly()) { // TODO(7854667): handle and test proper clipping x += Math.round(parent.getLayoutX()); y += Math.round(parent.getLayoutY()); parent = parent.getParent(); } applyLayoutRecursive(node, x, y); } private void applyLayoutRecursive(ReactShadowNode toUpdate, int x, int y) { if (!toUpdate.isLayoutOnly() && toUpdate.getNativeParent() != null) { int tag = toUpdate.getReactTag(); mUIViewOperationQueue.enqueueUpdateLayout( toUpdate.getNativeParent().getReactTag(), tag, x, y, toUpdate.getScreenWidth(), toUpdate.getScreenHeight()); return; } for (int i = 0; i < toUpdate.getChildCount(); i++) { ReactShadowNode child = toUpdate.getChildAt(i); int childTag = child.getReactTag(); if (mTagsWithLayoutVisited.get(childTag)) { continue; } mTagsWithLayoutVisited.put(childTag, true); int childX = child.getScreenX(); int childY = child.getScreenY(); childX += x; childY += y; applyLayoutRecursive(child, childX, childY); } } private void transitionLayoutOnlyViewToNativeView( ReactShadowNode node, @Nullable CatalystStylesDiffMap props) { ReactShadowNode parent = node.getParent(); if (parent == null) { node.setIsLayoutOnly(false); return; } // First, remove the node from its parent. This causes the parent to update its native children // count. The removeNodeFromParent call will cause all the view's children to be detached from // their native parent. int childIndex = parent.indexOf(node); parent.removeChildAt(childIndex); removeNodeFromParent(node, false); node.setIsLayoutOnly(false); // Create the view since it doesn't exist in the native hierarchy yet mUIViewOperationQueue.enqueueCreateView( node.getRootNode().getReactTag(), node.getReactTag(), node.getViewClass(), props); // Add the node and all its children as if we are adding a new nodes parent.addChildAt(node, childIndex); addNodeToNode(parent, node, childIndex); for (int i = 0; i < node.getChildCount(); i++) { addNodeToNode(node, node.getChildAt(i), i); } // Update layouts since the children of the node were offset by its x/y position previously. // Bit of a hack: we need to update the layout of this node's children now that it's no longer // layout-only, but we may still receive more layout updates at the end of this batch that we // don't want to ignore. Assertions.assertCondition(mTagsWithLayoutVisited.size() == 0); applyLayoutBase(node); for (int i = 0; i < node.getChildCount(); i++) { applyLayoutBase(node.getChildAt(i)); } mTagsWithLayoutVisited.clear(); } private static boolean isLayoutOnlyAndCollapsable(@Nullable CatalystStylesDiffMap props) { if (props == null) { return true; } if (props.hasKey(ViewProps.COLLAPSABLE) && !props.getBoolean(ViewProps.COLLAPSABLE, true)) { return false; } ReadableMapKeySetIterator keyIterator = props.mBackingMap.keySetIterator(); while (keyIterator.hasNextKey()) { if (!ViewProps.isLayoutOnly(keyIterator.nextKey())) { return false; } } return true; } }