Skip to content

Commit d7a9dcb

Browse files
committed
Fix threading isues with PagedList controller
1 parent 80bd181 commit d7a9dcb

File tree

1 file changed

+89
-5
lines changed

1 file changed

+89
-5
lines changed

epoxy-paging/src/main/java/com/airbnb/epoxy/paging/PagedListModelCache.kt

+89-5
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package com.airbnb.epoxy.paging
1717

1818
import android.annotation.SuppressLint
1919
import android.os.Handler
20+
import android.os.Looper
2021
import android.util.Log
2122
import androidx.paging.AsyncPagedListDiffer
2223
import androidx.paging.PagedList
@@ -31,6 +32,21 @@ import java.util.concurrent.Executor
3132
* A PagedList stream wrapper that caches models built for each item. It tracks changes in paged lists and caches
3233
* models for each item when they are invalidated to avoid rebuilding models for the whole list when PagedList is
3334
* updated.
35+
*
36+
* The PagedList submitted to this cache must be kept in sync with the model cache. To do this,
37+
* the executor of the PagedList differ is set to the same thread as the model building handler.
38+
* However, change notifications from the PageList happen on that list's notify executor which is
39+
* out of our control, and we require the user to configure that properly, or an error is thrown.
40+
*
41+
* There are two special cases:
42+
*
43+
* 1. The first time models are built happens synchronously for immediate UI. In this case we don't
44+
* use the model cache (to avoid data synchronization issues), but attempt to fill the cache with
45+
* the models later.
46+
*
47+
* 2. When a list is submitted it can trigger update callbacks synchronously. Since we don't control
48+
* that thread we allow a special case of cache modification when a new list is being submitted,
49+
* and all cache access is marked with @Synchronize to ensure safety when this happens.
3450
*/
3551
internal class PagedListModelCache<T>(
3652
private val modelBuilder: (itemIndex: Int, item: T?) -> EpoxyModel<*>,
@@ -41,22 +57,26 @@ internal class PagedListModelCache<T>(
4157
) {
4258
/**
4359
* Backing list for built models. This is a full array list that has null items for not yet build models.
44-
*
45-
* All interactions with this should by synchronized, since it is accessed from several threads
46-
* for model building, paged list updates, and cache clearing.
4760
*/
4861
private val modelCache = arrayListOf<EpoxyModel<*>?>()
4962
/**
5063
* Tracks the last accessed position so that we can report it back to the paged list when models are built.
5164
*/
5265
private var lastPosition: Int? = null
5366

67+
/**
68+
* Set to true while a new list is being submitted, so that we can ignore the update callback
69+
* thread restriction.
70+
*/
71+
private var inSubmitList: Boolean = false
72+
5473
/**
5574
* Observer for the PagedList changes that invalidates the model cache when data is updated.
5675
*/
5776
private val updateCallback = object : ListUpdateCallback {
5877
@Synchronized
5978
override fun onChanged(position: Int, count: Int, payload: Any?) {
79+
assertUpdateCallbacksAllowed()
6080
(position until (position + count)).forEach {
6181
modelCache[it] = null
6282
}
@@ -65,13 +85,15 @@ internal class PagedListModelCache<T>(
6585

6686
@Synchronized
6787
override fun onMoved(fromPosition: Int, toPosition: Int) {
88+
assertUpdateCallbacksAllowed()
6889
val model = modelCache.removeAt(fromPosition)
6990
modelCache.add(toPosition, model)
7091
rebuildCallback()
7192
}
7293

7394
@Synchronized
7495
override fun onInserted(position: Int, count: Int) {
96+
assertUpdateCallbacksAllowed()
7597
(0 until count).forEach {
7698
modelCache.add(position, null)
7799
}
@@ -80,13 +102,33 @@ internal class PagedListModelCache<T>(
80102

81103
@Synchronized
82104
override fun onRemoved(position: Int, count: Int) {
105+
assertUpdateCallbacksAllowed()
83106
(0 until count).forEach {
84107
modelCache.removeAt(position)
85108
}
86109
rebuildCallback()
87110
}
88111
}
89112

113+
/**
114+
* Changes to the paged list must happen on the same thread as changes to the model cache to
115+
* ensure they stay in sync.
116+
*
117+
* We can't force this to happen, and must instead rely on user's configuration, but we can alert
118+
* when it is not configured correctly.
119+
*
120+
* An exception is if the callback happens due to a new paged list being submitted, which can
121+
* trigger a synchronous callback if the list goes from null to non null, or vice versa.
122+
*
123+
* Synchronization on [submitList] and other model cache access methods prevent issues when
124+
* that happens.
125+
*/
126+
private fun assertUpdateCallbacksAllowed() {
127+
require(inSubmitList || Looper.myLooper() == modelBuildingHandler.looper) {
128+
"The notify executor for your PagedList must use the same thread as the model building handler set in PagedListEpoxyController.modelBuildingHandler"
129+
}
130+
}
131+
90132
@SuppressLint("RestrictedApi")
91133
private val asyncDiffer = object : AsyncPagedListDiffer<T>(
92134
updateCallback,
@@ -125,19 +167,40 @@ internal class PagedListModelCache<T>(
125167
}
126168
}
127169

170+
@Synchronized
128171
fun submitList(pagedList: PagedList<T>?) {
172+
inSubmitList = true
129173
asyncDiffer.submitList(pagedList)
174+
inSubmitList = false
130175
}
131176

132177
@Synchronized
133178
fun getModels(): List<EpoxyModel<*>> {
134-
val currentList = asyncDiffer.currentList
179+
val currentList = asyncDiffer.currentList ?: emptyList<T>()
180+
181+
// The first time models are built the EpoxyController does so synchronously, so that
182+
// the UI can be ready immediately. To avoid concurrent modification issues with the PagedList
183+
// and model cache we can't allow that first build to touch the cache.
184+
if (Looper.myLooper() != modelBuildingHandler.looper) {
185+
val initialModels = currentList.mapIndexed { position, item ->
186+
modelBuilder(position, item)
187+
}
188+
189+
// If the paged list still hasn't changed then we can populate the cache
190+
// with the models we built to avoid needing to rebuild them later.
191+
modelBuildingHandler.post {
192+
setCacheValues(currentList, initialModels)
193+
}
194+
195+
return initialModels
196+
}
197+
135198
(0 until modelCache.size).forEach { position ->
136199
if (modelCache[position] != null) {
137200
return@forEach
138201
}
139202

140-
modelBuilder(position, currentList?.get(position)).also {
203+
modelBuilder(position, currentList[position]).also {
141204
modelCache[position] = it
142205
}
143206
}
@@ -150,7 +213,28 @@ internal class PagedListModelCache<T>(
150213
}
151214

152215
@Synchronized
216+
private fun setCacheValues(
217+
originatingList: List<T>,
218+
initialModels: List<EpoxyModel<*>>
219+
) {
220+
if (asyncDiffer.currentList === originatingList) {
221+
modelCache.clear()
222+
modelCache.addAll(initialModels)
223+
}
224+
}
225+
226+
/**
227+
* Clears all cached models to force them to be rebuilt next time models are retrieved.
228+
* This is posted to the model building thread to maintain data synchronicity.
229+
*/
153230
fun clearModels() {
231+
modelBuildingHandler.post {
232+
clearModelsSynchronized()
233+
}
234+
}
235+
236+
@Synchronized
237+
private fun clearModelsSynchronized() {
154238
modelCache.fill(null)
155239
}
156240

0 commit comments

Comments
 (0)