Implementation of go/remoteviews-recycle-added-view.
Bug: 180915613
Test: atest CtsWidgetTestCases:android.widget.cts.RemoteViewsRecyclingTest
Change-Id: Ieb383ea613e0fd3dc754ef5fe8ae3bb6bb9d1dee
diff --git a/core/api/current.txt b/core/api/current.txt
index 61d2db9..e75e94e 100644
--- a/core/api/current.txt
+++ b/core/api/current.txt
@@ -54957,6 +54957,7 @@
ctor public RemoteViews(@NonNull java.util.Map<android.util.SizeF,android.widget.RemoteViews>);
ctor public RemoteViews(android.widget.RemoteViews);
ctor public RemoteViews(android.os.Parcel);
+ method public void addStableView(@IdRes int, @NonNull android.widget.RemoteViews, int);
method public void addView(@IdRes int, android.widget.RemoteViews);
method public android.view.View apply(android.content.Context, android.view.ViewGroup);
method @Deprecated public android.widget.RemoteViews clone();
diff --git a/core/java/android/widget/RemoteViews.java b/core/java/android/widget/RemoteViews.java
index 2328e58..d2f4cea 100644
--- a/core/java/android/widget/RemoteViews.java
+++ b/core/java/android/widget/RemoteViews.java
@@ -120,6 +120,7 @@
import java.util.Stack;
import java.util.concurrent.Executor;
import java.util.function.Consumer;
+import java.util.function.Predicate;
/**
* A class that describes a view hierarchy that can be displayed in
@@ -1993,22 +1994,79 @@
mIsRoot = false;
}
+ private static boolean hasStableId(View view) {
+ Object tag = view.getTag(com.android.internal.R.id.remote_views_stable_id);
+ return tag != null;
+ }
+
+ private static int getStableId(View view) {
+ Integer id = (Integer) view.getTag(com.android.internal.R.id.remote_views_stable_id);
+ return id == null ? ViewGroupActionAdd.NO_ID : id;
+ }
+
+ private static void setStableId(View view, int stableId) {
+ view.setTagInternal(com.android.internal.R.id.remote_views_stable_id, stableId);
+ }
+
+ // Returns the next recyclable child of the view group, or -1 if there are none.
+ private static int getNextRecyclableChild(ViewGroup vg) {
+ Integer tag = (Integer) vg.getTag(com.android.internal.R.id.remote_views_next_child);
+ return tag == null ? -1 : tag;
+ }
+
+ private static int getViewLayoutId(View v) {
+ return (Integer) v.getTag(R.id.widget_frame);
+ }
+
+ private static void setNextRecyclableChild(ViewGroup vg, int nextChild, int numChildren) {
+ if (nextChild < 0 || nextChild >= numChildren) {
+ vg.setTagInternal(com.android.internal.R.id.remote_views_next_child, -1);
+ } else {
+ vg.setTagInternal(com.android.internal.R.id.remote_views_next_child, nextChild);
+ }
+ }
+
+ private void finalizeViewRecycling(ViewGroup root) {
+ // Remove any recyclable children that were not used. nextChild should either be -1 or point
+ // to the next recyclable child that hasn't been recycled.
+ int nextChild = getNextRecyclableChild(root);
+ if (nextChild >= 0 && nextChild < root.getChildCount()) {
+ root.removeViews(nextChild, root.getChildCount() - nextChild);
+ }
+ // Make sure on the next round, we don't try to recycle if removeAllViews is not called.
+ setNextRecyclableChild(root, -1, 0);
+ // Traverse the view tree.
+ for (int i = 0; i < root.getChildCount(); i++) {
+ View child = root.getChildAt(i);
+ if (child instanceof ViewGroup && !child.isRootNamespace()) {
+ finalizeViewRecycling((ViewGroup) child);
+ }
+ }
+ }
+
/**
* ViewGroup methods that are related to adding Views.
*/
private class ViewGroupActionAdd extends Action {
+ static final int NO_ID = -1;
@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
private RemoteViews mNestedViews;
private int mIndex;
+ private int mStableId;
ViewGroupActionAdd(@IdRes int viewId, RemoteViews nestedViews) {
- this(viewId, nestedViews, -1 /* index */);
+ this(viewId, nestedViews, -1 /* index */, NO_ID /* nestedViewId */);
}
ViewGroupActionAdd(@IdRes int viewId, RemoteViews nestedViews, int index) {
+ this(viewId, nestedViews, index, NO_ID /* nestedViewId */);
+ }
+
+ ViewGroupActionAdd(@IdRes int viewId, RemoteViews nestedViews, int index, int stableId) {
this.viewId = viewId;
mNestedViews = nestedViews;
mIndex = index;
+ mStableId = stableId;
if (nestedViews != null) {
configureRemoteViewsAsChild(nestedViews);
}
@@ -2018,6 +2076,7 @@
int depth, Map<Class, Object> classCookies) {
viewId = parcel.readInt();
mIndex = parcel.readInt();
+ mStableId = parcel.readInt();
mNestedViews = new RemoteViews(parcel, bitmapCache, info, depth, classCookies);
mNestedViews.addFlags(mApplyFlags);
}
@@ -2025,6 +2084,7 @@
public void writeToParcel(Parcel dest, int flags) {
dest.writeInt(viewId);
dest.writeInt(mIndex);
+ dest.writeInt(mStableId);
mNestedViews.writeToParcel(dest, flags);
}
@@ -2033,6 +2093,17 @@
return mNestedViews.hasSameAppInfo(parentInfo);
}
+ private int findViewIndexToRecycle(ViewGroup target, RemoteViews newContent) {
+ for (int nextChild = getNextRecyclableChild(target); nextChild < target.getChildCount();
+ nextChild++) {
+ View child = target.getChildAt(nextChild);
+ if (getStableId(child) == mStableId) {
+ return nextChild;
+ }
+ }
+ return -1;
+ }
+
@Override
public void apply(View root, ViewGroup rootParent, InteractionHandler handler,
ColorResources colorResources) {
@@ -2043,10 +2114,45 @@
return;
}
+ // If removeAllViews was called, this returns the next potential recycled view.
+ // If there are no more views to recycle (or removeAllViews was not called), this
+ // will return -1.
+ final int nextChild = getNextRecyclableChild(target);
+ RemoteViews rvToApply = mNestedViews.getRemoteViewsToApply(context);
+ if (nextChild >= 0 && mStableId != NO_ID) {
+ // At that point, the views starting at index nextChild are the ones recyclable but
+ // not yet recycled. All views added on that round of application are placed before.
+ // Find the next view with the same stable id, or -1.
+ int recycledViewIndex = findViewIndexToRecycle(target, rvToApply);
+ if (recycledViewIndex >= 0) {
+ View child = target.getChildAt(recycledViewIndex);
+ if (getViewLayoutId(child) == rvToApply.getLayoutId()) {
+ if (nextChild < recycledViewIndex) {
+ target.removeViews(nextChild, recycledViewIndex - nextChild);
+ }
+ setNextRecyclableChild(target, nextChild + 1, target.getChildCount());
+ rvToApply.reapply(context, child, handler, null /* size */, colorResources,
+ false /* topLevel */);
+ return;
+ }
+ // If we cannot recycle the views, we still remove all views in between to
+ // avoid weird behaviors and insert the new view in place of the old one.
+ target.removeViews(nextChild, recycledViewIndex - nextChild + 1);
+ }
+ }
+ // If we cannot recycle, insert the new view before the next recyclable child.
+
// Inflate nested views and add as children
- target.addView(
- mNestedViews.apply(context, target, handler, null /* size */, colorResources),
- mIndex);
+ View nestedView = rvToApply.apply(context, target, handler, null /* size */,
+ colorResources);
+ if (mStableId != NO_ID) {
+ setStableId(nestedView, mStableId);
+ }
+ target.addView(nestedView, mIndex >= 0 ? mIndex : nextChild);
+ if (nextChild >= 0) {
+ // If we are at the end, there is no reason to try to recycle anymore
+ setNextRecyclableChild(target, nextChild + 1, target.getChildCount());
+ }
}
@Override
@@ -2063,24 +2169,91 @@
// Inflate nested views and perform all the async tasks for the child remoteView.
final Context context = root.mRoot.getContext();
- final AsyncApplyTask task = mNestedViews.getAsyncApplyTask(context, targetVg,
- null /* listener */, handler, null /* size */, colorResources);
+
+ // If removeAllViews was called, this returns the next potential recycled view.
+ // If there are no more views to recycle (or removeAllViews was not called), this
+ // will return -1.
+ final int nextChild = getNextRecyclableChild(targetVg);
+ if (nextChild >= 0 && mStableId != NO_ID) {
+ RemoteViews rvToApply = mNestedViews.getRemoteViewsToApply(context);
+ final int recycledViewIndex = target.findChildIndex(nextChild,
+ view -> getStableId(view) == mStableId);
+ if (recycledViewIndex >= 0) {
+ // At that point, the views starting at index nextChild are the ones
+ // recyclable but not yet recycled. All views added on that round of
+ // application are placed before.
+ ViewTree recycled = target.mChildren.get(recycledViewIndex);
+ // We can only recycle the view if the layout id is the same.
+ if (getViewLayoutId(recycled.mRoot) == rvToApply.getLayoutId()) {
+ if (recycledViewIndex > nextChild) {
+ target.removeChildren(nextChild, recycledViewIndex - nextChild);
+ }
+ setNextRecyclableChild(targetVg, nextChild + 1, target.mChildren.size());
+ final AsyncApplyTask reapplyTask = rvToApply.getInternalAsyncApplyTask(
+ context,
+ targetVg, null /* listener */, handler, null /* size */,
+ colorResources,
+ recycled.mRoot);
+ final ViewTree tree = reapplyTask.doInBackground();
+ if (tree == null) {
+ throw new ActionException(reapplyTask.mError);
+ }
+ return new RuntimeAction() {
+ @Override
+ public void apply(View root, ViewGroup rootParent,
+ InteractionHandler handler, ColorResources colorResources)
+ throws ActionException {
+ reapplyTask.onPostExecute(tree);
+ if (recycledViewIndex > nextChild) {
+ targetVg.removeViews(nextChild, recycledViewIndex - nextChild);
+ }
+ }
+ };
+ }
+ // If the layout id is different, still remove the children as if we recycled
+ // the view, to insert at the same place.
+ target.removeChildren(nextChild, recycledViewIndex - nextChild + 1);
+ return insertNewView(context, target, handler, colorResources,
+ () -> targetVg.removeViews(nextChild,
+ recycledViewIndex - nextChild + 1));
+
+ }
+ }
+ // If we cannot recycle, simply add the view at the same available slot.
+ return insertNewView(context, target, handler, colorResources, () -> {});
+ }
+
+ private Action insertNewView(Context context, ViewTree target, InteractionHandler handler,
+ ColorResources colorResources, Runnable finalizeAction) {
+ ViewGroup targetVg = (ViewGroup) target.mRoot;
+ int nextChild = getNextRecyclableChild(targetVg);
+ final AsyncApplyTask task = mNestedViews.getInternalAsyncApplyTask(context, targetVg,
+ null /* listener */, handler, null /* size */, colorResources,
+ null /* result */);
final ViewTree tree = task.doInBackground();
if (tree == null) {
throw new ActionException(task.mError);
}
+ if (mStableId != NO_ID) {
+ setStableId(task.mResult, mStableId);
+ }
// Update the global view tree, so that next call to findViewTreeById
// goes through the subtree as well.
- target.addChild(tree, mIndex);
+ final int insertIndex = mIndex >= 0 ? mIndex : nextChild;
+ target.addChild(tree, insertIndex);
+ if (nextChild >= 0) {
+ setNextRecyclableChild(targetVg, nextChild + 1, target.mChildren.size());
+ }
return new RuntimeAction() {
@Override
public void apply(View root, ViewGroup rootParent, InteractionHandler handler,
ColorResources colorResources) throws ActionException {
task.onPostExecute(tree);
- targetVg.addView(task.mResult, mIndex);
+ finalizeAction.run();
+ targetVg.addView(task.mResult, insertIndex);
}
};
}
@@ -2148,7 +2321,14 @@
}
if (mViewIdToKeep == REMOVE_ALL_VIEWS_ID) {
- target.removeAllViews();
+ // Remote any view without a stable id
+ for (int i = target.getChildCount() - 1; i >= 0; i--) {
+ if (!hasStableId(target.getChildAt(i))) {
+ target.removeViewAt(i);
+ }
+ }
+ // In the end, only children with a stable id (i.e. recyclable) are left.
+ setNextRecyclableChild(target, 0, target.getChildCount());
return;
}
@@ -2170,8 +2350,8 @@
final ViewGroup targetVg = (ViewGroup) target.mRoot;
if (mViewIdToKeep == REMOVE_ALL_VIEWS_ID) {
- // Clear all children when there's no excepted view
- target.mChildren = null;
+ target.mChildren.removeIf(childTree -> !hasStableId(childTree.mRoot));
+ setNextRecyclableChild(targetVg, 0, target.mChildren.size());
} else {
// Remove just the children which don't match the excepted view
target.mChildren.removeIf(childTree -> childTree.mRoot.getId() != mViewIdToKeep);
@@ -2184,7 +2364,11 @@
public void apply(View root, ViewGroup rootParent, InteractionHandler handler,
ColorResources colorResources) throws ActionException {
if (mViewIdToKeep == REMOVE_ALL_VIEWS_ID) {
- targetVg.removeAllViews();
+ for (int i = targetVg.getChildCount() - 1; i >= 0; i--) {
+ if (!hasStableId(targetVg.getChildAt(i))) {
+ targetVg.removeViewAt(i);
+ }
+ }
return;
}
@@ -3084,6 +3268,7 @@
}
mApplication = portrait.mApplication;
mLayoutId = portrait.mLayoutId;
+ mViewId = portrait.mViewId;
mLightBackgroundLayoutId = portrait.mLightBackgroundLayoutId;
mLandscape = landscape;
@@ -3136,6 +3321,7 @@
RemoteViews smallestView = findSmallestRemoteView();
mApplication = smallestView.mApplication;
mLayoutId = smallestView.mLayoutId;
+ mViewId = smallestView.mViewId;
mLightBackgroundLayoutId = smallestView.mLightBackgroundLayoutId;
}
@@ -3253,6 +3439,7 @@
ApplicationInfo.CREATOR.createFromParcel(parcel);
mIdealSize = parcel.readInt() == 0 ? null : SizeF.CREATOR.createFromParcel(parcel);
mLayoutId = parcel.readInt();
+ mViewId = parcel.readInt();
mLightBackgroundLayoutId = parcel.readInt();
readActionsFromParcel(parcel, depth);
@@ -3273,6 +3460,7 @@
RemoteViews smallestView = findSmallestRemoteView();
mApplication = smallestView.mApplication;
mLayoutId = smallestView.mLayoutId;
+ mViewId = smallestView.mViewId;
mLightBackgroundLayoutId = smallestView.mLightBackgroundLayoutId;
} else {
// MODE_HAS_LANDSCAPE_AND_PORTRAIT
@@ -3281,6 +3469,7 @@
mClassCookies);
mApplication = mPortrait.mApplication;
mLayoutId = mPortrait.mLayoutId;
+ mViewId = mPortrait.mViewId;
mLightBackgroundLayoutId = mPortrait.mLightBackgroundLayoutId;
}
mApplyFlags = parcel.readInt();
@@ -3458,6 +3647,29 @@
}
/**
+ * Equivalent to calling {@link ViewGroup#addView(View)} after inflating the given
+ * {@link RemoteViews}. If the {@link RemoteViews} may be re-inflated or updated,
+ * {@link #removeAllViews(int)} must be called on the same {@code viewId
+ * } before the first call to this method for the behavior of this method to be predictable.
+ *
+ * The {@code stableId} will be used to identify a potential view to recycled when the remote
+ * view is inflated. Views can be re-used if inserted in the same order, potentially with
+ * some views appearing / disappearing.
+ *
+ * Note: if a view is re-used, all the actions will be re-applied on it. However, its properties
+ * are not reset, so what was applied in previous round will have an effect. As a view may be
+ * re-created at any time by the host, the RemoteViews should not rely on keeping information
+ * from previous applications and always re-set all the properties they need.
+ *
+ * @param viewId The id of the parent {@link ViewGroup} to add child into.
+ * @param nestedView {@link RemoteViews} that describes the child.
+ * @param stableId An id that is stable across different versions of RemoteViews.
+ */
+ public void addStableView(@IdRes int viewId, @NonNull RemoteViews nestedView, int stableId) {
+ addAction(new ViewGroupActionAdd(viewId, nestedView, -1 /* index */, stableId));
+ }
+
+ /**
* Equivalent to calling {@link ViewGroup#addView(View, int)} after inflating the
* given {@link RemoteViews}.
*
@@ -4870,23 +5082,24 @@
public CancellationSignal applyAsync(Context context, ViewGroup parent,
Executor executor, OnViewAppliedListener listener, InteractionHandler handler,
SizeF size) {
- return getAsyncApplyTask(context, parent, listener, handler, size, null /* themeColors */)
- .startTaskOnExecutor(executor);
+ return applyAsync(context, parent, executor, listener, handler, size,
+ null /* themeColors */);
}
/** @hide */
public CancellationSignal applyAsync(Context context, ViewGroup parent, Executor executor,
OnViewAppliedListener listener, InteractionHandler handler, SizeF size,
ColorResources colorResources) {
- return getAsyncApplyTask(context, parent, listener, handler, size, colorResources)
- .startTaskOnExecutor(executor);
+ return new AsyncApplyTask(getRemoteViewsToApply(context, size), parent, context, listener,
+ handler, colorResources, null /* result */,
+ true /* topLevel */).startTaskOnExecutor(executor);
}
- private AsyncApplyTask getAsyncApplyTask(Context context, ViewGroup parent,
+ private AsyncApplyTask getInternalAsyncApplyTask(Context context, ViewGroup parent,
OnViewAppliedListener listener, InteractionHandler handler, SizeF size,
- ColorResources colorResources) {
+ ColorResources colorResources, View result) {
return new AsyncApplyTask(getRemoteViewsToApply(context, size), parent, context, listener,
- handler, colorResources, null /* result */);
+ handler, colorResources, result, false /* topLevel */);
}
private class AsyncApplyTask extends AsyncTask<Void, Void, ViewTree>
@@ -4898,6 +5111,12 @@
final OnViewAppliedListener mListener;
final InteractionHandler mHandler;
final ColorResources mColorResources;
+ /**
+ * Whether the remote view is the top-level one (i.e. not within an action).
+ *
+ * This is only used if the result is specified (i.e. the view is being recycled).
+ */
+ final boolean mTopLevel;
private View mResult;
private ViewTree mTree;
@@ -4906,13 +5125,15 @@
private AsyncApplyTask(
RemoteViews rv, ViewGroup parent, Context context, OnViewAppliedListener listener,
- InteractionHandler handler, ColorResources colorResources, View result) {
+ InteractionHandler handler, ColorResources colorResources,
+ View result, boolean topLevel) {
mRV = rv;
mParent = parent;
mContext = context;
mListener = listener;
mColorResources = colorResources;
mHandler = handler;
+ mTopLevel = topLevel;
mResult = result;
}
@@ -4959,6 +5180,10 @@
a.apply(viewTree.mRoot, mParent, handler, mColorResources);
}
}
+ // If the parent of the view is has is a root, resolve the recycling.
+ if (mTopLevel && mResult instanceof ViewGroup) {
+ finalizeViewRecycling((ViewGroup) mResult);
+ }
} catch (Exception e) {
mError = e;
}
@@ -5011,6 +5236,14 @@
/** @hide */
public void reapply(Context context, View v, InteractionHandler handler, SizeF size,
ColorResources colorResources) {
+ reapply(context, v, handler, size, colorResources, true);
+ }
+
+ // Note: topLevel should be true only for calls on the topLevel RemoteViews, internal calls
+ // should set it to false.
+ private void reapply(Context context, View v, InteractionHandler handler, SizeF size,
+ ColorResources colorResources, boolean topLevel) {
+
RemoteViews rvToApply = getRemoteViewsToApply(context, size);
// In the case that a view has this RemoteViews applied in one orientation or size, is
@@ -5025,6 +5258,11 @@
}
rvToApply.performApply(v, (ViewGroup) v.getParent(), handler, colorResources);
+
+ // If the parent of the view is has is a root, resolve the recycling.
+ if (topLevel && v instanceof ViewGroup) {
+ finalizeViewRecycling((ViewGroup) v);
+ }
}
/**
@@ -5068,8 +5306,8 @@
}
return new AsyncApplyTask(rvToApply, (ViewGroup) v.getParent(),
- context, listener, handler, colorResources, v).startTaskOnExecutor(
- executor);
+ context, listener, handler, colorResources, v, true /* topLevel */)
+ .startTaskOnExecutor(executor);
}
private void performApply(View v, ViewGroup parent, InteractionHandler handler,
@@ -5282,6 +5520,7 @@
mIdealSize.writeToParcel(dest, flags);
}
dest.writeInt(mLayoutId);
+ dest.writeInt(mViewId);
dest.writeInt(mLightBackgroundLayoutId);
writeActionsToParcel(dest);
} else if (hasSizedRemoteViews()) {
@@ -5470,6 +5709,14 @@
mChildren.add(index, child);
}
+ public void removeChildren(int start, int count) {
+ if (mChildren != null) {
+ for (int i = 0; i < count; i++) {
+ mChildren.remove(start);
+ }
+ }
+ }
+
private void addViewChild(View v) {
// ViewTree only contains Views which can be found using findViewById.
// If isRootNamespace is true, this view is skipped.
@@ -5500,6 +5747,28 @@
}
}
}
+
+ /** Find the first child for which the condition is true and return its index. */
+ public int findChildIndex(Predicate<View> condition) {
+ return findChildIndex(0, condition);
+ }
+
+ /**
+ * Find the first child, starting at {@code startIndex}, for which the condition is true and
+ * return its index.
+ */
+ public int findChildIndex(int startIndex, Predicate<View> condition) {
+ if (mChildren == null) {
+ return -1;
+ }
+
+ for (int i = startIndex; i < mChildren.size(); i++) {
+ if (condition.test(mChildren.get(i).mRoot)) {
+ return i;
+ }
+ }
+ return -1;
+ }
}
/**
@@ -5758,9 +6027,9 @@
}
/**
- * Set the ID of the top-level view of the XML layout.
+ * Set the ID of the top-level view of the XML layout.
*
- * Set to {@link View#NO_ID} to reset and simply keep the id defined in the XML layout.
+ * Set to {@link View#NO_ID} to reset and simply keep the id defined in the XML layout.
*
* @throws UnsupportedOperationException if the method is called on a RemoteViews defined in
* term of other RemoteViews (e.g. {@link #RemoteViews(RemoteViews, RemoteViews)}).
diff --git a/core/res/res/values/ids.xml b/core/res/res/values/ids.xml
index 3a41d5f..7bc4663 100644
--- a/core/res/res/values/ids.xml
+++ b/core/res/res/values/ids.xml
@@ -247,4 +247,10 @@
<!-- Accessibility action identifier for {@link android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction#ACTION_IME_ENTER}. -->
<item type="id" name="accessibilityActionImeEnter" />
+
+ <!-- View tag for remote views to store the index of the next child when adding nested remote views dynamically. -->
+ <item type="id" name="remote_views_next_child" />
+
+ <!-- View tag associating a view with its stable id for potential recycling. -->
+ <item type="id" name = "remote_views_stable_id" />
</resources>
diff --git a/core/res/res/values/symbols.xml b/core/res/res/values/symbols.xml
index 543dc15..5a7b1fa 100644
--- a/core/res/res/values/symbols.xml
+++ b/core/res/res/values/symbols.xml
@@ -4317,4 +4317,7 @@
<java-symbol type="bool" name="config_cecRcProfileSourceMediaContextSensitiveMenuHandled_default" />
<java-symbol type="bool" name="config_cecRcProfileSourceMediaContextSensitiveMenuNotHandled_allowed" />
<java-symbol type="bool" name="config_cecRcProfileSourceMediaContextSensitiveMenuNotHandled_default" />
+
+ <java-symbol type="id" name="remote_views_next_child" />
+ <java-symbol type="id" name="remote_views_stable_id" />
</resources>