@@ -17,6 +17,7 @@ package com.airbnb.epoxy.paging
17
17
18
18
import android.annotation.SuppressLint
19
19
import android.os.Handler
20
+ import android.os.Looper
20
21
import android.util.Log
21
22
import androidx.paging.AsyncPagedListDiffer
22
23
import androidx.paging.PagedList
@@ -31,6 +32,21 @@ import java.util.concurrent.Executor
31
32
* A PagedList stream wrapper that caches models built for each item. It tracks changes in paged lists and caches
32
33
* models for each item when they are invalidated to avoid rebuilding models for the whole list when PagedList is
33
34
* 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.
34
50
*/
35
51
internal class PagedListModelCache <T >(
36
52
private val modelBuilder : (itemIndex: Int , item: T ? ) -> EpoxyModel <* >,
@@ -41,22 +57,26 @@ internal class PagedListModelCache<T>(
41
57
) {
42
58
/* *
43
59
* 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.
47
60
*/
48
61
private val modelCache = arrayListOf<EpoxyModel <* >? > ()
49
62
/* *
50
63
* Tracks the last accessed position so that we can report it back to the paged list when models are built.
51
64
*/
52
65
private var lastPosition: Int? = null
53
66
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
+
54
73
/* *
55
74
* Observer for the PagedList changes that invalidates the model cache when data is updated.
56
75
*/
57
76
private val updateCallback = object : ListUpdateCallback {
58
77
@Synchronized
59
78
override fun onChanged (position : Int , count : Int , payload : Any? ) {
79
+ assertUpdateCallbacksAllowed()
60
80
(position until (position + count)).forEach {
61
81
modelCache[it] = null
62
82
}
@@ -65,13 +85,15 @@ internal class PagedListModelCache<T>(
65
85
66
86
@Synchronized
67
87
override fun onMoved (fromPosition : Int , toPosition : Int ) {
88
+ assertUpdateCallbacksAllowed()
68
89
val model = modelCache.removeAt(fromPosition)
69
90
modelCache.add(toPosition, model)
70
91
rebuildCallback()
71
92
}
72
93
73
94
@Synchronized
74
95
override fun onInserted (position : Int , count : Int ) {
96
+ assertUpdateCallbacksAllowed()
75
97
(0 until count).forEach {
76
98
modelCache.add(position, null )
77
99
}
@@ -80,13 +102,33 @@ internal class PagedListModelCache<T>(
80
102
81
103
@Synchronized
82
104
override fun onRemoved (position : Int , count : Int ) {
105
+ assertUpdateCallbacksAllowed()
83
106
(0 until count).forEach {
84
107
modelCache.removeAt(position)
85
108
}
86
109
rebuildCallback()
87
110
}
88
111
}
89
112
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
+
90
132
@SuppressLint(" RestrictedApi" )
91
133
private val asyncDiffer = object : AsyncPagedListDiffer <T >(
92
134
updateCallback,
@@ -125,19 +167,40 @@ internal class PagedListModelCache<T>(
125
167
}
126
168
}
127
169
170
+ @Synchronized
128
171
fun submitList (pagedList : PagedList <T >? ) {
172
+ inSubmitList = true
129
173
asyncDiffer.submitList(pagedList)
174
+ inSubmitList = false
130
175
}
131
176
132
177
@Synchronized
133
178
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
+
135
198
(0 until modelCache.size).forEach { position ->
136
199
if (modelCache[position] != null ) {
137
200
return @forEach
138
201
}
139
202
140
- modelBuilder(position, currentList?.get( position) ).also {
203
+ modelBuilder(position, currentList[ position] ).also {
141
204
modelCache[position] = it
142
205
}
143
206
}
@@ -150,7 +213,28 @@ internal class PagedListModelCache<T>(
150
213
}
151
214
152
215
@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
+ */
153
230
fun clearModels () {
231
+ modelBuildingHandler.post {
232
+ clearModelsSynchronized()
233
+ }
234
+ }
235
+
236
+ @Synchronized
237
+ private fun clearModelsSynchronized () {
154
238
modelCache.fill(null )
155
239
}
156
240
0 commit comments