From 0551a5c4c58f12977a6e60de17fb5e19ba171f8d Mon Sep 17 00:00:00 2001 From: Yigit Boyar Date: Fri, 21 Sep 2018 14:18:07 -0700 Subject: [PATCH] Paged list controller (#533) * EpoxyController that works with PagedList This CL introduces a new class in epoxy-paging called CachingPagingEpoxyController. This controller works with a PagedList and caches models for each item. It still allows modifying the final model list via an addModels API. Fixes: 524 Test: PagedListModelCacheTest * More docs and style fixes * more docs & cleanup based on comments * Rename caching paging to PagedListEpoxyController * update sample not to add items randomly, it is confusing --- blessedDeps.gradle | 6 +- epoxy-paging/build.gradle | 17 +- .../java/com/airbnb/epoxy/paging/Item.kt | 31 ++ .../com/airbnb/epoxy/paging/ListDataSource.kt | 65 ++++ .../epoxy/paging/PagedListModelCacheTest.kt | 335 ++++++++++++++++++ .../epoxy/paging/PagedListEpoxyController.kt | 119 +++++++ .../epoxy/paging/PagedListModelCache.kt | 157 ++++++++ .../epoxy/paging/PagingEpoxyController.java | 3 + .../paging/SimplePagingEpoxyController.java | 2 + .../airbnb/epoxy/paging/ExampleUnitTest.java | 17 - epoxy-pagingsample/build.gradle | 13 +- .../epoxy/pagingsample/DataBaseSetup.kt | 6 +- .../pagingsample/PagingSampleActivity.kt | 115 +++--- 13 files changed, 797 insertions(+), 89 deletions(-) create mode 100644 epoxy-paging/src/androidTest/java/com/airbnb/epoxy/paging/Item.kt create mode 100644 epoxy-paging/src/androidTest/java/com/airbnb/epoxy/paging/ListDataSource.kt create mode 100644 epoxy-paging/src/androidTest/java/com/airbnb/epoxy/paging/PagedListModelCacheTest.kt create mode 100644 epoxy-paging/src/main/java/com/airbnb/epoxy/paging/PagedListEpoxyController.kt create mode 100644 epoxy-paging/src/main/java/com/airbnb/epoxy/paging/PagedListModelCache.kt delete mode 100644 epoxy-paging/src/test/java/com/airbnb/epoxy/paging/ExampleUnitTest.java diff --git a/blessedDeps.gradle b/blessedDeps.gradle index 37508631e0..6e5ffa0163 100644 --- a/blessedDeps.gradle +++ b/blessedDeps.gradle @@ -17,7 +17,9 @@ rootProject.ext.MIN_SDK_VERSION_LITHO = 15 rootProject.ext.ANDROID_SUPPORT_LIBS_VERSION = "27.1.0" rootProject.ext.ANDROID_DATA_BINDING = "1.3.1" -rootProject.ext.ANDROID_PAGING = "1.0.0-beta1" +rootProject.ext.ANDROID_PAGING = "1.0.1" +rootProject.ext.ANDROID_ARCH_TESTING = "1.1.1" +rootProject.ext.ANDROID_TEST_RUNNER = "1.0.2" rootProject.ext.BUTTERKNIFE_VERSION = "8.8.1" rootProject.ext.SQUARE_JAVAPOET_VERSION = "1.11.1" rootProject.ext.SQUARE_KOTLINPOET_VERSION = "0.7.0" @@ -44,6 +46,8 @@ rootProject.ext.deps = [ androidDesignLibrary : "com.android.support:design:$ANDROID_SUPPORT_LIBS_VERSION", androidRecyclerView : "com.android.support:recyclerview-v7:$ANDROID_SUPPORT_LIBS_VERSION", androidPagingComponent: "android.arch.paging:runtime:$ANDROID_PAGING", + androidArchCoreTesting: "android.arch.core:core-testing:$ANDROID_ARCH_TESTING", + androidTestRunner : "com.android.support.test:runner:$ANDROID_TEST_RUNNER", androidAnnotations : "com.android.support:support-annotations:$ANDROID_SUPPORT_LIBS_VERSION", dataBindingCompiler : "com.android.databinding:compiler:$ANDROID_PLUGIN_VERSION", dataBindingAdapters : "com.android.databinding:adapters:$ANDROID_DATA_BINDING", diff --git a/epoxy-paging/build.gradle b/epoxy-paging/build.gradle index eb2c32b009..893177b5ee 100644 --- a/epoxy-paging/build.gradle +++ b/epoxy-paging/build.gradle @@ -1,23 +1,26 @@ apply plugin: 'com.android.library' - +apply plugin: 'kotlin-android' android { compileSdkVersion rootProject.COMPILE_SDK_VERSION defaultConfig { minSdkVersion rootProject.MIN_SDK_VERSION targetSdkVersion rootProject.TARGET_SDK_VERSION + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" } } dependencies { + implementation rootProject.deps.kotlin + api rootProject.deps.androidPagingComponent + api project(':epoxy-annotations') + api project(':epoxy-adapter') - compile rootProject.deps.androidPagingComponent - compile project(':epoxy-annotations') - compile project(':epoxy-adapter') + androidTestImplementation rootProject.deps.junit + androidTestImplementation rootProject.deps.androidArchCoreTesting + androidTestImplementation rootProject.deps.androidTestRunner - testCompile rootProject.deps.junit - testCompile rootProject.deps.robolectric - testCompile rootProject.deps.mockito + kaptAndroidTest project(":epoxy-processor") } apply from: rootProject.file('gradle/gradle-maven-push.gradle') \ No newline at end of file diff --git a/epoxy-paging/src/androidTest/java/com/airbnb/epoxy/paging/Item.kt b/epoxy-paging/src/androidTest/java/com/airbnb/epoxy/paging/Item.kt new file mode 100644 index 0000000000..be50dae467 --- /dev/null +++ b/epoxy-paging/src/androidTest/java/com/airbnb/epoxy/paging/Item.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2018 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 com.airbnb.epoxy.paging + +import android.support.v7.util.DiffUtil + +/** + * Dummy item for testing. + */ +data class Item(val id: Int, val value: String) { + companion object { + val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Item, newItem: Item) = oldItem.id == newItem.id + + override fun areContentsTheSame(oldItem: Item, newItem: Item) = oldItem == newItem + } + } +} diff --git a/epoxy-paging/src/androidTest/java/com/airbnb/epoxy/paging/ListDataSource.kt b/epoxy-paging/src/androidTest/java/com/airbnb/epoxy/paging/ListDataSource.kt new file mode 100644 index 0000000000..29be4be4db --- /dev/null +++ b/epoxy-paging/src/androidTest/java/com/airbnb/epoxy/paging/ListDataSource.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2018 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 com.airbnb.epoxy.paging + +import android.arch.paging.PositionalDataSource + +/** + * Simple data source that works with a given list and its loading can be stopped / started. + */ +class ListDataSource( + private val data: List +) : PositionalDataSource() { + private var pendingActions = arrayListOf<() -> Unit>() + private var running = true + + private fun compute(f: () -> Unit) { + if (running) { + f() + } else { + pendingActions.add(f) + } + } + + fun start() { + running = true + val pending = pendingActions + pendingActions = arrayListOf() + pending.forEach(this::compute) + } + + fun stop() { + running = false + } + + override fun loadRange(params: LoadRangeParams, callback: LoadRangeCallback) { + compute { + callback.onResult( + data.subList(params.startPosition, Math.min(data.size, params.startPosition + params.loadSize)) + ) + } + } + + override fun loadInitial(params: LoadInitialParams, callback: LoadInitialCallback) { + val start = computeInitialLoadPosition(params, data.size) + val itemCnt = computeInitialLoadSize(params, start, data.size) + callback.onResult( + data.subList(start, start + itemCnt), + start, + data.size + ) + } +} \ No newline at end of file diff --git a/epoxy-paging/src/androidTest/java/com/airbnb/epoxy/paging/PagedListModelCacheTest.kt b/epoxy-paging/src/androidTest/java/com/airbnb/epoxy/paging/PagedListModelCacheTest.kt new file mode 100644 index 0000000000..9e335f14d0 --- /dev/null +++ b/epoxy-paging/src/androidTest/java/com/airbnb/epoxy/paging/PagedListModelCacheTest.kt @@ -0,0 +1,335 @@ +/* + * Copyright 2018 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 com.airbnb.epoxy.paging + +import android.arch.core.executor.testing.CountingTaskExecutorRule +import android.arch.paging.PagedList +import android.support.test.InstrumentationRegistry +import android.support.test.runner.AndroidJUnit4 +import android.view.View +import com.airbnb.epoxy.EpoxyController +import com.airbnb.epoxy.EpoxyModel +import org.hamcrest.CoreMatchers +import org.hamcrest.CoreMatchers.`is` +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.Executor +import java.util.concurrent.TimeUnit + +@RunWith(AndroidJUnit4::class) +class PagedListModelCacheTest { + @Rule + @JvmField + val archExecutor = CountingTaskExecutorRule() + /** + * Simple mode builder for [Item] + */ + private val modelBuilder: (Int, Item?) -> EpoxyModel<*> = { pos, item -> + if (item == null) { + FakePlaceholderModel(pos) + } else { + FakeModel(item) + } + } + + /** + * Number of times a rebuild is requested + */ + private var rebuildCounter = 0 + private val rebuildCallback: () -> Unit = { + rebuildCounter++ + } + + private val pagedListModelCache = PagedListModelCache( + modelBuilder = modelBuilder, + rebuildCallback = rebuildCallback, + itemDiffCallback = Item.DIFF_CALLBACK, + diffExecutor = Executor { + it.run() + }, + modelBuildingHandler = EpoxyController.defaultModelBuildingHandler + ) + + @Test + fun empty() { + assertThat(pagedListModelCache.getModels(), `is`(emptyList())) + } + + @Test + fun simple() { + val items = createItems(PAGE_SIZE) + val (pagedList, _) = createPagedList(items) + pagedListModelCache.submitList(pagedList) + assertModelItems(items) + assertAndResetRebuildModels() + } + + @Test + fun partialLoad() { + val items = createItems(INITIAL_LOAD_SIZE + 2) + val (pagedList, dataSource) = createPagedList(items) + dataSource.stop() + pagedListModelCache.submitList(pagedList) + assertModelItems(items.subList(0, INITIAL_LOAD_SIZE) + listOf(20, 21)) + assertAndResetRebuildModels() + pagedListModelCache.loadAround(INITIAL_LOAD_SIZE) + assertModelItems(items.subList(0, INITIAL_LOAD_SIZE) + listOf(20, 21)) + assertThat(rebuildCounter, `is`(0)) + dataSource.start() + assertModelItems(items) + assertAndResetRebuildModels() + } + + @Test + fun partialLoad_jumpToPosition() { + val items = createItems(PAGE_SIZE * 10) + val (pagedList, _) = createPagedList(items) + pagedListModelCache.submitList(pagedList) + drain() + assertAndResetRebuildModels() + pagedListModelCache.loadAround(PAGE_SIZE * 8) + drain() + val models = collectModelItems() + assertAndResetRebuildModels() + // We cannot be sure what will be loaded but we can be sure that + // a ) around PAGE_SIZE * 8 will be loaded + // b ) there will be null items in between + assertThat(models[PAGE_SIZE * 8], `is`(items[PAGE_SIZE * 8] as Any)) + assertThat(models[PAGE_SIZE * 5], `is`((PAGE_SIZE * 5) as Any)) + } + + @Test + fun deletion() { + testListUpdate { items, models -> + Modification( + newList = items.copyToMutable().also { + it.removeAt(3) + }, + expectedModels = models.toMutableList().also { + it.removeAt(3) + } + ) + } + } + + @Test + fun deletion_range() { + testListUpdate { items, models -> + Modification( + newList = items.copyToMutable().also { + it.removeAll(items.subList(3, 5)) + }, + expectedModels = models.toMutableList().also { + it.removeAll(models.subList(3, 5)) + } + ) + } + } + + @Test + fun append() { + val newItem = Item(id = 100, value = "newItem") + testListUpdate { items, models -> + Modification( + newList = items.copyToMutable().also { + it.add(newItem) + }, + expectedModels = models.toMutableList().also { + it.add(newItem) + } + ) + } + } + + @Test + fun append_many() { + val newItems = (100 until 105).map { + Item(id = it, value = "newItem $it") + } + testListUpdate { items, models -> + Modification( + newList = items.copyToMutable().also { + it.addAll(newItems) + }, + expectedModels = models.toMutableList().also { + it.addAll(newItems) + } + ) + } + } + + @Test + fun insert() { + testListUpdate { items, models -> + val newItem = Item(id = 100, value = "item x") + Modification( + newList = items.copyToMutable().also { + it.add(5, newItem) + }, + expectedModels = models.toMutableList().also { + it.add(5, newItem) + } + ) + } + } + + @Test + fun insert_many() { + testListUpdate { items, models -> + val newItems = (100 until 105).map { + Item(id = it, value = "newItem $it") + } + Modification( + newList = items.copyToMutable().also { + it.addAll(5, newItems) + }, + expectedModels = models.toMutableList().also { + it.addAll(5, newItems) + } + ) + } + } + + @Test + fun move() { + testListUpdate { items, models -> + Modification( + newList = items.toMutableList().also { + it.add(3, it.removeAt(5)) + }, + expectedModels = models.toMutableList().also { + it.add(3, it.removeAt(5)) + } + ) + } + } + + @Test + fun move_multiple() { + testListUpdate { items, models -> + Modification( + newList = items.toMutableList().also { + it.add(3, it.removeAt(5)) + it.add(1, it.removeAt(8)) + }, + expectedModels = models.toMutableList().also { + it.add(3, it.removeAt(5)) + it.add(1, it.removeAt(8)) + } + ) + } + } + + private fun assertAndResetRebuildModels() { + assertThat(rebuildCounter > 0, CoreMatchers.`is`(true)) + rebuildCounter = 0 + } + + /** + * Helper method to verify multiple list update scenarios + */ + private fun testListUpdate(update: (items: List, models: List) -> Modification) { + val items = createItems(PAGE_SIZE) + val (pagedList, _) = createPagedList(items) + pagedListModelCache.submitList(pagedList) + val (updatedList, expectedModels) = update(items, collectModelItems()) + pagedListModelCache.submitList(createPagedList(updatedList).first) + + val updatedModels = collectModelItems() + assertThat(updatedModels.size, `is`(expectedModels.size)) + updatedModels.forEachIndexed { index, item -> + when (item) { + is Item -> { + assertThat(item, CoreMatchers.sameInstance(expectedModels[index])) + } + else -> { + assertThat(item, `is`(expectedModels[index])) + } + } + } + } + + private fun assertModelItems(expected: List) { + assertThat(collectModelItems(), `is`(expected)) + } + + @Suppress("IMPLICIT_CAST_TO_ANY") + private fun collectModelItems(): List { + drain() + return pagedListModelCache.getModels().map { + when (it) { + is FakeModel -> it.item + is FakePlaceholderModel -> it.pos + else -> null + } + } + } + + private fun drain() { + archExecutor.drainTasks(4, TimeUnit.SECONDS) + InstrumentationRegistry.getInstrumentation().runOnMainSync { } + archExecutor.drainTasks(4, TimeUnit.SECONDS) + InstrumentationRegistry.getInstrumentation().runOnMainSync { } + } + + private fun createItems(cnt: Int): List { + return (0 until cnt).map { + Item(id = it, value = "Item $it") + } + } + + private fun createPagedList(items: List): Pair, ListDataSource> { + val dataSource = ListDataSource(items) + val pagedList = PagedList.Builder( + dataSource, PagedList.Config.Builder() + .setEnablePlaceholders(true) + .setInitialLoadSizeHint(PAGE_SIZE * 2) + .setPageSize(PAGE_SIZE) + .build() + ).setFetchExecutor { it.run() } + .setNotifyExecutor { it.run() } + .build() + return pagedList to dataSource + } + + class FakePlaceholderModel(val pos: Int) : EpoxyModel(-pos.toLong()) { + override fun getDefaultLayout() = throw NotImplementedError("not needed for this test") + + } + + class FakeModel(val item: Item) : EpoxyModel(item.id.toLong()) { + override fun getDefaultLayout() = throw NotImplementedError("not needed for this test") + } + + data class Modification( + val newList: List, + val expectedModels: List + ) + + private fun List.copyToMutable(): MutableList { + return mapTo(arrayListOf()) { + it.copy() + } + } + + companion object { + private const val PAGE_SIZE = 10 + private const val INITIAL_LOAD_SIZE = PAGE_SIZE * 2 + } +} diff --git a/epoxy-paging/src/main/java/com/airbnb/epoxy/paging/PagedListEpoxyController.kt b/epoxy-paging/src/main/java/com/airbnb/epoxy/paging/PagedListEpoxyController.kt new file mode 100644 index 0000000000..13acdb8ea3 --- /dev/null +++ b/epoxy-paging/src/main/java/com/airbnb/epoxy/paging/PagedListEpoxyController.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2018 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 com.airbnb.epoxy.paging + +import android.arch.paging.PagedList +import android.os.Handler +import android.support.v7.util.DiffUtil +import com.airbnb.epoxy.EpoxyController +import com.airbnb.epoxy.EpoxyModel +import com.airbnb.epoxy.EpoxyViewHolder + +/** + * An [EpoxyController] that can work with a [PagedList]. + * + * Internally, it caches the model for each item in the [PagedList]. You should override + * [buildItemModel] method to build the model for the given item. Since [PagedList] might include + * `null` items if placeholders are enabled, this method needs to handle `null` values in the list. + * + * By default, the model for each item is added to the model list. To change this behavior (to + * filter items or inject extra items), you can override [addModels] function and manually add built + * models. + * + * @param T The type of the items in the [PagedList]. + */ +abstract class PagedListEpoxyController( + /** + * The handler to use for build models. + */ + modelBuildingHandler: Handler = EpoxyController.defaultModelBuildingHandler, + /** + * The handler to use when calculating the diff between built model lists + */ + diffingHandler: Handler = EpoxyController.defaultDiffingHandler, + /** + * [PagedListEpoxyController] uses an [DiffUtil.ItemCallback] to detect changes between + * [PagedList]s. By default, it relies on simple object equality but you can provide a custom + * one if you don't use all fields in the object in your models. + */ + itemDiffCallback: DiffUtil.ItemCallback = DEFAULT_ITEM_DIFF_CALLBACK as DiffUtil.ItemCallback +) : EpoxyController(modelBuildingHandler, diffingHandler) { + // this is where we keep the already built models + private val modelCache = PagedListModelCache( + modelBuilder = { pos, item -> + buildItemModel(pos, item) + }, + rebuildCallback = { + requestModelBuild() + }, + itemDiffCallback = itemDiffCallback, + modelBuildingHandler = modelBuildingHandler + ) + + final override fun buildModels() { + addModels(modelCache.getModels()) + } + + /** + * This function adds all built models to the adapter. You can override this method to add extra + * items into the model list or remove some. + */ + open fun addModels(models: List>) { + super.add(models) + } + + /** + * Builds the model for a given item. This must return a single model for each item. If you want + * to inject headers etc, you can override [addModels] function. + * + * If the `item` is `null`, you should provide the placeholder. If your [PagedList] is configured + * without placeholders, you don't need to handle the `null` case. + */ + abstract fun buildItemModel(currentPosition: Int, item: T?): EpoxyModel<*> + + override fun onModelBound( + holder: EpoxyViewHolder, + boundModel: EpoxyModel<*>, + position: Int, + previouslyBoundModel: EpoxyModel<*>? + ) { + // TODO the position may not be a good value if there are too many injected items. + modelCache.loadAround(position) + } + + /** + * Submit a new paged list. + * + * A diff will be calculated between this list and the previous list so you may still get calls + * to [buildItemModel] with items from the previous list. + */ + fun submitList(newList: PagedList?) { + modelCache.submitList(newList) + } + + companion object { + /** + * [PagedListEpoxyController] calculates a diff on top of the PagedList to check which + * models are invalidated. + * This is the default [DiffUtil.ItemCallback] which uses object equality. + */ + val DEFAULT_ITEM_DIFF_CALLBACK = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Any?, newItem: Any?) = oldItem == newItem + + override fun areContentsTheSame(oldItem: Any?, newItem: Any?) = oldItem == newItem + } + } +} \ No newline at end of file diff --git a/epoxy-paging/src/main/java/com/airbnb/epoxy/paging/PagedListModelCache.kt b/epoxy-paging/src/main/java/com/airbnb/epoxy/paging/PagedListModelCache.kt new file mode 100644 index 0000000000..5308b3532c --- /dev/null +++ b/epoxy-paging/src/main/java/com/airbnb/epoxy/paging/PagedListModelCache.kt @@ -0,0 +1,157 @@ +/* + * Copyright 2018 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 com.airbnb.epoxy.paging + +import android.annotation.SuppressLint +import android.arch.paging.AsyncPagedListDiffer +import android.arch.paging.PagedList +import android.os.Handler +import android.support.v7.recyclerview.extensions.AsyncDifferConfig +import android.support.v7.util.DiffUtil +import android.support.v7.util.ListUpdateCallback +import android.util.Log +import com.airbnb.epoxy.EpoxyController +import com.airbnb.epoxy.EpoxyModel +import java.lang.IllegalStateException +import java.util.concurrent.Executor + +/** + * A PagedList stream wrapper that caches models built for each item. It tracks changes in paged lists and caches + * models for each item when they are invalidated to avoid rebuilding models for the whole list when PagedList is + * updated. + */ +internal class PagedListModelCache( + private val modelBuilder: (itemIndex: Int, item: T?) -> EpoxyModel<*>, + private val rebuildCallback: () -> Unit, + private val itemDiffCallback : DiffUtil.ItemCallback, + private val diffExecutor : Executor? = null, + private val modelBuildingHandler : Handler +) { + /** + * Backing list for built models. This is a full array list that has null items for not yet build models. + */ + private val modelCache = arrayListOf?>() + /** + * Tracks the last accessed position so that we can report it back to the paged list when models are built. + */ + private var lastPosition: Int? = null + + /** + * Observer for the PagedList changes that invalidates the model cache when data is updated. + */ + private val updateCallback = object : ListUpdateCallback { + override fun onChanged(position: Int, count: Int, payload: Any?) { + (position until (position + count)).forEach { + modelCache[it] = null + } + rebuildCallback() + } + + override fun onMoved(fromPosition: Int, toPosition: Int) { + val model = modelCache.removeAt(fromPosition) + modelCache.add(toPosition, model) + rebuildCallback() + } + + override fun onInserted(position: Int, count: Int) { + (0 until count).forEach { _ -> + modelCache.add(position, null) + } + rebuildCallback() + } + + override fun onRemoved(position: Int, count: Int) { + (0 until count).forEach { _ -> + modelCache.removeAt(position) + } + rebuildCallback() + } + } + + private val asyncDiffer = @SuppressLint("RestrictedApi") + object : AsyncPagedListDiffer( + updateCallback, + AsyncDifferConfig.Builder( + itemDiffCallback + ).also {builder -> + if (diffExecutor != null) { + builder.setBackgroundThreadExecutor(diffExecutor) + } + // we have to reply on this private API, otherwise, paged list might be changed when models are being built, + // potentially creating concurrent modification problems. + builder.setMainThreadExecutor {runnable : Runnable -> + modelBuildingHandler.post(runnable) + } + }.build() + ){ + init { + if (modelBuildingHandler != EpoxyController.defaultModelBuildingHandler) { + try { + // looks like AsyncPagedListDiffer in 1.x ignores the config. + // Reflection to the rescue. + val mainThreadExecutorField = + AsyncPagedListDiffer::class.java.getDeclaredField("mMainThreadExecutor") + mainThreadExecutorField.isAccessible = true + mainThreadExecutorField.set(this, Executor { + modelBuildingHandler.post(it) + }) + } catch (t : Throwable) { + val msg = "Failed to hijack update handler in AsyncPagedListDiffer." + + "You can only build models on the main thread" + Log.e("PagedListModelCache", msg, t) + throw IllegalStateException(msg, t) + } + } + } + } + + fun submitList(pagedList: PagedList?) { + asyncDiffer.submitList(pagedList) + } + + private fun getOrBuildModel(pos: Int): EpoxyModel<*> { + modelCache[pos]?.let { + return it + } + return modelBuilder(pos, asyncDiffer.currentList?.get(pos)).also { + modelCache[pos] = it + } + } + + fun getModels(): List> { + (0 until modelCache.size).forEach { + getOrBuildModel(it) + } + lastPosition?.let { + triggerLoadAround(it) + } + @Suppress("UNCHECKED_CAST") + return modelCache as List> + } + + fun loadAround(position: Int) { + triggerLoadAround(position) + lastPosition = position + } + + private fun triggerLoadAround(position: Int) { + asyncDiffer.currentList?.let { + if (it.size > 0) { + it.loadAround(Math.min(position, it.size - 1)) + } + } + } +} \ No newline at end of file diff --git a/epoxy-paging/src/main/java/com/airbnb/epoxy/paging/PagingEpoxyController.java b/epoxy-paging/src/main/java/com/airbnb/epoxy/paging/PagingEpoxyController.java index 48eb50110d..6847becee7 100644 --- a/epoxy-paging/src/main/java/com/airbnb/epoxy/paging/PagingEpoxyController.java +++ b/epoxy-paging/src/main/java/com/airbnb/epoxy/paging/PagingEpoxyController.java @@ -33,7 +33,10 @@ * for them. * * @param The type of item in the list + * + * @deprecated Use {@link PagedListEpoxyController} instead. */ +@Deprecated() public abstract class PagingEpoxyController extends EpoxyController { private static final Config DEFAULT_CONFIG = new Config.Builder() diff --git a/epoxy-paging/src/main/java/com/airbnb/epoxy/paging/SimplePagingEpoxyController.java b/epoxy-paging/src/main/java/com/airbnb/epoxy/paging/SimplePagingEpoxyController.java index 8f104c4c30..3ad0bff38e 100644 --- a/epoxy-paging/src/main/java/com/airbnb/epoxy/paging/SimplePagingEpoxyController.java +++ b/epoxy-paging/src/main/java/com/airbnb/epoxy/paging/SimplePagingEpoxyController.java @@ -10,6 +10,8 @@ * {@link #buildModel(Object)} * * @param The list type + * + * @deprecated Use {@link PagedListEpoxyController} instead. */ public abstract class SimplePagingEpoxyController extends PagingEpoxyController { @Override diff --git a/epoxy-paging/src/test/java/com/airbnb/epoxy/paging/ExampleUnitTest.java b/epoxy-paging/src/test/java/com/airbnb/epoxy/paging/ExampleUnitTest.java deleted file mode 100644 index a238417a03..0000000000 --- a/epoxy-paging/src/test/java/com/airbnb/epoxy/paging/ExampleUnitTest.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.airbnb.epoxy.paging; - -import org.junit.Test; - -import static org.junit.Assert.*; - -/** - * Example local unit test, which will execute on the development machine (host). - * - * @see Testing documentation - */ -public class ExampleUnitTest { - @Test - public void addition_isCorrect() throws Exception { - assertEquals(4, 2 + 2); - } -} \ No newline at end of file diff --git a/epoxy-pagingsample/build.gradle b/epoxy-pagingsample/build.gradle index eae4474754..e6711ed91a 100644 --- a/epoxy-pagingsample/build.gradle +++ b/epoxy-pagingsample/build.gradle @@ -20,14 +20,15 @@ android { dependencies { - compile rootProject.deps.kotlin + implementation rootProject.deps.kotlin - compile "org.jetbrains.anko:anko-coroutines:0.10.4" - compile 'android.arch.persistence.room:runtime:1.0.0' - kapt 'android.arch.persistence.room:compiler:1.0.0' + implementation "org.jetbrains.anko:anko-coroutines:0.10.4" + implementation 'android.arch.persistence.room:runtime:1.1.1' + implementation "android.arch.lifecycle:extensions:1.1.1" + kapt 'android.arch.persistence.room:compiler:1.1.1' - compile project(':epoxy-adapter') - compile project(':epoxy-paging') + implementation project(':epoxy-adapter') + implementation project(':epoxy-paging') kapt project(':epoxy-processor') implementation 'com.android.support:appcompat-v7:27.1.0' diff --git a/epoxy-pagingsample/src/main/java/com/airbnb/epoxy/pagingsample/DataBaseSetup.kt b/epoxy-pagingsample/src/main/java/com/airbnb/epoxy/pagingsample/DataBaseSetup.kt index 4779f2a18d..1cd605b570 100644 --- a/epoxy-pagingsample/src/main/java/com/airbnb/epoxy/pagingsample/DataBaseSetup.kt +++ b/epoxy-pagingsample/src/main/java/com/airbnb/epoxy/pagingsample/DataBaseSetup.kt @@ -14,10 +14,10 @@ data class User( var uid: Int, @ColumnInfo(name = "first_name") - var firstName: String = "first name", + var firstName: String = "first name $uid", @ColumnInfo(name = "last_name") - var lastName: String = "last name" + var lastName: String = "last name $uid" ) @Dao @@ -29,7 +29,7 @@ interface UserDao { val all: List @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insertAll(vararg users: User) + fun insertAll(users: List) @Delete fun delete(users: List) diff --git a/epoxy-pagingsample/src/main/java/com/airbnb/epoxy/pagingsample/PagingSampleActivity.kt b/epoxy-pagingsample/src/main/java/com/airbnb/epoxy/pagingsample/PagingSampleActivity.kt index 6d0aa84cde..d41ef3156c 100644 --- a/epoxy-pagingsample/src/main/java/com/airbnb/epoxy/pagingsample/PagingSampleActivity.kt +++ b/epoxy-pagingsample/src/main/java/com/airbnb/epoxy/pagingsample/PagingSampleActivity.kt @@ -1,86 +1,72 @@ package com.airbnb.epoxy.pagingsample +import android.app.Application +import android.arch.lifecycle.AndroidViewModel +import android.arch.lifecycle.LiveData +import android.arch.lifecycle.Observer +import android.arch.lifecycle.ViewModelProviders +import android.arch.paging.LivePagedListBuilder import android.arch.paging.PagedList import android.arch.persistence.room.Room import android.content.Context -import android.os.AsyncTask import android.os.Bundle -import android.os.Handler -import android.os.Looper import android.support.v7.app.AppCompatActivity import android.support.v7.widget.AppCompatTextView import android.support.v7.widget.LinearLayoutManager import android.support.v7.widget.RecyclerView +import com.airbnb.epoxy.EpoxyAsyncUtil +import com.airbnb.epoxy.EpoxyModel import com.airbnb.epoxy.ModelView import com.airbnb.epoxy.TextProp -import com.airbnb.epoxy.paging.PagingEpoxyController -import kotlinx.coroutines.experimental.android.UI -import kotlinx.coroutines.experimental.async +import com.airbnb.epoxy.paging.PagedListEpoxyController +import kotlinx.coroutines.experimental.CommonPool +import kotlinx.coroutines.experimental.delay +import kotlinx.coroutines.experimental.launch import org.jetbrains.anko.coroutines.experimental.bg import java.lang.RuntimeException -import java.util.concurrent.Executor +import java.util.concurrent.TimeUnit class PagingSampleActivity : AppCompatActivity() { - - lateinit var db: PagingDatabase - public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) - db = Room.databaseBuilder(applicationContext, - PagingDatabase::class.java, - "database-name").build() - val pagingController = TestController() val recyclerView = findViewById(R.id.recycler_view) recyclerView.layoutManager = LinearLayoutManager(this) recyclerView.adapter = pagingController.adapter - async(UI) { - val pagedList = bg { - db.userDao().delete(db.userDao().all) - (1..3000) - .map { User(it) } - .let { db.userDao().insertAll(*it.toTypedArray()) } - - PagedList.Builder( - db.userDao().dataSource.create(), - PagedList.Config.Builder().run { - setEnablePlaceholders(false) - setPageSize(150) - setPrefetchDistance(30) - build() - }).run { - setNotifyExecutor(UiThreadExecutor) - setFetchExecutor(AsyncTask.THREAD_POOL_EXECUTOR) - build() - } - } - - pagingController.setList(pagedList.await()) - } - - pagingController.setList(emptyList()) + val viewModel = ViewModelProviders.of(this).get(ActivityViewModel::class.java) + viewModel.pagedList.observe(this, Observer { + pagingController.submitList(it) + }) } } -class TestController : PagingEpoxyController() { - init { - isDebugLoggingEnabled = true +class TestController : PagedListEpoxyController( + modelBuildingHandler = EpoxyAsyncUtil.getAsyncBackgroundHandler() +) { + override fun buildItemModel(currentPosition: Int, item: User?): EpoxyModel<*> { + return if (item == null) { + PagingViewModel_() + .id(-currentPosition) + .name("loading ${currentPosition}") + } else { + PagingViewModel_() + .id(item.uid) + .name("${item.uid}: ${item.firstName} / ${item.lastName}") + } } - override fun buildModels(users: List) { + override fun addModels(models: List>) { pagingView { id("header") - name("Header") + name("showing ${models.size} items") } + super.addModels(models) + } - users.forEach { - pagingView { - id(it.uid) - name("Id: ${it.uid}") - } - } + init { + isDebugLoggingEnabled = true } override fun onExceptionSwallowed(exception: RuntimeException) { @@ -99,10 +85,29 @@ class PagingView(context: Context) : AppCompatTextView(context) { } -object UiThreadExecutor : Executor { - private val handler = Handler(Looper.getMainLooper()) - - override fun execute(command: Runnable) { - handler.post(command) +class ActivityViewModel(app : Application) : AndroidViewModel(app) { + val db by lazy { + Room.inMemoryDatabaseBuilder(app, PagingDatabase::class.java).build() + } + val pagedList : LiveData> by lazy { + LivePagedListBuilder( + db.userDao().dataSource, 100 + ).build() + } + init { + bg { + (1..3000).map { + User(it) + }.let { + it.groupBy { + it.uid / 200 + }.forEach { group -> + launch(CommonPool) { + delay(group.key.toLong(), TimeUnit.SECONDS) + db.userDao().insertAll(group.value) + } + } + } + } } }