1 /*
<lambda>null2 * Copyright 2022 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package androidx.compose.ui.platform
18
19 import android.content.Context
20 import android.view.View
21 import android.view.ViewGroup
22 import android.widget.FrameLayout
23 import android.widget.LinearLayout
24 import androidx.activity.ComponentActivity
25 import androidx.compose.runtime.Composable
26 import androidx.compose.runtime.DisposableEffect
27 import androidx.recyclerview.widget.DefaultItemAnimator
28 import androidx.recyclerview.widget.LinearLayoutManager
29 import androidx.recyclerview.widget.RecyclerView
30 import androidx.test.ext.junit.rules.ActivityScenarioRule
31 import androidx.test.ext.junit.runners.AndroidJUnit4
32 import androidx.test.filters.LargeTest
33 import androidx.test.platform.app.InstrumentationRegistry
34 import androidx.testutils.AnimationDurationScaleRule
35 import com.google.common.truth.Truth.assertThat
36 import kotlin.coroutines.resume
37 import kotlinx.coroutines.Dispatchers
38 import kotlinx.coroutines.android.awaitFrame
39 import kotlinx.coroutines.runBlocking
40 import kotlinx.coroutines.suspendCancellableCoroutine
41 import kotlinx.coroutines.withContext
42 import org.junit.Before
43 import org.junit.Rule
44 import org.junit.Test
45 import org.junit.runner.RunWith
46
47 /** Used to check the size of the RecycledViewPool */
48 private const val MaxItemsInAnyTest = 100
49
50 @LargeTest
51 @RunWith(AndroidJUnit4::class)
52 /**
53 * Note: this test's structure largely parallels PoolingContainerRecyclerViewTest (though there are
54 * notable implementation differences)
55 *
56 * Consider if new tests added here should also be added there.
57 */
58 class AndroidComposeViewsRecyclerViewTest {
59 @get:Rule val animationRule = AnimationDurationScaleRule.create()
60
61 @get:Rule var activityRule = ActivityScenarioRule(ComponentActivity::class.java)
62
63 lateinit var recyclerView: RecyclerView
64 lateinit var container: FrameLayout
65
66 private val instrumentation = InstrumentationRegistry.getInstrumentation()!!
67
68 @Before
69 fun setup() {
70 activityRule.scenario.onActivity { activity ->
71 container = FrameLayout(activity)
72 container.layoutParams =
73 ViewGroup.LayoutParams(
74 ViewGroup.LayoutParams.MATCH_PARENT,
75 ViewGroup.LayoutParams.MATCH_PARENT
76 )
77 activity.setContentView(container)
78 recyclerView = RecyclerView(activity)
79 setUpRecyclerView(recyclerView)
80 container.addView(recyclerView)
81 }
82 }
83
84 private fun setUpRecyclerView(rv: RecyclerView) {
85 activityRule.scenario.onActivity { activity ->
86 // Animators cause items to stick around and prevent clean rebinds, which we don't want,
87 // since it makes testing this less straightforward.
88 rv.itemAnimator = null
89 rv.layoutManager = LinearLayoutManager(activity, RecyclerView.VERTICAL, false)
90 rv.layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 100)
91 }
92 }
93
94 @Test
95 fun allItemsChanged_noDisposals() {
96 lateinit var adapter: PoolingContainerTestAdapter
97 activityRule.scenario.onActivity { activity ->
98 adapter = PoolingContainerTestAdapter(activity, 100)
99 recyclerView.adapter = adapter
100 }
101 instrumentation.runOnMainSync {}
102
103 // All items created and bound
104 assertThat(adapter.creations).isEqualTo(100)
105 assertThat(adapter.compositions).isEqualTo(100)
106 assertThat(adapter.binds).isEqualTo(100)
107
108 instrumentation.runOnMainSync { adapter.notifyItemRangeChanged(0, 100) }
109 instrumentation.runOnMainSync {}
110
111 // All items changed: no new creations, but all items rebound
112 assertThat(adapter.creations).isEqualTo(100)
113 assertThat(adapter.compositions).isEqualTo(100)
114 assertThat(adapter.releases).isEqualTo(0)
115 assertThat(adapter.binds).isEqualTo(200)
116 }
117
118 @Test
119 fun viewDiscarded_allDisposed() {
120 lateinit var adapter: PoolingContainerTestAdapter
121 activityRule.scenario.onActivity { activity ->
122 adapter = PoolingContainerTestAdapter(activity, 100)
123 recyclerView.adapter = adapter
124 }
125
126 instrumentation.runOnMainSync {}
127 assertThat(adapter.creations).isEqualTo(100)
128 assertThat(adapter.compositions).isEqualTo(100)
129 assertThat(adapter.releases).isEqualTo(0)
130
131 instrumentation.runOnMainSync { container.removeAllViews() }
132 assertThat(adapter.releases).isEqualTo(100)
133 }
134
135 @Test
136 fun reattachedAndDetached_disposedTwice() {
137 lateinit var adapter: PoolingContainerTestAdapter
138
139 activityRule.scenario.onActivity { activity ->
140 adapter = PoolingContainerTestAdapter(activity, 100)
141 recyclerView.adapter = adapter
142 }
143 instrumentation.runOnMainSync {}
144
145 // Initially added: all items created, no disposals
146 assertThat(adapter.creations).isEqualTo(100)
147 assertThat(adapter.compositions).isEqualTo(100)
148 assertThat(adapter.releases).isEqualTo(0)
149
150 instrumentation.runOnMainSync { container.removeAllViews() }
151
152 // Removed: all items disposed
153 assertThat(adapter.releases).isEqualTo(100)
154
155 activityRule.scenario.onActivity { container.addView(recyclerView) }
156
157 // Re-added: no new disposals, no new creations, all items recomposed
158 assertThat(adapter.creations).isEqualTo(100)
159 assertThat(adapter.compositions).isEqualTo(200)
160 assertThat(adapter.releases).isEqualTo(100)
161
162 activityRule.scenario.onActivity { container.removeAllViews() }
163
164 // Removed again: all items disposed a second time
165 assertThat(adapter.releases).isEqualTo(200)
166 }
167
168 @Test
169 fun poolReplaced_allDisposed() = runBlocking {
170 lateinit var adapter: PoolingContainerTestAdapter
171 activityRule.scenario.onActivity { activity ->
172 adapter = PoolingContainerTestAdapter(activity, 100, 2)
173 val pool = recyclerView.recycledViewPool
174 for (i in 0..9) {
175 pool.setMaxRecycledViews(i, 10)
176 }
177 recyclerView.adapter = adapter
178 }
179 instrumentation.runOnMainSync {}
180 assertThat(recyclerView.height).isEqualTo(100)
181 assertThat(adapter.creations).isEqualTo(50)
182
183 // Scroll to put some views into the shared pool
184 instrumentation.runOnMainSync { recyclerView.smoothScrollBy(0, 100) }
185
186 recyclerView.awaitScrollIdle()
187
188 assertThat(adapter.creations).isEqualTo(100)
189 assertThat(adapter.compositions).isEqualTo(100)
190 assertThat(adapter.releases).isEqualTo(0)
191
192 // Swap pool, confirm contents of old pool are disposed
193 instrumentation.runOnMainSync {
194 recyclerView.setRecycledViewPool(RecyclerView.RecycledViewPool())
195 }
196 activityRule.scenario.onActivity { container.removeAllViews() }
197 assertThat(adapter.releases).isEqualTo(100)
198 }
199
200 @Test
201 fun poolCleared_allDisposed() = runBlocking {
202 lateinit var adapter: PoolingContainerTestAdapter
203 activityRule.scenario.onActivity { activity ->
204 adapter = PoolingContainerTestAdapter(activity, 100, 2)
205 }
206 instrumentation.runOnMainSync {
207 val pool = recyclerView.recycledViewPool
208 for (i in 0..9) {
209 pool.setMaxRecycledViews(i, 10)
210 }
211 recyclerView.adapter = adapter
212 }
213
214 instrumentation.runOnMainSync {}
215
216 // Scroll to put some views into the shared pool
217 instrumentation.runOnMainSync { recyclerView.smoothScrollBy(0, 100) }
218
219 recyclerView.awaitScrollIdle()
220
221 assertThat(adapter.creations).isEqualTo(100)
222 assertThat(adapter.compositions).isEqualTo(100)
223 assertThat(adapter.releases).isEqualTo(0)
224
225 // Clear pool, remove from Activity, confirm contents of pool are disposed
226 instrumentation.runOnMainSync {
227 recyclerView.recycledViewPool.clear()
228 container.removeAllViews()
229 }
230 assertThat(adapter.releases).isEqualTo(100)
231 }
232
233 @Test
234 fun setAdapter_allDisposed() {
235 // Replacing the adapter when it is the only adapter attached to the pool means that
236 // the pool is cleared, so everything should be disposed.
237 doSetOrSwapTest(expectedDisposalsAfterBlock = 100) { recyclerView.adapter = it }
238 }
239
240 @Test
241 fun swapAdapter_noDisposals() {
242 doSetOrSwapTest(expectedDisposalsAfterBlock = 0) { recyclerView.swapAdapter(it, false) }
243 }
244
245 @Test
246 fun setAdapterToNull_allDisposed() {
247 doSetOrSwapTest(expectedDisposalsAfterBlock = 100) { recyclerView.adapter = null }
248 }
249
250 private fun doSetOrSwapTest(
251 expectedDisposalsAfterBlock: Int,
252 setOrSwapBlock: (PoolingContainerTestAdapter) -> Unit,
253 ) = runBlocking {
254 lateinit var adapter: PoolingContainerTestAdapter
255 lateinit var adapter2: PoolingContainerTestAdapter
256 activityRule.scenario.onActivity { activity ->
257 adapter = PoolingContainerTestAdapter(activity, 100, 2)
258 adapter2 = PoolingContainerTestAdapter(activity, 100, 2)
259 val pool = recyclerView.recycledViewPool
260 for (i in 0..9) {
261 pool.setMaxRecycledViews(i, 10)
262 }
263 recyclerView.adapter = adapter
264 }
265 instrumentation.runOnMainSync {}
266
267 // Scroll to put some views into the shared pool
268 withContext(Dispatchers.Main) { recyclerView.smoothScrollBy(0, 100) }
269 recyclerView.awaitScrollIdle()
270
271 assertThat(adapter.creations).isEqualTo(100)
272 assertThat(adapter.compositions).isEqualTo(100)
273 assertThat(adapter.releases).isEqualTo(0)
274
275 withContext(Dispatchers.Main) {
276 // Set or swap adapter, confirm expected results
277 setOrSwapBlock(adapter2)
278 }
279
280 assertThat(adapter.releases + adapter2.releases).isEqualTo(expectedDisposalsAfterBlock)
281
282 // Remove the RecyclerView, confirm everything is disposed
283 instrumentation.runOnMainSync { container.removeAllViews() }
284 assertThat(adapter.releases).isEqualTo(100)
285 assertThat(adapter2.creations).isEqualTo(adapter2.releases)
286 assertThat(adapter2.compositions).isEqualTo(adapter2.creations)
287 // ...and that nothing unexpected happened
288 assertThat(adapter.creations).isEqualTo(100)
289 assertThat(adapter.compositions).isEqualTo(100)
290 }
291
292 @Test
293 fun overflowingScrapTest() {
294 lateinit var adapter: PoolingContainerTestAdapter
295 activityRule.scenario.onActivity { activity ->
296 adapter = PoolingContainerTestAdapter(activity, 100)
297 recyclerView.adapter = adapter
298 val pool = recyclerView.recycledViewPool
299 for (i in 0..9) {
300 // We'll generate more scrap views of each type than this
301 pool.setMaxRecycledViews(i, 3)
302 }
303 }
304
305 instrumentation.runOnMainSync {}
306
307 // All items created and bound
308 assertThat(adapter.creations).isEqualTo(100)
309 assertThat(adapter.compositions).isEqualTo(100)
310 assertThat(adapter.binds).isEqualTo(100)
311
312 // Simulate removing and re-adding the first 100 items
313 instrumentation.runOnMainSync {
314 adapter.notifyItemRangeRemoved(0, 100)
315 adapter.notifyItemRangeInserted(0, 100)
316 }
317 instrumentation.runOnMainSync {}
318
319 assertThat(adapter.creations).isEqualTo(200)
320 assertThat(adapter.compositions).isEqualTo(200)
321
322 instrumentation.runOnMainSync { container.removeAllViews() }
323
324 // Make sure that all views were disposed, including those that never made it to the pool
325 assertThat(adapter.releases).isEqualTo(200)
326 }
327
328 @Test
329 fun sharedViewPool() =
330 runBlocking(Dispatchers.Main) {
331 val itemViewCacheSize = 2
332 container.removeAllViews()
333 val lp1 = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)
334 val lp2 = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)
335 val rv1: RecyclerView = recyclerView.also { it.layoutParams = lp1 }
336 lateinit var rv2: RecyclerView
337 lateinit var testContainer: LinearLayout
338 val pool = RecyclerView.RecycledViewPool()
339 lateinit var adapter1: PoolingContainerTestAdapter
340 lateinit var adapter2: PoolingContainerTestAdapter
341 activityRule.scenario.onActivity { activity ->
342 adapter1 = PoolingContainerTestAdapter(activity, 100, 10)
343 adapter2 = PoolingContainerTestAdapter(activity, 100, 10)
344
345 rv2 =
346 RecyclerView(activity).also {
347 setUpRecyclerView(it)
348 it.layoutParams = lp2
349 }
350 testContainer =
351 LinearLayout(activity).also {
352 it.layoutParams =
353 FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 200)
354 it.orientation = LinearLayout.VERTICAL
355 }
356 rv1.setItemViewCacheSize(itemViewCacheSize)
357 rv2.setItemViewCacheSize(itemViewCacheSize)
358 rv1.adapter = adapter1
359 rv2.adapter = adapter2
360 testContainer.addView(rv1)
361 testContainer.addView(rv2)
362 container.addView(testContainer)
363 for (i in 0..9) {
364 pool.setMaxRecycledViews(i, 10)
365 }
366 rv1.setRecycledViewPool(pool)
367 rv2.setRecycledViewPool(pool)
368 }
369
370 awaitFrame()
371 awaitFrame()
372
373 assertThat(adapter1.creations).isEqualTo(10)
374 assertThat(adapter1.compositions).isEqualTo(10)
375
376 // Scroll to put some views into the shared pool
377 rv1.scrollBy(0, 100)
378
379 // The RV keeps a couple items in its view cache before returning them to the pool
380 val expectedRecycledItems = 10 - itemViewCacheSize
381 assertThat(pool.getRecycledViewCount(0)).isEqualTo(expectedRecycledItems)
382
383 // Nothing should have been disposed yet, everything should have gone to the pool
384 assertThat(adapter1.releases + adapter2.releases).isEqualTo(0)
385
386 val adapter1Creations = adapter1.creations
387 // There were 10, we scrolled 10 more into view, plus maybe prefetching
388 assertThat(adapter1Creations).isAtLeast(20)
389 val adapter1Compositions = adapter1.compositions
390 // Currently, prefetched views don't end up being composed, but that could change
391 assertThat(adapter1Compositions).isAtLeast(20)
392
393 // Remove the first RecyclerView
394 testContainer.removeView(rv1)
395 // get the relayout
396 awaitFrame()
397 awaitFrame()
398
399 // After the first RecyclerView is removed, we expect everything it created to be
400 // disposed,
401 // *except* for what's in the shared pool
402 assertThat(adapter1.creations).isEqualTo(adapter1Creations) // just checking
403 assertThat(adapter1Compositions).isEqualTo(adapter1.compositions) // just checking
404 assertThat(pool.size).isEqualTo(expectedRecycledItems)
405 // We need to check compositions, not creations, because if it's not composed, it won't
406 // be
407 // disposed.
408 assertThat(adapter1.releases).isEqualTo(adapter1.compositions - expectedRecycledItems)
409 assertThat(adapter2.creations).isEqualTo(20) // it's twice as tall with rv1 gone
410 assertThat(adapter2.compositions).isEqualTo(20) // it's twice as tall with rv1 gone
411 assertThat(adapter2.releases).isEqualTo(0) // it hasn't scrolled
412
413 testContainer.removeView(rv2)
414 awaitFrame()
415
416 assertThat(adapter1.creations).isEqualTo(adapter1Creations) // just to be really sure...
417 // double-check that nothing weird happened
418 assertThat(adapter1.compositions).isEqualTo(20)
419 // at this point they're all off
420 assertThat(adapter1.releases).isEqualTo(adapter1.compositions)
421 assertThat(adapter2.creations).isEqualTo(20) // again, just checking
422 assertThat(adapter2.compositions).isEqualTo(20) // again, just checking
423 assertThat(adapter2.releases).isEqualTo(20) // all of these should be gone too
424 }
425
426 @Test
427 fun animationTest() = runBlocking {
428 animationRule.setAnimationDurationScale(1f)
429
430 withContext(Dispatchers.Main) { recyclerView.itemAnimator = DefaultItemAnimator() }
431
432 lateinit var adapter: PoolingContainerTestAdapter
433 activityRule.scenario.onActivity { activity ->
434 adapter = PoolingContainerTestAdapter(activity, 100, itemHeightPx = 2)
435 recyclerView.adapter = adapter
436 }
437 awaitFrame()
438
439 // All this needs to be on the main thread so that the animation doesn't progress and lead
440 // to race conditions.
441 withContext(Dispatchers.Main) {
442 // Remove all onscreen items
443 adapter.items = 50
444 adapter.notifyItemRangeRemoved(0, 50)
445
446 // For some reason, one frame isn't enough
447 awaitFrame()
448 awaitFrame()
449
450 // Animation started: 50 new items created, existing 50 animating out
451 // and so they can't be released yet
452 assertThat(adapter.releases).isEqualTo(0)
453 assertThat(adapter.creations).isEqualTo(100)
454 assertThat(adapter.compositions).isEqualTo(100)
455
456 // After the animation, the original 50 are either disposed or in the pool
457 recyclerView.awaitItemAnimationsComplete()
458 // Assumption check: if they're *all* in the pool,
459 // this test isn't very useful and we need to make the pool smaller for this test.
460 assertThat(adapter.releases).isGreaterThan(0)
461 assertThat(adapter.releases).isEqualTo(50 - recyclerView.recycledViewPool.size)
462 assertThat(adapter.creations).isEqualTo(100)
463 assertThat(adapter.compositions).isEqualTo(100)
464 }
465 }
466 }
467
468 class PoolingContainerTestAdapter(
469 val context: Context,
470 var items: Int,
471 private val itemHeightPx: Int = 1
472 ) : RecyclerView.Adapter<PoolingContainerTestAdapter.ViewHolder>() {
473 init {
474 if (items > MaxItemsInAnyTest) {
475 throw IllegalArgumentException(
476 "$items > $MaxItemsInAnyTest, increase MaxItemsInAnyTest"
477 )
478 }
479 }
480
481 class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
482
483 var creations = 0
484 var compositions = 0
485 var binds = 0
486 var releases = 0
487
onCreateViewHoldernull488 override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
489 val view = DisposalCountingComposeView(context, this)
490 view.layoutParams =
491 RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, itemHeightPx)
492
493 creations++
494
495 return ViewHolder(view)
496 }
497
onBindViewHoldernull498 override fun onBindViewHolder(holder: ViewHolder, position: Int) {
499 binds++
500 }
501
getItemViewTypenull502 override fun getItemViewType(position: Int): Int {
503 return position / 10
504 }
505
getItemCountnull506 override fun getItemCount(): Int = items
507 }
508
509 class DisposalCountingComposeView(
510 context: Context,
511 private val adapter: PoolingContainerTestAdapter
512 ) : AbstractComposeView(context) {
513 @Composable
514 override fun Content() {
515 DisposableEffect(true) {
516 adapter.compositions++
517 onDispose { adapter.releases++ }
518 }
519 }
520 }
521
awaitScrollIdlenull522 private suspend fun RecyclerView.awaitScrollIdle() {
523 val rv = this
524 withContext(Dispatchers.Main) {
525 suspendCancellableCoroutine<Unit> { continuation ->
526 val listener =
527 object : RecyclerView.OnScrollListener() {
528 override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
529 if (newState == RecyclerView.SCROLL_STATE_IDLE) {
530 continuation.resume(Unit)
531 }
532 }
533 }
534
535 rv.addOnScrollListener(listener)
536
537 continuation.invokeOnCancellation { rv.removeOnScrollListener(listener) }
538
539 if (rv.scrollState == RecyclerView.SCROLL_STATE_IDLE) {
540 continuation.resume(Unit)
541 }
542 }
543 }
544 }
545
awaitItemAnimationsCompletenull546 private suspend fun RecyclerView.awaitItemAnimationsComplete() {
547 val rv = this
548 withContext(Dispatchers.Main) {
549 suspendCancellableCoroutine<Unit> { continuation ->
550 val animator =
551 rv.itemAnimator
552 ?: throw IllegalStateException(
553 "awaitItemAnimationsComplete() was called on a RecyclerView with no ItemAnimator." +
554 " This may have been unintended."
555 )
556 animator.isRunning { continuation.resume(Unit) }
557 }
558 }
559 }
560
561 private val RecyclerView.RecycledViewPool.size: Int
562 get() {
563 var items = 0
564 for (type in 0..(MaxItemsInAnyTest - 1) / 10) {
565 items += this.getRecycledViewCount(type)
566 }
567 return items
568 }
569