Skip to content

Commit ffd44ee

Browse files
[Fireperf][AASA] FirstDrawDoneListener (#4041)
* WIP * handler * handle bug prior to api 26 * tests * copyright * improve tests * comment fix * improve tests * comment * backport View.isAttachedToWindow() * improved registerForNextDraw_delaysAddingListenerForAPIsBelow26 * remove LayoutChangeListener * add check to mmake sure OnAttachStateChangeListener removes itself * mcreate helper isAliveAndAttached * improve test with Robolectric ShadowLooper
1 parent a84fe26 commit ffd44ee

File tree

2 files changed

+282
-0
lines changed

2 files changed

+282
-0
lines changed
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright 2022 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
//
6+
// You may obtain a copy of the License at
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
package com.google.firebase.perf.util;
15+
16+
import android.os.Build;
17+
import android.os.Handler;
18+
import android.os.Looper;
19+
import android.view.View;
20+
import android.view.ViewTreeObserver;
21+
import androidx.annotation.RequiresApi;
22+
import java.util.concurrent.atomic.AtomicReference;
23+
24+
/**
25+
* OnDrawListener that unregisters itself and invokes callback when the next draw is done. This API
26+
* 16+ implementation is an approximation of the initial display time. {@link
27+
* android.view.Choreographer#postFrameCallback} is an Android API that provides a simpler and more
28+
* accurate initial display time, but it was bugged before API 30, hence we use this backported
29+
* implementation.
30+
*/
31+
@RequiresApi(Build.VERSION_CODES.JELLY_BEAN)
32+
public class FirstDrawDoneListener implements ViewTreeObserver.OnDrawListener {
33+
private final Handler mainThreadHandler = new Handler(Looper.getMainLooper());
34+
private final AtomicReference<View> viewReference;
35+
private final Runnable callback;
36+
37+
/** Registers a post-draw callback for the next draw of a view. */
38+
public static void registerForNextDraw(View view, Runnable drawDoneCallback) {
39+
final FirstDrawDoneListener listener = new FirstDrawDoneListener(view, drawDoneCallback);
40+
// Handle bug prior to API 26 where OnDrawListener from the floating ViewTreeObserver is not
41+
// merged into the real ViewTreeObserver.
42+
// https://android.googlesource.com/platform/frameworks/base/+/9f8ec54244a5e0343b9748db3329733f259604f3
43+
if (Build.VERSION.SDK_INT < 26 && !isAliveAndAttached(view)) {
44+
view.addOnAttachStateChangeListener(
45+
new View.OnAttachStateChangeListener() {
46+
@Override
47+
public void onViewAttachedToWindow(View view) {
48+
view.getViewTreeObserver().addOnDrawListener(listener);
49+
view.removeOnAttachStateChangeListener(this);
50+
}
51+
52+
@Override
53+
public void onViewDetachedFromWindow(View view) {
54+
view.removeOnAttachStateChangeListener(this);
55+
}
56+
});
57+
} else {
58+
view.getViewTreeObserver().addOnDrawListener(listener);
59+
}
60+
}
61+
62+
private FirstDrawDoneListener(View view, Runnable callback) {
63+
this.viewReference = new AtomicReference<>(view);
64+
this.callback = callback;
65+
}
66+
67+
@Override
68+
public void onDraw() {
69+
// Set viewReference to null so any onDraw past the first is a no-op
70+
View view = viewReference.getAndSet(null);
71+
if (view == null) {
72+
return;
73+
}
74+
// OnDrawListeners cannot be removed within onDraw, so we remove it with a
75+
// GlobalLayoutListener
76+
view.getViewTreeObserver()
77+
.addOnGlobalLayoutListener(() -> view.getViewTreeObserver().removeOnDrawListener(this));
78+
mainThreadHandler.postAtFrontOfQueue(callback);
79+
}
80+
81+
/**
82+
* Helper to avoid <a
83+
* href="https://android.googlesource.com/platform/frameworks/base/+/9f8ec54244a5e0343b9748db3329733f259604f3">bug
84+
* prior to API 26</a>, where the floating ViewTreeObserver's OnDrawListeners are not merged into
85+
* the real ViewTreeObserver during attach.
86+
*
87+
* @return true if the View is already attached and the ViewTreeObserver is not a floating
88+
* placeholder.
89+
*/
90+
private static boolean isAliveAndAttached(View view) {
91+
return view.getViewTreeObserver().isAlive() && isAttachedToWindow(view);
92+
}
93+
94+
/** Backport {@link View#isAttachedToWindow()} which is API 19+ only. */
95+
private static boolean isAttachedToWindow(View view) {
96+
if (Build.VERSION.SDK_INT >= 19) {
97+
return view.isAttachedToWindow();
98+
}
99+
return view.getWindowToken() != null;
100+
}
101+
}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// Copyright 2022 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
//
6+
// You may obtain a copy of the License at
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
package com.google.firebase.perf.util;
15+
16+
import static com.google.common.truth.Truth.assertThat;
17+
import static org.mockito.Mockito.inOrder;
18+
import static org.mockito.Mockito.mock;
19+
import static org.mockito.Mockito.never;
20+
import static org.mockito.Mockito.times;
21+
import static org.mockito.Mockito.verify;
22+
import static org.robolectric.Shadows.shadowOf;
23+
24+
import android.os.Build;
25+
import android.os.Handler;
26+
import android.os.Looper;
27+
import android.view.View;
28+
import android.view.ViewTreeObserver;
29+
import android.view.ViewTreeObserver.OnDrawListener;
30+
import androidx.test.core.app.ApplicationProvider;
31+
import java.lang.reflect.Field;
32+
import java.util.ArrayList;
33+
import java.util.List;
34+
import java.util.concurrent.CopyOnWriteArrayList;
35+
import org.junit.Before;
36+
import org.junit.Test;
37+
import org.junit.runner.RunWith;
38+
import org.mockito.InOrder;
39+
import org.robolectric.RobolectricTestRunner;
40+
import org.robolectric.annotation.Config;
41+
import org.robolectric.annotation.LooperMode;
42+
43+
/** Unit tests for {@link FirstDrawDoneListener}. */
44+
@RunWith(RobolectricTestRunner.class)
45+
@LooperMode(LooperMode.Mode.PAUSED)
46+
public class FirstDrawDoneListenerTest {
47+
private View testView;
48+
49+
@Before
50+
public void setUp() {
51+
testView = new View(ApplicationProvider.getApplicationContext());
52+
}
53+
54+
@Test
55+
@Config(sdk = 25)
56+
public void registerForNextDraw_delaysAddingListenerForAPIsBelow26()
57+
throws NoSuchFieldException, IllegalAccessException, ClassNotFoundException {
58+
ArrayList<OnDrawListener> mOnDrawListeners =
59+
initViewTreeObserverWithListener(testView.getViewTreeObserver());
60+
assertThat(mOnDrawListeners.size()).isEqualTo(0);
61+
62+
// OnDrawListener is not registered, it is delayed for later
63+
FirstDrawDoneListener.registerForNextDraw(testView, () -> {});
64+
assertThat(mOnDrawListeners.size()).isEqualTo(0);
65+
66+
// Register listener after the view is attached to a window
67+
List<View.OnAttachStateChangeListener> attachListeners = dispatchAttachedToWindow(testView);
68+
assertThat(mOnDrawListeners.size()).isEqualTo(1);
69+
assertThat(mOnDrawListeners.get(0)).isInstanceOf(FirstDrawDoneListener.class);
70+
assertThat(attachListeners).isEmpty();
71+
}
72+
73+
@Test
74+
@Config(sdk = 26)
75+
public void registerForNextDraw_directlyAddsListenerForApi26AndAbove()
76+
throws NoSuchFieldException, IllegalAccessException {
77+
ArrayList<OnDrawListener> mOnDrawListeners =
78+
initViewTreeObserverWithListener(testView.getViewTreeObserver());
79+
assertThat(mOnDrawListeners.size()).isEqualTo(0);
80+
81+
// Immediately register an OnDrawListener to ViewTreeObserver
82+
FirstDrawDoneListener.registerForNextDraw(testView, () -> {});
83+
assertThat(mOnDrawListeners.size()).isEqualTo(1);
84+
assertThat(mOnDrawListeners.get(0)).isInstanceOf(FirstDrawDoneListener.class);
85+
}
86+
87+
@Test
88+
@Config(sdk = 26)
89+
public void onDraw_postsCallbackToFrontOfQueue() {
90+
Handler handler = new Handler(Looper.getMainLooper());
91+
Runnable drawDoneCallback = mock(Runnable.class);
92+
Runnable otherCallback = mock(Runnable.class);
93+
InOrder inOrder = inOrder(drawDoneCallback, otherCallback);
94+
95+
FirstDrawDoneListener.registerForNextDraw(testView, drawDoneCallback);
96+
handler.post(otherCallback); // 3rd in queue
97+
handler.postAtFrontOfQueue(otherCallback); // 2nd in queue
98+
testView.getViewTreeObserver().dispatchOnDraw(); // 1st in queue
99+
verify(drawDoneCallback, never()).run();
100+
verify(otherCallback, never()).run();
101+
102+
// Execute all posted tasks
103+
shadowOf(Looper.getMainLooper()).idle();
104+
inOrder.verify(drawDoneCallback, times(1)).run();
105+
inOrder.verify(otherCallback, times(2)).run();
106+
inOrder.verifyNoMoreInteractions();
107+
}
108+
109+
@Test
110+
@Config(sdk = 26)
111+
public void onDraw_unregistersItself_inLayoutChangeListener()
112+
throws NoSuchFieldException, IllegalAccessException {
113+
ArrayList<OnDrawListener> mOnDrawListeners =
114+
initViewTreeObserverWithListener(testView.getViewTreeObserver());
115+
FirstDrawDoneListener.registerForNextDraw(testView, () -> {});
116+
assertThat(mOnDrawListeners.size()).isEqualTo(1);
117+
118+
// Does not remove OnDrawListener before onDraw, even if OnGlobalLayout is triggered
119+
testView.getViewTreeObserver().dispatchOnGlobalLayout();
120+
assertThat(mOnDrawListeners.size()).isEqualTo(1);
121+
122+
// Removes OnDrawListener in the next OnGlobalLayout after onDraw
123+
testView.getViewTreeObserver().dispatchOnDraw();
124+
testView.getViewTreeObserver().dispatchOnGlobalLayout();
125+
assertThat(mOnDrawListeners.size()).isEqualTo(0);
126+
}
127+
128+
/**
129+
* Returns ViewTreeObserver.mOnDrawListeners field through reflection. Since reflections are
130+
* employed, prefer to be used in tests with fixed API level using @Config(sdk = X).
131+
*
132+
* @param vto ViewTreeObserver instance to initialize and return the mOnDrawListeners from
133+
*/
134+
private static ArrayList<OnDrawListener> initViewTreeObserverWithListener(ViewTreeObserver vto)
135+
throws NoSuchFieldException, IllegalAccessException {
136+
// Adding a listener forces ViewTreeObserver.mOnDrawListeners to be initialized and non-null.
137+
OnDrawListener placeHolder = () -> {};
138+
vto.addOnDrawListener(placeHolder);
139+
vto.removeOnDrawListener(placeHolder);
140+
141+
// Obtain mOnDrawListeners field through reflection
142+
Field mOnDrawListeners =
143+
android.view.ViewTreeObserver.class.getDeclaredField("mOnDrawListeners");
144+
mOnDrawListeners.setAccessible(true);
145+
ArrayList<OnDrawListener> listeners = (ArrayList<OnDrawListener>) mOnDrawListeners.get(vto);
146+
assertThat(listeners).isNotNull();
147+
assertThat(listeners.size()).isEqualTo(0);
148+
return listeners;
149+
}
150+
151+
/**
152+
* Simulates {@link View}'s dispatchAttachedToWindow() on API 25 using reflection.
153+
*
154+
* <p>This only simulates the part where dispatchAttachedToWindow() notifies the list of {@link
155+
* View.OnAttachStateChangeListener}.
156+
*
157+
* @param view the view in which we are simulating dispatchAttachedToWindow().
158+
* @return list of {@link View.OnAttachStateChangeListener} from the input {@link View}
159+
*/
160+
private static List<View.OnAttachStateChangeListener> dispatchAttachedToWindow(View view)
161+
throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
162+
assert Build.VERSION.SDK_INT == 25;
163+
Class<?> listenerInfo = Class.forName("android.view.View$ListenerInfo");
164+
Field mListenerInfo = View.class.getDeclaredField("mListenerInfo");
165+
mListenerInfo.setAccessible(true);
166+
Object li = mListenerInfo.get(view);
167+
assertThat(li).isNotNull();
168+
Field mOnAttachStateChangeListeners =
169+
listenerInfo.getDeclaredField("mOnAttachStateChangeListeners");
170+
mOnAttachStateChangeListeners.setAccessible(true);
171+
CopyOnWriteArrayList<View.OnAttachStateChangeListener> listeners =
172+
(CopyOnWriteArrayList<View.OnAttachStateChangeListener>)
173+
mOnAttachStateChangeListeners.get(li);
174+
assertThat(listeners).isNotNull();
175+
assertThat(listeners).isNotEmpty();
176+
for (View.OnAttachStateChangeListener listener : listeners) {
177+
listener.onViewAttachedToWindow(view);
178+
}
179+
return listeners;
180+
}
181+
}

0 commit comments

Comments
 (0)