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
17package android.support.v7.util;
18
19import android.support.annotation.UiThread;
20import android.support.annotation.WorkerThread;
21import android.util.Log;
22import android.util.SparseBooleanArray;
23import 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 */
46public class AsyncListUtil<T> {
47    static final String TAG = "AsyncListUtil";
48
49    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    boolean mAllowScrollHints;
66    private int mScrollHint = ViewCallback.HINT_SCROLL_NONE;
67
68    int mItemCount = 0;
69
70    int mDisplayedGeneration = 0;
71    int mRequestedGeneration = mDisplayedGeneration;
72
73    final SparseIntArray mMissingPositions = new SparseIntArray();
74
75    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     */
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
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     */
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     */
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     */
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     */
170    public int getItemCount() {
171        return mItemCount;
172    }
173
174    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
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
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
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
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
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
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
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
590        public abstract void onItemLoaded(int position);
591    }
592}
593