[go: nahoru, domu]

blob: cc6560bcd2d5536af88f990575c0a4b06bc296ac [file] [log] [blame]
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.recyclerview.widget
import android.content.Context
import android.os.Build
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.annotation.RequiresApi
import androidx.customview.poolingcontainer.addPoolingContainerListener
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.filters.SdkSuppress
import androidx.test.internal.runner.junit4.statement.UiThreadStatement.runOnUiThread
import androidx.testutils.AnimationDurationScaleRule
import com.google.common.truth.Truth.assertThat
import kotlin.coroutines.resume
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.android.awaitFrame
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@LargeTest
@RunWith(AndroidJUnit4::class)
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
/**
* Note: this test's structure largely parallels AndroidComposeViewsRecyclerViewTest
* (though there are notable implementation differences)
*
* Consider if new tests added here should also be added there.
*/
class PoolingContainerRecyclerViewTest : BaseRecyclerViewInstrumentationTest() {
@get:Rule
val animationRule = AnimationDurationScaleRule.create()
@Before
fun setup() {
val rv = RecyclerView(activity)
setRecyclerView(rv, false, false)
setUpRecyclerView(rv)
}
private fun setUpRecyclerView(recyclerView: RecyclerView) {
runOnUiThread {
// Animators cause items to stick around and prevent clean rebinds, which we don't want,
// since it makes testing this less straightforward.
recyclerView.itemAnimator = null
recyclerView.layoutManager =
LinearLayoutManager(activity, RecyclerView.VERTICAL, false)
recyclerView.layoutParams = TestedFrameLayout.FullControlLayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, 100
)
}
}
@Test
fun allItemsChanged_noDisposals() {
val adapter = PoolingContainerTestAdapter(activity, 100)
setAdapter(adapter)
instrumentation.runOnMainSync { }
// All items created and bound
assertThat(adapter.creations).isEqualTo(100)
assertThat(adapter.binds).isEqualTo(100)
instrumentation.runOnMainSync { adapter.notifyItemRangeChanged(0, 100) }
instrumentation.runOnMainSync { }
// All items changed: no new creations, but all items rebound
assertThat(adapter.creations).isEqualTo(100)
assertThat(adapter.releases).isEqualTo(0)
assertThat(adapter.binds).isEqualTo(200)
}
@Test
fun viewDiscarded_allDisposed() {
val adapter = PoolingContainerTestAdapter(activity, 100)
setAdapter(adapter)
instrumentation.runOnMainSync { }
assertThat(adapter.creations).isEqualTo(100)
assertThat(adapter.releases).isEqualTo(0)
instrumentation.runOnMainSync { activity.container.removeAllViews() }
assertThat(adapter.releases).isEqualTo(100)
}
@Test
fun reattachedAndDetached_disposedTwice() {
val adapter = PoolingContainerTestAdapter(activity, 100)
setAdapter(adapter)
instrumentation.runOnMainSync { }
// Initially added: all items created, no disposals
assertThat(adapter.creations).isEqualTo(100)
assertThat(adapter.releases).isEqualTo(0)
instrumentation.runOnMainSync { activity.container.removeAllViews() }
// Removed: all items disposed
assertThat(adapter.releases).isEqualTo(100)
instrumentation.runOnMainSync { activity.container.addView(mRecyclerView) }
// Re-added: no new disposals, no new creations
assertThat(adapter.creations).isEqualTo(100)
assertThat(adapter.releases).isEqualTo(100)
instrumentation.runOnMainSync { activity.container.removeAllViews() }
// Removed again: all items disposed a second time
assertThat(adapter.releases).isEqualTo(200)
}
@Test
fun poolReplaced_allDisposed() = runBlocking {
val adapter = PoolingContainerTestAdapter(activity, 100, 2)
instrumentation.runOnMainSync {
val pool = mRecyclerView.recycledViewPool
for (i in 0..9) {
pool.setMaxRecycledViews(i, 10)
}
}
setAdapter(adapter)
instrumentation.runOnMainSync { }
assertThat(mRecyclerView.height).isEqualTo(100)
assertThat(adapter.creations).isEqualTo(50)
// Scroll to put some views into the shared pool
instrumentation.runOnMainSync {
mRecyclerView.smoothScrollBy(0, 100)
}
mRecyclerView.awaitScrollIdle()
assertThat(adapter.creations).isEqualTo(100)
assertThat(adapter.releases).isEqualTo(0)
assertThat(mRecyclerView.recycledViewPool.mAttachCountForClearing).isEqualTo(1)
assertThat(mRecyclerView.recycledViewPool.size())
.isEqualTo(50 - mRecyclerView.mRecycler.mViewCacheMax)
// Swap pool, confirm contents of old pool are disposed
instrumentation.runOnMainSync {
mRecyclerView.setRecycledViewPool(RecyclerView.RecycledViewPool())
}
instrumentation.runOnMainSync { recyclerViewContainer.removeAllViews() }
assertThat(adapter.releases).isEqualTo(100)
}
@Test
fun poolCleared_allDisposed() = runBlocking {
val adapter = PoolingContainerTestAdapter(activity, 100, 2)
instrumentation.runOnMainSync {
val pool = mRecyclerView.recycledViewPool
for (i in 0..9) {
pool.setMaxRecycledViews(i, 10)
}
}
setAdapter(adapter)
instrumentation.runOnMainSync { }
// Scroll to put some views into the shared pool
instrumentation.runOnMainSync {
mRecyclerView.smoothScrollBy(0, 100)
}
mRecyclerView.awaitScrollIdle()
assertThat(adapter.creations).isEqualTo(100)
assertThat(adapter.releases).isEqualTo(0)
assertThat(mRecyclerView.recycledViewPool.mAttachCountForClearing).isEqualTo(1)
assertThat(mRecyclerView.recycledViewPool.size())
.isEqualTo(50 - mRecyclerView.mRecycler.mViewCacheMax)
// Clear pool, confirm contents of pool are disposed
instrumentation.runOnMainSync {
mRecyclerView.recycledViewPool.clear()
}
instrumentation.runOnMainSync { recyclerViewContainer.removeAllViews() }
assertThat(adapter.releases).isEqualTo(100)
}
@Test
fun setAdapter_allDisposed() {
// Replacing the adapter when it is the only adapter attached to the pool means that
// the pool is cleared, so everything should be disposed.
doSetOrSwapTest(expectedDisposalsAfterBlock = 100) {
mRecyclerView.adapter = it
}
}
@Test
fun swapAdapter_noDisposals() {
doSetOrSwapTest(expectedDisposalsAfterBlock = 0) {
mRecyclerView.swapAdapter(it, false)
}
}
@Test
fun setAdapterToNull_allDisposed() {
doSetOrSwapTest(expectedDisposalsAfterBlock = 100) {
mRecyclerView.adapter = null
}
}
private fun doSetOrSwapTest(
expectedDisposalsAfterBlock: Int,
setOrSwapBlock: (PoolingContainerTestAdapter) -> Unit,
) = runBlocking {
val adapter = PoolingContainerTestAdapter(activity, 100, 2)
val adapter2 = PoolingContainerTestAdapter(activity, 100, 2)
instrumentation.runOnMainSync {
val pool = mRecyclerView.recycledViewPool
for (i in 0..9) {
pool.setMaxRecycledViews(i, 10)
}
}
setAdapter(adapter)
instrumentation.runOnMainSync { }
// Scroll to put some views into the shared pool
instrumentation.runOnMainSync {
mRecyclerView.smoothScrollBy(0, 100)
}
mRecyclerView.awaitScrollIdle()
assertThat(adapter.creations).isEqualTo(100)
assertThat(adapter.releases).isEqualTo(0)
assertThat(mRecyclerView.recycledViewPool.mAttachCountForClearing).isEqualTo(1)
assertThat(mRecyclerView.recycledViewPool.size())
.isEqualTo(50 - mRecyclerView.mRecycler.mViewCacheMax)
// Set or swap adapter, confirm contents of pool are correct
instrumentation.runOnMainSync {
setOrSwapBlock(adapter2)
}
assertThat(adapter.releases + adapter2.releases).isEqualTo(expectedDisposalsAfterBlock)
// Remove the RecyclerView, confirm everything is disposed
instrumentation.runOnMainSync { recyclerViewContainer.removeAllViews() }
assertThat(adapter.releases).isEqualTo(100)
assertThat(adapter2.creations).isEqualTo(adapter2.releases)
// ...and that nothing unexpected happened
assertThat(adapter.creations).isEqualTo(100)
}
@Test
fun overflowingScrapTest() {
lateinit var adapter: PoolingContainerTestAdapter
instrumentation.runOnMainSync {
adapter = PoolingContainerTestAdapter(activity, 100)
setAdapter(adapter)
val pool = mRecyclerView.recycledViewPool
for (i in 0..9) {
// We'll generate more scrap views of each type than this
pool.setMaxRecycledViews(i, 3)
}
}
instrumentation.runOnMainSync { }
// All items created and bound
assertThat(adapter.creations).isEqualTo(100)
assertThat(adapter.binds).isEqualTo(100)
// Simulate removing and re-adding the first 100 items
instrumentation.runOnMainSync {
adapter.notifyItemRangeRemoved(0, 100)
adapter.notifyItemRangeInserted(0, 100)
}
instrumentation.runOnMainSync { }
assertThat(adapter.creations).isEqualTo(200)
instrumentation.runOnMainSync { recyclerViewContainer.removeAllViews() }
// Make sure that all views were disposed, including those that never made it to the pool
assertThat(adapter.releases).isEqualTo(200)
}
@Test
fun sharedViewPool() {
val itemViewCacheSize = 2
instrumentation.runOnMainSync {
activity.container.removeAllViews()
}
val lp1 = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)
val lp2 = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)
val rv1: RecyclerView = mRecyclerView.also { it.layoutParams = lp1 }
lateinit var rv2: RecyclerView
lateinit var container: LinearLayout
val pool = RecyclerView.RecycledViewPool()
val adapter1 = PoolingContainerTestAdapter(activity, 100, 10)
val adapter2 = PoolingContainerTestAdapter(activity, 100, 10)
instrumentation.runOnMainSync {
rv2 = RecyclerView(activity).also { setUpRecyclerView(it); it.layoutParams = lp2 }
container = LinearLayout(activity).also {
it.layoutParams = TestedFrameLayout.FullControlLayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
200
)
it.orientation = LinearLayout.VERTICAL
}
rv1.setItemViewCacheSize(itemViewCacheSize)
rv2.setItemViewCacheSize(itemViewCacheSize)
rv1.adapter = adapter1
rv2.adapter = adapter2
container.addView(rv1)
container.addView(rv2)
activity.container.addView(container)
for (i in 0..9) {
pool.setMaxRecycledViews(i, 10)
}
rv1.setRecycledViewPool(pool)
rv2.setRecycledViewPool(pool)
}
instrumentation.runOnMainSync { }
assertThat(adapter1.creations).isEqualTo(10)
// Scroll to put some views into the shared pool
repeat(10) {
instrumentation.runOnMainSync {
rv1.scrollBy(0, 10)
}
}
// The RV keeps a couple items in its view cache before returning them to the pool
val expectedRecycledItems = 10 - itemViewCacheSize
assertThat(pool.getRecycledViewCount(0)).isEqualTo(expectedRecycledItems)
// Nothing should have been disposed yet, everything should have gone to the pool
assertThat(adapter1.releases + adapter2.releases).isEqualTo(0)
val adapter1Creations = adapter1.creations
// There were 10, we scrolled 10 more into view, plus maybe prefetching
assertThat(adapter1Creations).isAtLeast(20)
// Remove the first RecyclerView
instrumentation.runOnMainSync {
container.removeView(rv1)
}
instrumentation.runOnMainSync { } // get the relayout
// After the first RecyclerView is removed, we expect everything it created to be disposed,
// *except* for what's in the shared pool
assertThat(adapter1.creations).isEqualTo(adapter1Creations) // just checking
assertThat(pool.size()).isEqualTo(expectedRecycledItems)
assertThat(adapter1.releases).isEqualTo(adapter1Creations - expectedRecycledItems)
assertThat(adapter2.creations).isEqualTo(20) // it's twice as tall with rv1 gone
assertThat(adapter2.releases).isEqualTo(0) // it hasn't scrolled
instrumentation.runOnMainSync {
container.removeView(rv2)
}
assertThat(adapter1.creations).isEqualTo(adapter1Creations) // just to be really sure...
assertThat(adapter1.releases).isEqualTo(adapter1Creations) // at this point they're all off
assertThat(adapter2.creations).isEqualTo(20) // again, just checking
assertThat(adapter2.releases).isEqualTo(20) // all of these should be gone too
}
@Test
fun animationTest() = runBlocking {
animationRule.setAnimationDurationScale(1f)
withContext(Dispatchers.Main) {
mRecyclerView.itemAnimator = DefaultItemAnimator()
}
val adapter = PoolingContainerTestAdapter(activity, 100, itemHeightPx = 2)
setAdapter(adapter)
awaitFrame()
// All this needs to be on the main thread so that the animation doesn't progress and lead
// to race conditions.
withContext(Dispatchers.Main) {
// Remove all onscreen items
adapter.items = 50
adapter.notifyItemRangeRemoved(0, 50)
// For some reason, one frame isn't enough
awaitFrame()
awaitFrame()
// Animation started: 50 new items created, existing 50 animating out
// and so they can't be released yet
assertThat(adapter.releases).isEqualTo(0)
assertThat(adapter.creations).isEqualTo(100)
// After the animation, the original 50 are either disposed or in the pool
mRecyclerView.awaitItemAnimationsComplete()
// Assumption check: if they're *all* in the pool,
// this test isn't very useful and we need to make the pool smaller for this test.
assertThat(adapter.releases).isGreaterThan(0)
assertThat(adapter.releases).isEqualTo(50 - mRecyclerView.recycledViewPool.size())
assertThat(adapter.creations).isEqualTo(100)
}
}
}
class PoolingContainerTestAdapter(
val context: Context,
var items: Int,
private val itemHeightPx: Int = 1
) :
RecyclerView.Adapter<PoolingContainerTestAdapter.ViewHolder>() {
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
var creations = 0
var binds = 0
var releases = 0
@RequiresApi(Build.VERSION_CODES.KITKAT)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = View(context)
view.layoutParams =
RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, itemHeightPx)
creations++
view.addPoolingContainerListener {
if (view.isAttachedToWindow) {
// Enforce detached-from-window constraint
assertThat(view.isAttachedToWindow).isFalse()
}
releases++
}
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
binds++
}
override fun getItemViewType(position: Int): Int {
return position / 10
}
override fun getItemCount(): Int = items
}
private suspend fun RecyclerView.awaitScrollIdle() {
val rv = this
withContext(Dispatchers.Main) {
suspendCancellableCoroutine<Unit> { continuation ->
val listener = object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
continuation.resume(Unit)
}
}
}
rv.addOnScrollListener(listener)
continuation.invokeOnCancellation { rv.removeOnScrollListener(listener) }
if (rv.scrollState == RecyclerView.SCROLL_STATE_IDLE) {
continuation.resume(Unit)
}
}
}
}
private suspend fun RecyclerView.awaitItemAnimationsComplete() {
val rv = this
withContext(Dispatchers.Main) {
suspendCancellableCoroutine<Unit> { continuation ->
val animator = rv.itemAnimator ?: throw IllegalStateException(
"awaitItemAnimationsComplete() was called on a RecyclerView with no ItemAnimator." +
" This may have been unintended."
)
animator.isRunning { continuation.resume(Unit) }
}
}
}