Files
react-native/ReactAndroid/src/main/java/com/facebook/react/flat/ClippingDrawCommandManager.java
T
Ahmed El-Helw 147989cd96 Fix a Nodes crash when double detaching a clipped view
Summary:
This fixes a crash for the case when we try to drop a view that has already been dropped.

**The Problem**

We got reports of a crash (t12912526) that occurs when the resolveViewManager method can't resolve a ViewManager for a View being dropped.

Investigating this, one thing in common between all the stack traces for this is that dropView is called from line 210 of FlatNativeViewHierarchyManager. This part of the code is specifically the part we added to remove strong references to any clipped children (from views that have subview clipping enabled).

So this is a problem specifically with Nodes and clipSubviews, which brings up some questions:

**when can this happen?**

The only situation this can possibly happen is when we drop a child (which is clipped) followed by dropping its parent in the same cycle. Consider a tree where each view only has one child, such as:  A - B - C - D. This crash would happen if D is clipped, and we removed it, followed by removing any of its parents in the same frame.

**if the removes happen in different frames, does this bug occur?**

No - the reason is that before we execute the DropView operations, we run through StateBuilder, which traverses the shadow tree and marks updates, thus removing the view going away (such that the delete in the next frame doesn't try to re-delete it).

So why doesn't this happen when we're dropping in the same frame? The reason is that manageChildren (where this all starts) asks to remove some views. We handle this by removing said Nodes and their children from the shadow tree. Consequently, when StateBuilder iterates over the shadow tree, it can't do the right thing because said nodes no longer exist.

As a more concrete example, consider A - B - C - D again, and consider that both D and B are removed. StateBuilder only sees A, and realizes that it now has 0 children (whereas before it has 1), so it removes B from its children. However, this process isn't recursive, so C never gets cleaned up.

**why doesn't this happen with Nodes without clipping containers?**

The answer to this is that NativeViewHierarchyManager's dropView method checks the existance of each child before deeply dropping that child and its subtree. So in this case, we drop D and all its children, and when we come to drop B, we try to drop C (which exists) and then its children (D, which doesn't exist because we already dropped it, so we ignore it).

**why doesn't this happen with non-Nodes?**

The reason is that non-Nodes handles removes differently - every remove is enqueued in a call to NativeViewHierarchy's manageChildren, which explicitly asks the parent to remove said child. Consequently, we never try to remove a child that is already removed.

**Fix**

The initial fix was to check whether or not the view exists, but this updated patch just does the right thing at drop time - i.e. whenever a view is dropped, we notify the parent of this fact so that it can clear the reference from clipped views.

**One last Note**

There are two reasons for switching `super.dropView` to `dropView` - first, the comment is only partially correct - calling `super.dropView` will avoid looking at clipped children (as an aside, that could cause a leak in the case of nested clipping subviews), but will look at clipped grandchildren, because of the super class's iteration across the set of children.

Reviewed By: astreet

Differential Revision: D3815485
2016-12-19 13:40:34 -08:00

615 lines
23 KiB
Java

