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