• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright 2018 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.recyclerview.widget;
18 
19 import android.util.Log;
20 import android.util.SparseBooleanArray;
21 import android.util.SparseIntArray;
22 
23 import androidx.annotation.NonNull;
24 import androidx.annotation.Nullable;
25 import androidx.annotation.UiThread;
26 import androidx.annotation.WorkerThread;
27 
28 /**
29  * A utility class that supports asynchronous content loading.
30  * <p>
31  * It can be used to load Cursor data in chunks without querying the Cursor on the UI Thread while
32  * keeping UI and cache synchronous for better user experience.
33  * <p>
34  * It loads the data on a background thread and keeps only a limited number of fixed sized
35  * chunks in memory at all times.
36  * <p>
37  * {@link AsyncListUtil} queries the currently visible range through {@link ViewCallback},
38  * loads the required data items in the background through {@link DataCallback}, and notifies a
39  * {@link ViewCallback} when the data is loaded. It may load some extra items for smoother
40  * scrolling.
41  * <p>
42  * Note that this class uses a single thread to load the data, so it suitable to load data from
43  * secondary storage such as disk, but not from network.
44  * <p>
45  * This class is designed to work with {@link RecyclerView}, but it does
46  * not depend on it and can be used with other list views.
47  *
48  */
49 public class AsyncListUtil<T> {
50     static final String TAG = "AsyncListUtil";
51 
52     static final boolean DEBUG = false;
53 
54     final Class<T> mTClass;
55     final int mTileSize;
56     final DataCallback<T> mDataCallback;
57     final ViewCallback mViewCallback;
58 
59     final TileList<T> mTileList;
60 
61     final ThreadUtil.MainThreadCallback<T> mMainThreadProxy;
62     final ThreadUtil.BackgroundCallback<T> mBackgroundProxy;
63 
64     final int[] mTmpRange = new int[2];
65     final int[] mPrevRange = new int[2];
66     final int[] mTmpRangeExtended = new int[2];
67 
68     boolean mAllowScrollHints;
69     private int mScrollHint = ViewCallback.HINT_SCROLL_NONE;
70 
71     int mItemCount = 0;
72 
73     int mDisplayedGeneration = 0;
74     int mRequestedGeneration = mDisplayedGeneration;
75 
76     final SparseIntArray mMissingPositions = new SparseIntArray();
77 
log(String s, Object... args)78     void log(String s, Object... args) {
79         Log.d(TAG, "[MAIN] " + String.format(s, args));
80     }
81 
82     /**
83      * Creates an AsyncListUtil.
84      *
85      * @param klass Class of the data item.
86      * @param tileSize Number of item per chunk loaded at once.
87      * @param dataCallback Data access callback.
88      * @param viewCallback Callback for querying visible item range and update notifications.
89      */
AsyncListUtil(@onNull Class<T> klass, int tileSize, @NonNull DataCallback<T> dataCallback, @NonNull ViewCallback viewCallback)90     public AsyncListUtil(@NonNull Class<T> klass, int tileSize,
91             @NonNull DataCallback<T> dataCallback, @NonNull ViewCallback viewCallback) {
92         mTClass = klass;
93         mTileSize = tileSize;
94         mDataCallback = dataCallback;
95         mViewCallback = viewCallback;
96 
97         mTileList = new TileList<T>(mTileSize);
98 
99         ThreadUtil<T> threadUtil = new MessageThreadUtil<T>();
100         mMainThreadProxy = threadUtil.getMainThreadProxy(mMainThreadCallback);
101         mBackgroundProxy = threadUtil.getBackgroundProxy(mBackgroundCallback);
102 
103         refresh();
104     }
105 
isRefreshPending()106     private boolean isRefreshPending() {
107         return mRequestedGeneration != mDisplayedGeneration;
108     }
109 
110     /**
111      * Updates the currently visible item range.
112      *
113      * <p>
114      * Identifies the data items that have not been loaded yet and initiates loading them in the
115      * background. Should be called from the view's scroll listener (such as
116      * {@link RecyclerView.OnScrollListener#onScrolled}).
117      */
onRangeChanged()118     public void onRangeChanged() {
119         if (isRefreshPending()) {
120             return;  // Will update range will the refresh result arrives.
121         }
122         updateRange();
123         mAllowScrollHints = true;
124     }
125 
126     /**
127      * Forces reloading the data.
128      * <p>
129      * Discards all the cached data and reloads all required data items for the currently visible
130      * range. To be called when the data item count and/or contents has changed.
131      */
refresh()132     public void refresh() {
133         mMissingPositions.clear();
134         mBackgroundProxy.refresh(++mRequestedGeneration);
135     }
136 
137     /**
138      * Returns the data item at the given position or <code>null</code> if it has not been loaded
139      * yet.
140      *
141      * <p>
142      * If this method has been called for a specific position and returned <code>null</code>, then
143      * {@link ViewCallback#onItemLoaded(int)} will be called when it finally loads. Note that if
144      * this position stays outside of the cached item range (as defined by
145      * {@link ViewCallback#extendRangeInto} method), then the callback will never be called for
146      * this position.
147      *
148      * @param position Item position.
149      *
150      * @return The data item at the given position or <code>null</code> if it has not been loaded
151      *         yet.
152      */
153     @Nullable
getItem(int position)154     public T getItem(int position) {
155         if (position < 0 || position >= mItemCount) {
156             throw new IndexOutOfBoundsException(position + " is not within 0 and " + mItemCount);
157         }
158         T item = mTileList.getItemAt(position);
159         if (item == null && !isRefreshPending()) {
160             mMissingPositions.put(position, 0);
161         }
162         return item;
163     }
164 
165     /**
166      * Returns the number of items in the data set.
167      *
168      * <p>
169      * This is the number returned by a recent call to
170      * {@link DataCallback#refreshData()}.
171      *
172      * @return Number of items.
173      */
getItemCount()174     public int getItemCount() {
175         return mItemCount;
176     }
177 
updateRange()178     void updateRange() {
179         mViewCallback.getItemRangeInto(mTmpRange);
180         if (mTmpRange[0] > mTmpRange[1] || mTmpRange[0] < 0) {
181             return;
182         }
183         if (mTmpRange[1] >= mItemCount) {
184             // Invalid range may arrive soon after the refresh.
185             return;
186         }
187 
188         if (!mAllowScrollHints) {
189             mScrollHint = ViewCallback.HINT_SCROLL_NONE;
190         } else if (mTmpRange[0] > mPrevRange[1] || mPrevRange[0] > mTmpRange[1]) {
191             // Ranges do not intersect, long leap not a scroll.
192             mScrollHint = ViewCallback.HINT_SCROLL_NONE;
193         } else if (mTmpRange[0] < mPrevRange[0]) {
194             mScrollHint = ViewCallback.HINT_SCROLL_DESC;
195         } else if (mTmpRange[0] > mPrevRange[0]) {
196             mScrollHint = ViewCallback.HINT_SCROLL_ASC;
197         }
198 
199         mPrevRange[0] = mTmpRange[0];
200         mPrevRange[1] = mTmpRange[1];
201 
202         mViewCallback.extendRangeInto(mTmpRange, mTmpRangeExtended, mScrollHint);
203         mTmpRangeExtended[0] = Math.min(mTmpRange[0], Math.max(mTmpRangeExtended[0], 0));
204         mTmpRangeExtended[1] =
205                 Math.max(mTmpRange[1], Math.min(mTmpRangeExtended[1], mItemCount - 1));
206 
207         mBackgroundProxy.updateRange(mTmpRange[0], mTmpRange[1],
208                 mTmpRangeExtended[0], mTmpRangeExtended[1], mScrollHint);
209     }
210 
211     private final ThreadUtil.MainThreadCallback<T>
212             mMainThreadCallback = new ThreadUtil.MainThreadCallback<T>() {
213         @Override
214         public void updateItemCount(int generation, int itemCount) {
215             if (DEBUG) {
216                 log("updateItemCount: size=%d, gen #%d", itemCount, generation);
217             }
218             if (!isRequestedGeneration(generation)) {
219                 return;
220             }
221             mItemCount = itemCount;
222             mViewCallback.onDataRefresh();
223             mDisplayedGeneration = mRequestedGeneration;
224             recycleAllTiles();
225 
226             mAllowScrollHints = false;  // Will be set to true after a first real scroll.
227             // There will be no scroll event if the size change does not affect the current range.
228             updateRange();
229         }
230 
231         @Override
232         public void addTile(int generation, TileList.Tile<T> tile) {
233             if (!isRequestedGeneration(generation)) {
234                 if (DEBUG) {
235                     log("recycling an older generation tile @%d", tile.mStartPosition);
236                 }
237                 mBackgroundProxy.recycleTile(tile);
238                 return;
239             }
240             TileList.Tile<T> duplicate = mTileList.addOrReplace(tile);
241             if (duplicate != null) {
242                 Log.e(TAG, "duplicate tile @" + duplicate.mStartPosition);
243                 mBackgroundProxy.recycleTile(duplicate);
244             }
245             if (DEBUG) {
246                 log("gen #%d, added tile @%d, total tiles: %d",
247                         generation, tile.mStartPosition, mTileList.size());
248             }
249             int endPosition = tile.mStartPosition + tile.mItemCount;
250             int index = 0;
251             while (index < mMissingPositions.size()) {
252                 final int position = mMissingPositions.keyAt(index);
253                 if (tile.mStartPosition <= position && position < endPosition) {
254                     mMissingPositions.removeAt(index);
255                     mViewCallback.onItemLoaded(position);
256                 } else {
257                     index++;
258                 }
259             }
260         }
261 
262         @Override
263         public void removeTile(int generation, int position) {
264             if (!isRequestedGeneration(generation)) {
265                 return;
266             }
267             TileList.Tile<T> tile = mTileList.removeAtPos(position);
268             if (tile == null) {
269                 Log.e(TAG, "tile not found @" + position);
270                 return;
271             }
272             if (DEBUG) {
273                 log("recycling tile @%d, total tiles: %d", tile.mStartPosition, mTileList.size());
274             }
275             mBackgroundProxy.recycleTile(tile);
276         }
277 
278         private void recycleAllTiles() {
279             if (DEBUG) {
280                 log("recycling all %d tiles", mTileList.size());
281             }
282             for (int i = 0; i < mTileList.size(); i++) {
283                 mBackgroundProxy.recycleTile(mTileList.getAtIndex(i));
284             }
285             mTileList.clear();
286         }
287 
288         private boolean isRequestedGeneration(int generation) {
289             return generation == mRequestedGeneration;
290         }
291     };
292 
293     private final ThreadUtil.BackgroundCallback<T>
294             mBackgroundCallback = new ThreadUtil.BackgroundCallback<T>() {
295 
296         private TileList.Tile<T> mRecycledRoot;
297 
298         final SparseBooleanArray mLoadedTiles = new SparseBooleanArray();
299 
300         private int mGeneration;
301         private int mItemCount;
302 
303         private int mFirstRequiredTileStart;
304         private int mLastRequiredTileStart;
305 
306         @Override
307         public void refresh(int generation) {
308             mGeneration = generation;
309             mLoadedTiles.clear();
310             mItemCount = mDataCallback.refreshData();
311             mMainThreadProxy.updateItemCount(mGeneration, mItemCount);
312         }
313 
314         @Override
315         public void updateRange(int rangeStart, int rangeEnd, int extRangeStart, int extRangeEnd,
316                 int scrollHint) {
317             if (DEBUG) {
318                 log("updateRange: %d..%d extended to %d..%d, scroll hint: %d",
319                         rangeStart, rangeEnd, extRangeStart, extRangeEnd, scrollHint);
320             }
321 
322             if (rangeStart > rangeEnd) {
323                 return;
324             }
325 
326             final int firstVisibleTileStart = getTileStart(rangeStart);
327             final int lastVisibleTileStart = getTileStart(rangeEnd);
328 
329             mFirstRequiredTileStart = getTileStart(extRangeStart);
330             mLastRequiredTileStart = getTileStart(extRangeEnd);
331             if (DEBUG) {
332                 log("requesting tile range: %d..%d",
333                         mFirstRequiredTileStart, mLastRequiredTileStart);
334             }
335 
336             // All pending tile requests are removed by ThreadUtil at this point.
337             // Re-request all required tiles in the most optimal order.
338             if (scrollHint == ViewCallback.HINT_SCROLL_DESC) {
339                 requestTiles(mFirstRequiredTileStart, lastVisibleTileStart, scrollHint, true);
340                 requestTiles(lastVisibleTileStart + mTileSize, mLastRequiredTileStart, scrollHint,
341                         false);
342             } else {
343                 requestTiles(firstVisibleTileStart, mLastRequiredTileStart, scrollHint, false);
344                 requestTiles(mFirstRequiredTileStart, firstVisibleTileStart - mTileSize, scrollHint,
345                         true);
346             }
347         }
348 
349         private int getTileStart(int position) {
350             return position - position % mTileSize;
351         }
352 
353         private void requestTiles(int firstTileStart, int lastTileStart, int scrollHint,
354                                   boolean backwards) {
355             for (int i = firstTileStart; i <= lastTileStart; i += mTileSize) {
356                 int tileStart = backwards ? (lastTileStart + firstTileStart - i) : i;
357                 if (DEBUG) {
358                     log("requesting tile @%d", tileStart);
359                 }
360                 mBackgroundProxy.loadTile(tileStart, scrollHint);
361             }
362         }
363 
364         @Override
365         public void loadTile(int position, int scrollHint) {
366             if (isTileLoaded(position)) {
367                 if (DEBUG) {
368                     log("already loaded tile @%d", position);
369                 }
370                 return;
371             }
372             TileList.Tile<T> tile = acquireTile();
373             tile.mStartPosition = position;
374             tile.mItemCount = Math.min(mTileSize, mItemCount - tile.mStartPosition);
375             mDataCallback.fillData(tile.mItems, tile.mStartPosition, tile.mItemCount);
376             flushTileCache(scrollHint);
377             addTile(tile);
378         }
379 
380         @Override
381         public void recycleTile(TileList.Tile<T> tile) {
382             if (DEBUG) {
383                 log("recycling tile @%d", tile.mStartPosition);
384             }
385             mDataCallback.recycleData(tile.mItems, tile.mItemCount);
386 
387             tile.mNext = mRecycledRoot;
388             mRecycledRoot = tile;
389         }
390 
391         private TileList.Tile<T> acquireTile() {
392             if (mRecycledRoot != null) {
393                 TileList.Tile<T> result = mRecycledRoot;
394                 mRecycledRoot = mRecycledRoot.mNext;
395                 return result;
396             }
397             return new TileList.Tile<T>(mTClass, mTileSize);
398         }
399 
400         private boolean isTileLoaded(int position) {
401             return mLoadedTiles.get(position);
402         }
403 
404         private void addTile(TileList.Tile<T> tile) {
405             mLoadedTiles.put(tile.mStartPosition, true);
406             mMainThreadProxy.addTile(mGeneration, tile);
407             if (DEBUG) {
408                 log("loaded tile @%d, total tiles: %d", tile.mStartPosition, mLoadedTiles.size());
409             }
410         }
411 
412         private void removeTile(int position) {
413             mLoadedTiles.delete(position);
414             mMainThreadProxy.removeTile(mGeneration, position);
415             if (DEBUG) {
416                 log("flushed tile @%d, total tiles: %s", position, mLoadedTiles.size());
417             }
418         }
419 
420         private void flushTileCache(int scrollHint) {
421             final int cacheSizeLimit = mDataCallback.getMaxCachedTiles();
422             while (mLoadedTiles.size() >= cacheSizeLimit) {
423                 int firstLoadedTileStart = mLoadedTiles.keyAt(0);
424                 int lastLoadedTileStart = mLoadedTiles.keyAt(mLoadedTiles.size() - 1);
425                 int startMargin = mFirstRequiredTileStart - firstLoadedTileStart;
426                 int endMargin = lastLoadedTileStart - mLastRequiredTileStart;
427                 if (startMargin > 0 && (startMargin >= endMargin ||
428                         (scrollHint == ViewCallback.HINT_SCROLL_ASC))) {
429                     removeTile(firstLoadedTileStart);
430                 } else if (endMargin > 0 && (startMargin < endMargin ||
431                         (scrollHint == ViewCallback.HINT_SCROLL_DESC))){
432                     removeTile(lastLoadedTileStart);
433                 } else {
434                     // Could not flush on either side, bail out.
435                     return;
436                 }
437             }
438         }
439 
440         private void log(String s, Object... args) {
441             Log.d(TAG, "[BKGR] " + String.format(s, args));
442         }
443     };
444 
445     /**
446      * The callback that provides data access for {@link AsyncListUtil}.
447      *
448      * <p>
449      * All methods are called on the background thread.
450      */
451     public static abstract class DataCallback<T> {
452 
453         /**
454          * Refresh the data set and return the new data item count.
455          *
456          * <p>
457          * If the data is being accessed through {@link android.database.Cursor} this is where
458          * the new cursor should be created.
459          *
460          * @return Data item count.
461          */
462         @WorkerThread
refreshData()463         public abstract int refreshData();
464 
465         /**
466          * Fill the given tile.
467          *
468          * <p>
469          * The provided tile might be a recycled tile, in which case it will already have objects.
470          * It is suggested to re-use these objects if possible in your use case.
471          *
472          * @param startPosition The start position in the list.
473          * @param itemCount The data item count.
474          * @param data The data item array to fill into. Should not be accessed beyond
475          *             <code>itemCount</code>.
476          */
477         @WorkerThread
fillData(@onNull T[] data, int startPosition, int itemCount)478         public abstract void fillData(@NonNull T[] data, int startPosition, int itemCount);
479 
480         /**
481          * Recycle the objects created in {@link #fillData} if necessary.
482          *
483          *
484          * @param data Array of data items. Should not be accessed beyond <code>itemCount</code>.
485          * @param itemCount The data item count.
486          */
487         @WorkerThread
recycleData(@onNull T[] data, int itemCount)488         public void recycleData(@NonNull T[] data, int itemCount) {
489         }
490 
491         /**
492          * Returns tile cache size limit (in tiles).
493          *
494          * <p>
495          * The actual number of cached tiles will be the maximum of this value and the number of
496          * tiles that is required to cover the range returned by
497          * {@link ViewCallback#extendRangeInto(int[], int[], int)}.
498          * <p>
499          * For example, if this method returns 10, and the most
500          * recent call to {@link ViewCallback#extendRangeInto(int[], int[], int)} returned
501          * {100, 179}, and the tile size is 5, then the maximum number of cached tiles will be 16.
502          * <p>
503          * However, if the tile size is 20, then the maximum number of cached tiles will be 10.
504          * <p>
505          * The default implementation returns 10.
506          *
507          * @return Maximum cache size.
508          */
509         @WorkerThread
getMaxCachedTiles()510         public int getMaxCachedTiles() {
511             return 10;
512         }
513     }
514 
515     /**
516      * The callback that links {@link AsyncListUtil} with the list view.
517      *
518      * <p>
519      * All methods are called on the main thread.
520           */
521     public static abstract class ViewCallback {
522 
523         /**
524          * No scroll direction hint available.
525          */
526         public static final int HINT_SCROLL_NONE = 0;
527 
528         /**
529          * Scrolling in descending order (from higher to lower positions in the order of the backing
530          * storage).
531          */
532         public static final int HINT_SCROLL_DESC = 1;
533 
534         /**
535          * Scrolling in ascending order (from lower to higher positions in the order of the backing
536          * storage).
537          */
538         public static final int HINT_SCROLL_ASC = 2;
539 
540         /**
541          * Compute the range of visible item positions.
542          * <p>
543          * outRange[0] is the position of the first visible item (in the order of the backing
544          * storage).
545          * <p>
546          * outRange[1] is the position of the last visible item (in the order of the backing
547          * storage).
548          * <p>
549          * Negative positions and positions greater or equal to {@link #getItemCount} are invalid.
550          * If the returned range contains invalid positions it is ignored (no item will be loaded).
551          *
552          * @param outRange The visible item range.
553          */
554         @UiThread
getItemRangeInto(@onNull int[] outRange)555         public abstract void getItemRangeInto(@NonNull int[] outRange);
556 
557         /**
558          * Compute a wider range of items that will be loaded for smoother scrolling.
559          *
560          * <p>
561          * If there is no scroll hint, the default implementation extends the visible range by half
562          * its length in both directions. If there is a scroll hint, the range is extended by
563          * its full length in the scroll direction, and by half in the other direction.
564          * <p>
565          * For example, if <code>range</code> is <code>{100, 200}</code> and <code>scrollHint</code>
566          * is {@link #HINT_SCROLL_ASC}, then <code>outRange</code> will be <code>{50, 300}</code>.
567          * <p>
568          * However, if <code>scrollHint</code> is {@link #HINT_SCROLL_NONE}, then
569          * <code>outRange</code> will be <code>{50, 250}</code>
570          *
571          * @param range Visible item range.
572          * @param outRange Extended range.
573          * @param scrollHint The scroll direction hint.
574          */
575         @UiThread
extendRangeInto(@onNull int[] range, @NonNull int[] outRange, int scrollHint)576         public void extendRangeInto(@NonNull int[] range, @NonNull int[] outRange, int scrollHint) {
577             final int fullRange = range[1] - range[0] + 1;
578             final int halfRange = fullRange / 2;
579             outRange[0] = range[0] - (scrollHint == HINT_SCROLL_DESC ? fullRange : halfRange);
580             outRange[1] = range[1] + (scrollHint == HINT_SCROLL_ASC ? fullRange : halfRange);
581         }
582 
583         /**
584          * Called when the entire data set has changed.
585          */
586         @UiThread
onDataRefresh()587         public abstract void onDataRefresh();
588 
589         /**
590          * Called when an item at the given position is loaded.
591          * @param position Item position.
592          */
593         @UiThread
onItemLoaded(int position)594         public abstract void onItemLoaded(int position);
595     }
596 }
597