/*
* Copyright (c) 2014-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.stetho.inspector.elements;
import android.app.Activity;
import com.facebook.stetho.common.Accumulator;
import com.facebook.stetho.common.ListUtil;
import com.facebook.stetho.common.Util;
import java.util.ArrayDeque;
import java.util.Collections;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.Set;
public final class ShadowDocument implements DocumentView {
private final Object mRootElement;
private final IdentityHashMap<Object, ElementInfo> mElementToInfoMap = new IdentityHashMap<>();
private boolean mIsUpdating;
public ShadowDocument(Object rootElement) {
mRootElement = Util.throwIfNull(rootElement);
}
@Override
public Object getRootElement() {
return mRootElement;
}
@Override
public ElementInfo getElementInfo(Object element) {
return mElementToInfoMap.get(element);
}
public UpdateBuilder beginUpdate() {
if (mIsUpdating) {
throw new IllegalStateException();
}
mIsUpdating = true;
return new UpdateBuilder();
}
public final class UpdateBuilder {
/**
* We use a {@link LinkedHashMap} to preserve ordering between
* {@link UpdateBuilder#setElementChildren(Object, List)} and
* {@link Update#getChangedElements(Accumulator)}. This isn't needed for correctness but it
* significantly improves performance.<p/>
*
* Transmitting DOM updates to Chrome works best if we can do it in top-down order because it
* allows us to skip processing (and, more importantly, transmission) of an element that was
* already transmitted in a previous DOM.childNodeInserted event (i.o.w. we can skip
* transmission of E2 if it was already bundled up in E1's event, where E2 is any element in
* E1's sub-tree). DOM.childNodeInserted transmits the node being inserted by-value, so it takes
* time and space proportional to the size of that node's sub-tree. This means the difference
* between O(n^2) and O(n) time for transmitting updates to Chrome.<p/>
*
* We currently only have one implementation of {@link DocumentProvider},
* {@link com.facebook.stetho.inspector.elements.android.AndroidDocumentProvider}, and it
* already supplies element changes in top-down order. Because of this, we can just use
* {@link LinkedHashMap} instead of adding some kind of post-process sorting of the elements to
* put them in that order. If we reach a point where we can't or shouldn't rely on elements
* being forwarded to us in top-down order, then we should change this field to an
* {@link IdentityHashMap} and sort them before relaying them via
* {@link Update#getChangedElements(Accumulator)}.<p/>
*
* When a large sub-tree is added (e.g. starting a new {@link Activity}), the use of
* {@link LinkedHashMap} instead of {@link IdentityHashMap} can mean the difference between an
* update taking 500ms versus taking more than 30 seconds.<p/>
*
* Technically we actually want something like a LinkedIdentityHashMap because we do want
* to key off of object identity instead of allowing for the possibility of value identity.
* Given the difference in performance, however, the risk of potential protocol abuse seems
* reasonable.<p/>
*/
private final Map<Object, ElementInfo> mElementToInfoChangesMap = new LinkedHashMap<>();
/**
* This contains every element in {@link #mElementToInfoChangesMap} whose
* {@link ElementInfo#parentElement} is null. {@link ShadowDocument} provides access to a tree, which
* means it has a single root (only one element with a null parent). During an update, however,
* the DOM can be conceptually thought of as being a forest. The true root is identified by
* {@link #mRootElement}, and all other roots identify disconnected trees full of elements that
* must be garbage collected.
*/
private final HashSet<Object> mRootElementChanges = new HashSet<>();
/**
* This is used during {@link #setElementChildren}. We allocate 1 on-demand and reuse it.
*/
private HashSet<Object> mCachedNotNewChildrenSet;
public void setElementChildren(Object element, List<Object> children) {
// If we receive redundant information, then nothing needs to be done.
ElementInfo changesElementInfo = mElementToInfoChangesMap.get(element);
if (changesElementInfo != null &&
ListUtil.identityEquals(children, changesElementInfo.children)) {
return;
}
ElementInfo oldElementInfo = mElementToInfoMap.get(element);
if (changesElementInfo == null &&
oldElementInfo != null &&
ListUtil.identityEquals(children, oldElementInfo.children)) {
return;
}
ElementInfo newElementInfo;
if (changesElementInfo != null &&
oldElementInfo != null &&
oldElementInfo.parentElement == changesElementInfo.parentElement &&
ListUtil.identityEquals(children, oldElementInfo.children)) {
// setElementChildren() was already called for element with changes during this
// transaction, but now we're being told that the children should match the old view.
// So we should actually remove the change entry.
newElementInfo = mElementToInfoMap.get(element);
mElementToInfoChangesMap.remove(element);
} else {
Object parentElement = (changesElementInfo != null)
? changesElementInfo.parentElement
: (oldElementInfo != null)
? oldElementInfo.parentElement
: null;
newElementInfo = new ElementInfo(element, parentElement, children);
mElementToInfoChangesMap.put(element, newElementInfo);
}
// At this point, newElementInfo is either equal to oldElementInfo because we've reverted
// back to the same data that's in the old view of the tree, or it's a brand new object with
// brand new changes (it's different than both of oldElementInfo and changesElementInfo).
// Next, set the parentElement to null for child elements that have been removed from
// element's children. We must be careful not to set a parentElement to null if that child has
// already been moved to be the child of a different element. e.g.,
// setElementChildren(E, { A, B, C})
// ...
// setElementChildren(F, { A })
// setElementChildren(E, { B, C }) (don't mark A's parent as null in this case)
// notNewChildrenSet = (oldChildren + changesChildren) - newChildren
HashSet<Object> notNewChildrenSet = acquireNotNewChildrenHashSet();
if (oldElementInfo != null &&
oldElementInfo.children != newElementInfo.children) {
for (int i = 0, N = oldElementInfo.children.size(); i < N; ++i) {
final Object childElement = oldElementInfo.children.get(i);
notNewChildrenSet.add(childElement);
}
}
if (changesElementInfo != null &&
changesElementInfo.children != newElementInfo.children) {
for (int i = 0, N = changesElementInfo.children.size(); i < N; ++i) {
final Object childElement = changesElementInfo.children.get(i);
notNewChildrenSet.add(childElement);
}
}
for (int i = 0, N = newElementInfo.children.size(); i < N; ++i) {
final Object childElement = newElementInfo.children.get(i);
setElementParent(childElement, element);
notNewChildrenSet.remove(childElement);
}
for (Object childElement : notNewChildrenSet) {
final ElementInfo childChangesElementInfo = mElementToInfoChangesMap.get(childElement);
if (childChangesElementInfo != null &&
childChangesElementInfo.parentElement != element) {
// do nothing. this childElement was moved to be the child of another element.
continue;
}
final ElementInfo oldChangesElementInfo = mElementToInfoMap.get(childElement);
if (oldChangesElementInfo != null &&
oldChangesElementInfo.parentElement == element) {
setElementParent(childElement, null);
}
}
releaseNotNewChildrenHashSet(notNewChildrenSet);
}
private void setElementParent(Object element, Object parentElement) {
ElementInfo changesElementInfo = mElementToInfoChangesMap.get(element);
if (changesElementInfo != null &&
parentElement == changesElementInfo.parentElement) {
return;
}
ElementInfo oldElementInfo = mElementToInfoMap.get(element);
if (changesElementInfo == null &&
oldElementInfo != null &&
parentElement == oldElementInfo.parentElement) {
return;
}
if (changesElementInfo != null &&
oldElementInfo != null &&
parentElement == oldElementInfo.parentElement &&
ListUtil.identityEquals(oldElementInfo.children, changesElementInfo.children)) {
mElementToInfoChangesMap.remove(element);
if (parentElement == null) {
mRootElementChanges.remove(element);
}
return;
}
List<Object> children = (changesElementInfo != null)
? changesElementInfo.children
: (oldElementInfo != null)
? oldElementInfo.children
: Collections.emptyList();
ElementInfo newElementInfo = new ElementInfo(element, parentElement, children);
mElementToInfoChangesMap.put(element, newElementInfo);
if (parentElement == null) {
mRootElementChanges.add(element);
} else {
mRootElementChanges.remove(element);
}
}
public Update build() {
return new Update(mElementToInfoChangesMap, mRootElementChanges);
}
private HashSet<Object> acquireNotNewChildrenHashSet() {
HashSet<Object> notNewChildrenHashSet = mCachedNotNewChildrenSet;
if (notNewChildrenHashSet == null) {
notNewChildrenHashSet = new HashSet<>();
}
mCachedNotNewChildrenSet = null;
return notNewChildrenHashSet;
}
private void releaseNotNewChildrenHashSet(HashSet<Object> notNewChildrenHashSet) {
notNewChildrenHashSet.clear();
if (mCachedNotNewChildrenSet == null) {
mCachedNotNewChildrenSet = notNewChildrenHashSet;
}
}
}
public final class Update implements DocumentView {
private final Map<Object, ElementInfo> mElementToInfoChangesMap;
private final Set<Object> mRootElementChangesSet;
public Update(
Map<Object, ElementInfo> elementToInfoChangesMap,
Set<Object> rootElementChangesSet) {
mElementToInfoChangesMap = elementToInfoChangesMap;
mRootElementChangesSet = rootElementChangesSet;
}
public boolean isEmpty() {
return mElementToInfoChangesMap.isEmpty();
}
public boolean isElementChanged(Object element) {
return mElementToInfoChangesMap.containsKey(element);
}
public Object getRootElement() {
return ShadowDocument.this.getRootElement();
}
public ElementInfo getElementInfo(Object element) {
// Return ElementInfo for the new (albeit uncommitted and pre-garbage collected) view of the
// Document. If element is garbage then you'll still get its info (feature, not a bug :)).
ElementInfo elementInfo = mElementToInfoChangesMap.get(element);
if (elementInfo != null) {
return elementInfo;
}
return mElementToInfoMap.get(element);
}
public void getChangedElements(Accumulator<Object> accumulator) {
for (Object element : mElementToInfoChangesMap.keySet()) {
accumulator.store(element);
}
}
public void getGarbageElements(Accumulator<Object> accumulator) {
// This queue stores pairs of elements, [element, expectedParent]
// When we dequeue, we look at element's parentElement in the new view to see if it matches
// expectedParent. If it does, then it's garbage. For enqueueing roots, whose parents are
// null, since we can't enqueue null we instead enqueue the element twice.
Queue<Object> queue = new ArrayDeque<>();
// Initialize the queue with all disconnected tree roots (parentElement == null) which
// aren't the DOM root.
for (Object element : mRootElementChangesSet) {
ElementInfo newElementInfo = getElementInfo(element);
if (element != mRootElement && newElementInfo.parentElement == null) {
queue.add(element);
queue.add(element);
}
}
// BFS traversal from those elements in the old view of the tree and test each element
// to see if it's still within a disconnected sub-tree. We can tell if it's garbage if its
// parent element in the new view of the tree hasn't changed.
while (!queue.isEmpty()) {
final Object element = queue.remove();
final Object expectedParent0 = queue.remove();
final Object expectedParent = (element == expectedParent0) ? null : expectedParent0;
final ElementInfo newElementInfo = getElementInfo(element);
if (newElementInfo.parentElement == expectedParent) {
accumulator.store(element);
ElementInfo oldElementInfo = ShadowDocument.this.getElementInfo(element);
if (oldElementInfo != null) {
for (int i = 0, N = oldElementInfo.children.size(); i < N; ++i) {
queue.add(oldElementInfo.children.get(i));
queue.add(element);
}
}
}
}
}
public void abandon() {
if (!mIsUpdating) {
throw new IllegalStateException();
}
mIsUpdating = false;
}
public void commit() {
if (!mIsUpdating) {
throw new IllegalStateException();
}
// Apply the changes to the tree
mElementToInfoMap.putAll(mElementToInfoChangesMap);
// Remove garbage elements: those that have a null parent (other than mRootElement), and
// their entire sub-trees, but excluding reparented elements.
for (Object element : mRootElementChangesSet) {
removeGarbageSubTree(mElementToInfoMap, element);
}
mIsUpdating = false;
// Not usually enabled because it's expensive. Very useful for debugging.
//validateTree(mElementToInfoMap);
}
private void removeGarbageSubTree(
Map<Object, ElementInfo> elementToInfoMap,
Object element) {
final ElementInfo elementInfo = elementToInfoMap.get(element);
// If this element has a parent (it's not a root), and that parent is still in the tree after
// changes have been applied and after our caller (removeGarbageSubTree) removed another
// element that claims this element as its child, then that means this element should not be
// removed. It has been reparented, and recursion stops here.
if (elementInfo.parentElement != null &&
elementToInfoMap.containsKey(elementInfo.parentElement)) {
return;
}
elementToInfoMap.remove(element);
for (int i = 0, N = elementInfo.children.size(); i < N; ++i) {
removeGarbageSubTree(elementToInfoMap, elementInfo.children.get(i));
}
}
// This method is intended for use during debugging. Put a breakpoint on each throw statement in
// order to catch structural problems in the tree. This method should only be called at the very
// end of commit().
private void validateTree(Map<Object, ElementInfo> elementToInfoMap) {
// We need a tree, not a forest.
HashSet<Object> rootElements = new HashSet<>();
for (Map.Entry<Object, ElementInfo> entry : elementToInfoMap.entrySet()) {
final Object element = entry.getKey();
final ElementInfo elementInfo = entry.getValue();
if (element != elementInfo.element) {
// should not be possible
throw new IllegalStateException("element != elementInfo.element");
}
// Verify children
for (int i = 0, N = elementInfo.children.size(); i < N; ++i) {
final Object childElement = elementInfo.children.get(i);
final ElementInfo childElementInfo = elementToInfoMap.get(childElement);
if (childElementInfo == null) {
throw new IllegalStateException(String.format(
"elementInfo.get(elementInfo.children.get(%s)) == null",
i));
}
if (childElementInfo.parentElement != element) {
throw new IllegalStateException("childElementInfo.parentElement != element");
}
}
// Verify parent
if (elementInfo.parentElement == null) {
rootElements.add(element);
} else {
final ElementInfo parentElementInfo = elementToInfoMap.get(elementInfo.parentElement);
if (parentElementInfo == null) {
throw new IllegalStateException(
"elementToInfoMap.get(elementInfo.parentElementInfo) == NULL");
}
if (elementInfo.parentElement != parentElementInfo.element) {
// should not be possible
throw new IllegalStateException(
"elementInfo.parentElementInfo != parentElementInfo.parent");
}
if (!parentElementInfo.children.contains(element)) {
throw new IllegalStateException(
"parentElementInfo.children.contains(element) == FALSE");
}
}
}
if (rootElements.size() != 1) {
throw new IllegalStateException(
"elementToInfoMap is a forest, not a tree. rootElements.size() != 1");
}
}
}
}