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