/**
* 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.flat;
import javax.annotation.Nullable;
import java.util.ArrayList;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.util.SparseArray;
import android.util.SparseIntArray;
import android.view.View;
import android.view.animation.Animation;
import com.facebook.infer.annotation.Assertions;
import com.facebook.react.uimanager.ReactClippingViewGroupHelper;
/**
* Abstract class for a {@link DrawCommandManager} with directional clipping. Allows support for
* vertical and horizontal clipping by implementing abstract methods.
*
* Uses two dynamic programming arrays to efficiently update which views and commands are onscreen,
* while not having to sort the incoming draw commands. The draw commands are loosely sorted, as
* they represent a flattening of the normal view hierarchy, and we use that information to quickly
* find children that should be considered onscreen. One array keeps track of, for each index, the
* maximum bottom position that occurs at or before that index; the other keeps track of the
* minimum top position that occurs at or after that index. Given the following children:
*
* +---------------------------------+ 0 (Y coordinate)
* | 0 |
* | +-----------+ | 10
* | | 1 | |
* | | | +--------------+ | 20
* | | | | 3 | |
* | +-----------+ | | | 30
* | | | |
* | +-----------+ | | | 40
* | | 2 | | | |
* | | | +--------------+ | 50
* | | | |
* | +-----------+ | 60
* | |
* +---------------------------------+ 70
*
* +-----------+ 80
* | 4 |
* | | +--------------+ 90
* | | | 6 |
* +-----------+ | | 100
* | |
* +-----------+ | | 110
* | 5 | | |
* | | +--------------+ 120
* | |
* +-----------+ 130
*
* The two arrays are:
* 0 1 2 3 4 5 6
* Max Bottom: [70, 70, 70, 70, 100, 130, 130]
* Min Top: [ 0, 0, 0, 0, 80, 90, 90]
*
* We can then binary search for the first max bottom that is above our rect, and the first min top
* that is below our rect.
*
* If the top and bottom of the rect are 55 and 85, respectively, we will start drawing at index 0
* and stop at index 4.
*
* +---------------------------------+ 0 (Y coordinate)
* | 0 |
* | +-----------+ | 10
* | | 1 | |
* | | | +--------------+ | 20
* | | | | 3 | |
* | +-----------+ | | | 30
* | | | |
* | +-----------+ | | | 40
* | | 2 | | | |
* | | | +--------------+ | 50
* - -| -| - - - -| - - - - - - |- - -
* | +-----------+ | 60
* | |
* +---------------------------------+ 70
*
* +-----------+ 80
* - - -| 4 - - - | - - - - - - - - -
* | | +--------------+ 90
* | | | 6 |
* +-----------+ | | 100
* | |
* +-----------+ | | 110
* | 5 | | |
* | | +--------------+ 120
* | |
* +-----------+ 130
*
* If the top and bottom are 75 and 105 respectively, we will start drawing at index 4 and stop at
* index 6.
*
* +---------------------------------+ 0 (Y coordinate)
* | 0 |
* | +-----------+ | 10
* | | 1 | |
* | | | +--------------+ | 20
* | | | | 3 | |
* | +-----------+ | | | 30
* | | | |
* | +-----------+ | | | 40
* | | 2 | | | |
* | | | +--------------+ | 50
* | | | |
* | +-----------+ | 60
* | |
* +---------------------------------+ 70
* - - - - - - - - - - - - - - - -
* +-----------+ 80
* | 4 |
* | | +--------------+ 90
* | | | 6 |
* +-----------+ | | 100
* - - - - - - - |- - - - - | - - -
* +-----------+ | | 110
* | 5 | | |
* | | +--------------+ 120
* | |
* +-----------+ 130
*
* While this doesn't map exactly to all of the commands that could be clipped, it means that
* children which contain other children (a pretty common case when flattening views) are clipped
* or unclipped as one logical unit. This has the side effect of minimizing the amount of
* invalidates coming from minor clipping rect adjustments. The underlying dynamic programming
* arrays can be calculated off the UI thread in O(n) time, requiring just two passes through the
* command array.
*
* We do a similar optimization when searching for matching node regions, as node regions are
* loosely sorted as well when clipping.
*/
/* package */ abstract class ClippingDrawCommandManager extends DrawCommandManager {
private final FlatViewGroup mFlatViewGroup;
private DrawCommand[] mDrawCommands = DrawCommand.EMPTY_ARRAY;
protected float[] mCommandMaxBottom = StateBuilder.EMPTY_FLOAT_ARRAY;
protected float[] mCommandMinTop = StateBuilder.EMPTY_FLOAT_ARRAY;
private NodeRegion[] mNodeRegions = NodeRegion.EMPTY_ARRAY;
protected float[] mRegionMaxBottom = StateBuilder.EMPTY_FLOAT_ARRAY;
protected float[] mRegionMinTop = StateBuilder.EMPTY_FLOAT_ARRAY;
// Onscreen bounds of draw command array.
private int mStart;
private int mStop;
// Mapping of ids to index position within the draw command array. O(log n) lookups should be
// less in our case because of the large constant overhead and auto boxing of the map.
private SparseIntArray mDrawViewIndexMap = StateBuilder.EMPTY_SPARSE_INT;
// Map of views that are currently clipped.
private final SparseArray<View> mClippedSubviews = new SparseArray<>();
protected final Rect mClippingRect = new Rect();
// Used in updating the clipping rect, as sometimes we want to detach all views, which means we
// need to temporarily store the views we are detaching and removing. These are always of size
// 0, except when used in update clipping rect.
private final SparseArray<View> mViewsToRemove = new SparseArray<>();
private final ArrayList<View> mViewsToKeep = new ArrayList<>();
ClippingDrawCommandManager(FlatViewGroup flatViewGroup, DrawCommand[] drawCommands) {
mFlatViewGroup = flatViewGroup;
initialSetup(drawCommands);
}
/**
* Initially setup this instance. Makes sure the draw commands are mounted, and that our
* clipping rect reflects our current bounds.
*
* @param drawCommands The list of current draw commands. In current implementations, this will
* always be DrawCommand.EMPTY_ARRAY
*/
private void initialSetup(DrawCommand[] drawCommands) {
mountDrawCommands(
drawCommands,
mDrawViewIndexMap,
mCommandMaxBottom,
mCommandMinTop,
true);
updateClippingRect();
}
/**
* @return index of the first command that could be on the screen.
*/
abstract int commandStartIndex();
/**
* @return index of the first command that is guaranteed to be off the screen, starting from the
* given start.
*/
abstract int commandStopIndex(int start);
/**
* @return index of the first region that is guaranteed to be outside of the bounds for touch.
*/
abstract int regionStopIndex(float touchX, float touchY);
/**
* Whether an index and all indices before it are guaranteed to be out of bounds for the current
* touch.
*
* @param index The region index to check.
* @param touchX X coordinate.
* @param touchY Y coordinate.
* @return true if the index and all before it are out of bounds.
*/
abstract boolean regionAboveTouch(int index, float touchX, float touchY);
@Override
public void mountDrawCommands(
DrawCommand[] drawCommands,
SparseIntArray drawViewIndexMap,
float[] maxBottom,
float[] minTop,
boolean willMountViews) {
mDrawCommands = drawCommands;
mCommandMaxBottom = maxBottom;
mCommandMinTop = minTop;
mDrawViewIndexMap = drawViewIndexMap;
if (mClippingRect.bottom != mClippingRect.top) {
mStart = commandStartIndex();
mStop = commandStopIndex(mStart);
if (!willMountViews) {
// If we are not mounting views, we still need to update view indices and positions. It is
// possible that a child changed size and we still need new clipping even though we are not
// mounting views.
updateClippingToCurrentRect();
}
}
}
@Override
public void mountNodeRegions(NodeRegion[] nodeRegions, float[] maxBottom, float[] minTop) {
mNodeRegions = nodeRegions;
mRegionMaxBottom = maxBottom;
mRegionMinTop = minTop;
}
@Override
public @Nullable NodeRegion virtualNodeRegionWithinBounds(float touchX, float touchY) {
int i = regionStopIndex(touchX, touchY);
while (i-- > 0) {
NodeRegion nodeRegion = mNodeRegions[i];
if (!nodeRegion.mIsVirtual) {
// only interested in virtual nodes
continue;
}
if (regionAboveTouch(i, touchX, touchY)) {
break;
}
if (nodeRegion.withinBounds(touchX, touchY)) {
return nodeRegion;
}
}
return null;
}
@Override
public @Nullable NodeRegion anyNodeRegionWithinBounds(float touchX, float touchY) {
int i = regionStopIndex(touchX, touchY);
while (i-- > 0) {
NodeRegion nodeRegion = mNodeRegions[i];
if (regionAboveTouch(i, touchX, touchY)) {
break;
}
if (nodeRegion.withinBounds(touchX, touchY)) {
return nodeRegion;
}
}
return null;
}
private void clip(int id, View view) {
mClippedSubviews.put(id, view);
}
private void unclip(int id) {
mClippedSubviews.remove(id);
}
private boolean isClipped(int id) {
return mClippedSubviews.get(id) != null;
}
private boolean isNotClipped(int id) {
return mClippedSubviews.get(id) == null;
}
@Override
void onClippedViewDropped(View view) {
unclip(view.getId());
mFlatViewGroup.removeDetachedView(view);
}
@Override
public void mountViews(ViewResolver viewResolver, int[] viewsToAdd, int[] viewsToDetach) {
for (int viewToAdd : viewsToAdd) {
// Views that are just temporarily detached are marked with a negative value.
boolean newView = viewToAdd > 0;
if (!newView) {
viewToAdd = -viewToAdd;
}
int commandArrayIndex = mDrawViewIndexMap.get(viewToAdd);
DrawView drawView = (DrawView) mDrawCommands[commandArrayIndex];
View view = viewResolver.getView(drawView.reactTag);
ensureViewHasNoParent(view);
if (newView) {
// This view was not previously attached to this parent.
drawView.mWasMounted = true;
if (animating(view) || withinBounds(commandArrayIndex)) {
// View should be drawn. This view can't currently be clipped because it wasn't
// previously attached to this parent.
mFlatViewGroup.addViewInLayout(view);
} else {
clip(drawView.reactTag, view);
}
} else {
// This view was previously attached, and just temporarily detached.
if (drawView.mWasMounted) {
// The DrawView has been mounted before.
if (isNotClipped(drawView.reactTag)) {
// The DrawView is not clipped. Attach it.
mFlatViewGroup.attachViewToParent(view);
}
// else The DrawView has been previously mounted and is clipped, so don't attach it.
} else {
// We are mounting it, so lets get this part out of the way.
drawView.mWasMounted = true;
// The DrawView has not been mounted before, which means the bounds changed and triggered
// a new DrawView when it was collected from the shadow node. We have a view with the
// same id temporarily detached, but its bounds have changed.
if (animating(view) || withinBounds(commandArrayIndex)) {
// View should be drawn.
if (isClipped(drawView.reactTag)) {
// View was clipped, so add it.
mFlatViewGroup.addViewInLayout(view);
unclip(drawView.reactTag);
} else {
// View was just temporarily removed, so attach it. We already know it isn't clipped,
// so no need to unclip it.
mFlatViewGroup.attachViewToParent(view);
}
} else {
// View should be clipped.
if (isNotClipped(drawView.reactTag)) {
// View was onscreen.
mFlatViewGroup.removeDetachedView(view);
clip(drawView.reactTag, view);
}
// else view is already clipped and not within bounds.
}
}
}
}
for (int viewToDetach : viewsToDetach) {
View view = viewResolver.getView(viewToDetach);
if (view.getParent() != null) {
throw new RuntimeException("Trying to remove view not owned by FlatViewGroup");
} else {
mFlatViewGroup.removeDetachedView(view);
}
// The view isn't clipped anymore, but gone entirely.
unclip(viewToDetach);
}
}
/**
* Returns true if a view is currently animating.
*/
private static boolean animating(View view) {
Animation animation = view.getAnimation();
return animation != null && !animation.hasEnded();
}
/**
* Returns true if a command index is currently onscreen.
*/
private boolean withinBounds(int i) {
return mStart <= i && i < mStop;
}
@Override
public boolean updateClippingRect() {
ReactClippingViewGroupHelper.calculateClippingRect(mFlatViewGroup, mClippingRect);
if (mFlatViewGroup.getParent() == null || mClippingRect.top == mClippingRect.bottom) {
// If we are unparented or are clipping to an empty rect, no op. Return false so we don't
// invalidate.
return false;
}
int start = commandStartIndex();
int stop = commandStopIndex(start);
if (mStart <= start && stop <= mStop) {
// We would only be removing children, don't invalidate and don't bother changing the
// attached children.
return false;
}
mStart = start;
mStop = stop;
updateClippingToCurrentRect();
return true;
}
/**
* Used either after we have updated the current rect, or when we have mounted new commands and
* the rect hasn't changed. Updates the clipping after mStart and mStop have been set to the
* correct values. For draw commands, this is all it takes to update the command mounting, as
* draw commands are only attached in a conceptual sense, and don't rely on the android view
* hierarchy.
*
* For native children, we have to walk through our current views and remove any that are no
* longer on screen, and add those that are newly on screen. As an optimization for fling, if we
* are removing two or more native views we instead detachAllViews from the {@link FlatViewGroup}
* and re-attach or add as needed.
*
* This approximation is roughly correct, as we tend to add and remove the same amount of views,
* and each add and remove pair is O(n); detachAllViews and re-attach requires two passes, so
* using this once we are removing more than two native views is a good breakpoint.
*/
private void updateClippingToCurrentRect() {
for (int i = 0, size = mFlatViewGroup.getChildCount(); i < size; i++) {
View view = mFlatViewGroup.getChildAt(i);
int index = mDrawViewIndexMap.get(view.getId());
if (withinBounds(index) || animating(view)) {
mViewsToKeep.add(view);
} else {
mViewsToRemove.append(i, view);
clip(view.getId(), view);
}
}
int removeSize = mViewsToRemove.size();
boolean removeAll = removeSize > 2;
if (removeAll) {
// Detach all, as we are changing quite a few views, whether flinging or otherwise.
mFlatViewGroup.detachAllViewsFromParent();
for (int i = 0; i < removeSize; i++) {
mFlatViewGroup.removeDetachedView(mViewsToRemove.valueAt(i));
}
} else {
// Simple clipping sweep, as we are changing relatively few views.
while (removeSize-- > 0) {
mFlatViewGroup.removeViewsInLayout(mViewsToRemove.keyAt(removeSize), 1);
}
}
mViewsToRemove.clear();
int current = mStart;
int childIndex = 0;
for (int i = 0, size = mViewsToKeep.size(); i < size; i++) {
View view = mViewsToKeep.get(i);
int commandIndex = mDrawViewIndexMap.get(view.getId());
if (current <= commandIndex) {
while (current != commandIndex) {
if (mDrawCommands[current] instanceof DrawView) {
DrawView drawView = (DrawView) mDrawCommands[current];
mFlatViewGroup.addViewInLayout(
Assertions.assumeNotNull(mClippedSubviews.get(drawView.reactTag)),
childIndex++);
unclip(drawView.reactTag);
}
current++;
}
// We are currently at the command index, but we want to increment beyond it.
current++;
}
if (removeAll) {
mFlatViewGroup.attachViewToParent(view, childIndex);
}
// We want to make sure we increment the child index even if we didn't detach it to maintain
// order.
childIndex++;
}
mViewsToKeep.clear();
while (current < mStop) {
if (mDrawCommands[current] instanceof DrawView) {
DrawView drawView = (DrawView) mDrawCommands[current];
mFlatViewGroup.addViewInLayout(
Assertions.assumeNotNull(mClippedSubviews.get(drawView.reactTag)),
childIndex++);
unclip(drawView.reactTag);
}
current++;
}
}
@Override
public void getClippingRect(Rect outClippingRect) {
outClippingRect.set(mClippingRect);
}
@Override
public SparseArray<View> getDetachedViews() {
return mClippedSubviews;
}
/**
* Draws the unclipped commands on the given canvas. This would be much simpler if we didn't
* have to worry about animating views, as we could simply:
*
* for (int i = start; i < stop; i++) {
* drawCommands[i].draw(...);
* }
*
* This is complicated however by animating views, which may occur before or after the current
* clipping rect. Consider the following array:
*
* +--------------+
* | DrawView | 0
* | *animating* |
* +--------------+
* | DrawCommmand | 1
* | *clipped* |
* +--------------+
* | DrawCommand | 2 start
* | |
* +--------------+
* | DrawCommand | 3
* | |
* +--------------+
* | DrawView | 4
* | |
* +--------------+
* | DrawView | 5 stop
* | *clipped* |
* +--------------+
* | DrawView | 6
* | *animating* |
* +--------------+
*
* 2, 3, and 4 are onscreen according to bounds, while 0 and 6 are onscreen according to
* animation. We have to walk through the attached children making sure to draw any draw
* commands that should be drawn before that draw view, as well as making sure not to draw any
* draw commands that are out of bounds.
*
* @param canvas The canvas to draw on.
*/
@Override
public void draw(Canvas canvas) {
int commandIndex = mStart;
int size = mFlatViewGroup.getChildCount();
// Iterate through the children, making sure that we draw any draw commands we haven't drawn
// that should happen before the next draw view.
for (int i = 0; i < size; i++) {
// This is the command index of the next view that we need to draw. Since a view might be
// animating, this view is either before all the commands onscreen, onscreen, or after the
// onscreen commands.
int viewIndex = mDrawViewIndexMap.get(mFlatViewGroup.getChildAt(i).getId());
if (mStop < viewIndex) {
// The current view is outside of the viewport bounds. We want to draw all the commands
// up to the stop, then draw all the views outside the viewport bounds.
while (commandIndex < mStop) {
mDrawCommands[commandIndex++].draw(mFlatViewGroup, canvas);
}
// We are now out of commands to draw, so we could just draw the remaining attached
// children, but the for loop logic will draw the rest anyway.
} else if (commandIndex <= viewIndex) {
// The viewIndex is within our onscreen bounds (or == stop). We want to draw all the
// commands from the current position to the current view, inclusive.
while (commandIndex < viewIndex) {
mDrawCommands[commandIndex++].draw(mFlatViewGroup, canvas);
}
// Command index now == viewIndex, so increment beyond it.
commandIndex++;
}
mDrawCommands[viewIndex].draw(mFlatViewGroup, canvas);
}
// If we get here, it means we have drawn all the views, now just draw the remaining draw
// commands.
while (commandIndex < mStop) {
mDrawCommands[commandIndex++].draw(mFlatViewGroup, canvas);
}
}
@Override
void debugDraw(Canvas canvas) {
// Draws clipped draw commands, but does not draw clipped views.
for (DrawCommand drawCommand : mDrawCommands) {
if (drawCommand instanceof DrawView) {
if (isNotClipped(((DrawView) drawCommand).reactTag)) {
drawCommand.debugDraw(mFlatViewGroup, canvas);
}
// else, don't draw, and don't increment index
} else {
drawCommand.debugDraw(mFlatViewGroup, canvas);
}
}
}
}