AsyncListUtil.java

/*
 * Copyright 2018 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.recyclerview.widget;

import android.util.Log;
import android.util.SparseBooleanArray;
import android.util.SparseIntArray;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.UiThread;
import androidx.annotation.WorkerThread;

/**
 * A utility class that supports asynchronous content loading.
 * <p>
 * It can be used to load Cursor data in chunks without querying the Cursor on the UI Thread while
 * keeping UI and cache synchronous for better user experience.
 * <p>
 * It loads the data on a background thread and keeps only a limited number of fixed sized
 * chunks in memory at all times.
 * <p>
 * {@link AsyncListUtil} queries the currently visible range through {@link ViewCallback},
 * loads the required data items in the background through {@link DataCallback}, and notifies a
 * {@link ViewCallback} when the data is loaded. It may load some extra items for smoother
 * scrolling.
 * <p>
 * Note that this class uses a single thread to load the data, so it suitable to load data from
 * secondary storage such as disk, but not from network.
 * <p>
 * This class is designed to work with {@link RecyclerView}, but it does
 * not depend on it and can be used with other list views.
 *
 */
public class AsyncListUtil<T> {
    static final String TAG = "AsyncListUtil";

    static final boolean DEBUG = false;

    final Class<T> mTClass;
    final int mTileSize;
    final DataCallback<T> mDataCallback;
    final ViewCallback mViewCallback;

    final TileList<T> mTileList;

    final ThreadUtil.MainThreadCallback<T> mMainThreadProxy;
    final ThreadUtil.BackgroundCallback<T> mBackgroundProxy;

    final int[] mTmpRange = new int[2];
    final int[] mPrevRange = new int[2];
    final int[] mTmpRangeExtended = new int[2];

    boolean mAllowScrollHints;
    private int mScrollHint = ViewCallback.HINT_SCROLL_NONE;

    int mItemCount = 0;

    int mDisplayedGeneration = 0;
    int mRequestedGeneration = mDisplayedGeneration;

    final SparseIntArray mMissingPositions = new SparseIntArray();

    void log(String s, Object... args) {
        Log.d(TAG, "[MAIN] " + String.format(s, args));
    }

    /**
     * Creates an AsyncListUtil.
     *
     * @param klass Class of the data item.
     * @param tileSize Number of item per chunk loaded at once.
     * @param dataCallback Data access callback.
     * @param viewCallback Callback for querying visible item range and update notifications.
     */
    public AsyncListUtil(@NonNull Class<T> klass, int tileSize,
            @NonNull DataCallback<T> dataCallback, @NonNull ViewCallback viewCallback) {
        mTClass = klass;
        mTileSize = tileSize;
        mDataCallback = dataCallback;
        mViewCallback = viewCallback;

        mTileList = new TileList<T>(mTileSize);

        ThreadUtil<T> threadUtil = new MessageThreadUtil<T>();
        mMainThreadProxy = threadUtil.getMainThreadProxy(mMainThreadCallback);
        mBackgroundProxy = threadUtil.getBackgroundProxy(mBackgroundCallback);

        refresh();
    }

    private boolean isRefreshPending() {
        return mRequestedGeneration != mDisplayedGeneration;
    }

    /**
     * Updates the currently visible item range.
     *
     * <p>
     * Identifies the data items that have not been loaded yet and initiates loading them in the
     * background. Should be called from the view's scroll listener (such as
     * {@link RecyclerView.OnScrollListener#onScrolled}).
     */
    public void onRangeChanged() {
        if (isRefreshPending()) {
            return;  // Will update range will the refresh result arrives.
        }
        updateRange();
        mAllowScrollHints = true;
    }

    /**
     * Forces reloading the data.
     * <p>
     * Discards all the cached data and reloads all required data items for the currently visible
     * range. To be called when the data item count and/or contents has changed.
     */
    public void refresh() {
        mMissingPositions.clear();
        mBackgroundProxy.refresh(++mRequestedGeneration);
    }

    /**
     * Returns the data item at the given position or <code>null</code> if it has not been loaded
     * yet.
     *
     * <p>
     * If this method has been called for a specific position and returned <code>null</code>, then
     * {@link ViewCallback#onItemLoaded(int)} will be called when it finally loads. Note that if
     * this position stays outside of the cached item range (as defined by
     * {@link ViewCallback#extendRangeInto} method), then the callback will never be called for
     * this position.
     *
     * @param position Item position.
     *
     * @return The data item at the given position or <code>null</code> if it has not been loaded
     *         yet.
     */
    @Nullable
    public T getItem(int position) {
        if (position < 0 || position >= mItemCount) {
            throw new IndexOutOfBoundsException(position + " is not within 0 and " + mItemCount);
        }
        T item = mTileList.getItemAt(position);
        if (item == null && !isRefreshPending()) {
            mMissingPositions.put(position, 0);
        }
        return item;
    }

    /**
     * Returns the number of items in the data set.
     *
     * <p>
     * This is the number returned by a recent call to
     * {@link DataCallback#refreshData()}.
     *
     * @return Number of items.
     */
    public int getItemCount() {
        return mItemCount;
    }

    void updateRange() {
        mViewCallback.getItemRangeInto(mTmpRange);
        if (mTmpRange[0] > mTmpRange[1] || mTmpRange[0] < 0) {
            return;
        }
        if (mTmpRange[1] >= mItemCount) {
            // Invalid range may arrive soon after the refresh.
            return;
        }

        if (!mAllowScrollHints) {
            mScrollHint = ViewCallback.HINT_SCROLL_NONE;
        } else if (mTmpRange[0] > mPrevRange[1] || mPrevRange[0] > mTmpRange[1]) {
            // Ranges do not intersect, long leap not a scroll.
            mScrollHint = ViewCallback.HINT_SCROLL_NONE;
        } else if (mTmpRange[0] < mPrevRange[0]) {
            mScrollHint = ViewCallback.HINT_SCROLL_DESC;
        } else if (mTmpRange[0] > mPrevRange[0]) {
            mScrollHint = ViewCallback.HINT_SCROLL_ASC;
        }

        mPrevRange[0] = mTmpRange[0];
        mPrevRange[1] = mTmpRange[1];

        mViewCallback.extendRangeInto(mTmpRange, mTmpRangeExtended, mScrollHint);
        mTmpRangeExtended[0] = Math.min(mTmpRange[0], Math.max(mTmpRangeExtended[0], 0));
        mTmpRangeExtended[1] =
                Math.max(mTmpRange[1], Math.min(mTmpRangeExtended[1], mItemCount - 1));

        mBackgroundProxy.updateRange(mTmpRange[0], mTmpRange[1],
                mTmpRangeExtended[0], mTmpRangeExtended[1], mScrollHint);
    }

    private final ThreadUtil.MainThreadCallback<T>
            mMainThreadCallback = new ThreadUtil.MainThreadCallback<T>() {
        @Override
        public void updateItemCount(int generation, int itemCount) {
            if (DEBUG) {
                log("updateItemCount: size=%d, gen #%d", itemCount, generation);
            }
            if (!isRequestedGeneration(generation)) {
                return;
            }
            mItemCount = itemCount;
            mViewCallback.onDataRefresh();
            mDisplayedGeneration = mRequestedGeneration;
            recycleAllTiles();

            mAllowScrollHints = false;  // Will be set to true after a first real scroll.
            // There will be no scroll event if the size change does not affect the current range.
            updateRange();
        }

        @Override
        public void addTile(int generation, TileList.Tile<T> tile) {
            if (!isRequestedGeneration(generation)) {
                if (DEBUG) {
                    log("recycling an older generation tile @%d", tile.mStartPosition);
                }
                mBackgroundProxy.recycleTile(tile);
                return;
            }
            TileList.Tile<T> duplicate = mTileList.addOrReplace(tile);
            if (duplicate != null) {
                Log.e(TAG, "duplicate tile @" + duplicate.mStartPosition);
                mBackgroundProxy.recycleTile(duplicate);
            }
            if (DEBUG) {
                log("gen #%d, added tile @%d, total tiles: %d",
                        generation, tile.mStartPosition, mTileList.size());
            }
            int endPosition = tile.mStartPosition + tile.mItemCount;
            int index = 0;
            while (index < mMissingPositions.size()) {
                final int position = mMissingPositions.keyAt(index);
                if (tile.mStartPosition <= position && position < endPosition) {
                    mMissingPositions.removeAt(index);
                    mViewCallback.onItemLoaded(position);
                } else {
                    index++;
                }
            }
        }

        @Override
        public void removeTile(int generation, int position) {
            if (!isRequestedGeneration(generation)) {
                return;
            }
            TileList.Tile<T> tile = mTileList.removeAtPos(position);
            if (tile == null) {
                Log.e(TAG, "tile not found @" + position);
                return;
            }
            if (DEBUG) {
                log("recycling tile @%d, total tiles: %d", tile.mStartPosition, mTileList.size());
            }
            mBackgroundProxy.recycleTile(tile);
        }

        private void recycleAllTiles() {
            if (DEBUG) {
                log("recycling all %d tiles", mTileList.size());
            }
            for (int i = 0; i < mTileList.size(); i++) {
                mBackgroundProxy.recycleTile(mTileList.getAtIndex(i));
            }
            mTileList.clear();
        }

        private boolean isRequestedGeneration(int generation) {
            return generation == mRequestedGeneration;
        }
    };

    private final ThreadUtil.BackgroundCallback<T>
            mBackgroundCallback = new ThreadUtil.BackgroundCallback<T>() {

        private TileList.Tile<T> mRecycledRoot;

        final SparseBooleanArray mLoadedTiles = new SparseBooleanArray();

        private int mGeneration;
        private int mItemCount;

        private int mFirstRequiredTileStart;
        private int mLastRequiredTileStart;

        @Override
        public void refresh(int generation) {
            mGeneration = generation;
            mLoadedTiles.clear();
            mItemCount = mDataCallback.refreshData();
            mMainThreadProxy.updateItemCount(mGeneration, mItemCount);
        }

        @Override
        public void updateRange(int rangeStart, int rangeEnd, int extRangeStart, int extRangeEnd,
                int scrollHint) {
            if (DEBUG) {
                log("updateRange: %d..%d extended to %d..%d, scroll hint: %d",
                        rangeStart, rangeEnd, extRangeStart, extRangeEnd, scrollHint);
            }

            if (rangeStart > rangeEnd) {
                return;
            }

            final int firstVisibleTileStart = getTileStart(rangeStart);
            final int lastVisibleTileStart = getTileStart(rangeEnd);

            mFirstRequiredTileStart = getTileStart(extRangeStart);
            mLastRequiredTileStart = getTileStart(extRangeEnd);
            if (DEBUG) {
                log("requesting tile range: %d..%d",
                        mFirstRequiredTileStart, mLastRequiredTileStart);
            }

            // All pending tile requests are removed by ThreadUtil at this point.
            // Re-request all required tiles in the most optimal order.
            if (scrollHint == ViewCallback.HINT_SCROLL_DESC) {
                requestTiles(mFirstRequiredTileStart, lastVisibleTileStart, scrollHint, true);
                requestTiles(lastVisibleTileStart + mTileSize, mLastRequiredTileStart, scrollHint,
                        false);
            } else {
                requestTiles(firstVisibleTileStart, mLastRequiredTileStart, scrollHint, false);
                requestTiles(mFirstRequiredTileStart, firstVisibleTileStart - mTileSize, scrollHint,
                        true);
            }
        }

        private int getTileStart(int position) {
            return position - position % mTileSize;
        }

        private void requestTiles(int firstTileStart, int lastTileStart, int scrollHint,
                                  boolean backwards) {
            for (int i = firstTileStart; i <= lastTileStart; i += mTileSize) {
                int tileStart = backwards ? (lastTileStart + firstTileStart - i) : i;
                if (DEBUG) {
                    log("requesting tile @%d", tileStart);
                }
                mBackgroundProxy.loadTile(tileStart, scrollHint);
            }
        }

        @Override
        public void loadTile(int position, int scrollHint) {
            if (isTileLoaded(position)) {
                if (DEBUG) {
                    log("already loaded tile @%d", position);
                }
                return;
            }
            TileList.Tile<T> tile = acquireTile();
            tile.mStartPosition = position;
            tile.mItemCount = Math.min(mTileSize, mItemCount - tile.mStartPosition);
            mDataCallback.fillData(tile.mItems, tile.mStartPosition, tile.mItemCount);
            flushTileCache(scrollHint);
            addTile(tile);
        }

        @Override
        public void recycleTile(TileList.Tile<T> tile) {
            if (DEBUG) {
                log("recycling tile @%d", tile.mStartPosition);
            }
            mDataCallback.recycleData(tile.mItems, tile.mItemCount);

            tile.mNext = mRecycledRoot;
            mRecycledRoot = tile;
        }

        private TileList.Tile<T> acquireTile() {
            if (mRecycledRoot != null) {
                TileList.Tile<T> result = mRecycledRoot;
                mRecycledRoot = mRecycledRoot.mNext;
                return result;
            }
            return new TileList.Tile<T>(mTClass, mTileSize);
        }

        private boolean isTileLoaded(int position) {
            return mLoadedTiles.get(position);
        }

        private void addTile(TileList.Tile<T> tile) {
            mLoadedTiles.put(tile.mStartPosition, true);
            mMainThreadProxy.addTile(mGeneration, tile);
            if (DEBUG) {
                log("loaded tile @%d, total tiles: %d", tile.mStartPosition, mLoadedTiles.size());
            }
        }

        private void removeTile(int position) {
            mLoadedTiles.delete(position);
            mMainThreadProxy.removeTile(mGeneration, position);
            if (DEBUG) {
                log("flushed tile @%d, total tiles: %s", position, mLoadedTiles.size());
            }
        }

        private void flushTileCache(int scrollHint) {
            final int cacheSizeLimit = mDataCallback.getMaxCachedTiles();
            while (mLoadedTiles.size() >= cacheSizeLimit) {
                int firstLoadedTileStart = mLoadedTiles.keyAt(0);
                int lastLoadedTileStart = mLoadedTiles.keyAt(mLoadedTiles.size() - 1);
                int startMargin = mFirstRequiredTileStart - firstLoadedTileStart;
                int endMargin = lastLoadedTileStart - mLastRequiredTileStart;
                if (startMargin > 0 && (startMargin >= endMargin ||
                        (scrollHint == ViewCallback.HINT_SCROLL_ASC))) {
                    removeTile(firstLoadedTileStart);
                } else if (endMargin > 0 && (startMargin < endMargin ||
                        (scrollHint == ViewCallback.HINT_SCROLL_DESC))){
                    removeTile(lastLoadedTileStart);
                } else {
                    // Could not flush on either side, bail out.
                    return;
                }
            }
        }

        private void log(String s, Object... args) {
            Log.d(TAG, "[BKGR] " + String.format(s, args));
        }
    };

    /**
     * The callback that provides data access for {@link AsyncListUtil}.
     *
     * <p>
     * All methods are called on the background thread.
     */
    public static abstract class DataCallback<T> {

        /**
         * Refresh the data set and return the new data item count.
         *
         * <p>
         * If the data is being accessed through {@link android.database.Cursor} this is where
         * the new cursor should be created.
         *
         * @return Data item count.
         */
        @WorkerThread
        public abstract int refreshData();

        /**
         * Fill the given tile.
         *
         * <p>
         * The provided tile might be a recycled tile, in which case it will already have objects.
         * It is suggested to re-use these objects if possible in your use case.
         *
         * @param startPosition The start position in the list.
         * @param itemCount The data item count.
         * @param data The data item array to fill into. Should not be accessed beyond
         *             <code>itemCount</code>.
         */
        @WorkerThread
        public abstract void fillData(@NonNull T[] data, int startPosition, int itemCount);

        /**
         * Recycle the objects created in {@link #fillData} if necessary.
         *
         *
         * @param data Array of data items. Should not be accessed beyond <code>itemCount</code>.
         * @param itemCount The data item count.
         */
        @WorkerThread
        public void recycleData(@NonNull T[] data, int itemCount) {
        }

        /**
         * Returns tile cache size limit (in tiles).
         *
         * <p>
         * The actual number of cached tiles will be the maximum of this value and the number of
         * tiles that is required to cover the range returned by
         * {@link ViewCallback#extendRangeInto(int[], int[], int)}.
         * <p>
         * For example, if this method returns 10, and the most
         * recent call to {@link ViewCallback#extendRangeInto(int[], int[], int)} returned
         * {100, 179}, and the tile size is 5, then the maximum number of cached tiles will be 16.
         * <p>
         * However, if the tile size is 20, then the maximum number of cached tiles will be 10.
         * <p>
         * The default implementation returns 10.
         *
         * @return Maximum cache size.
         */
        @WorkerThread
        public int getMaxCachedTiles() {
            return 10;
        }
    }

    /**
     * The callback that links {@link AsyncListUtil} with the list view.
     *
     * <p>
     * All methods are called on the main thread.
          */
    public static abstract class ViewCallback {

        /**
         * No scroll direction hint available.
         */
        public static final int HINT_SCROLL_NONE = 0;

        /**
         * Scrolling in descending order (from higher to lower positions in the order of the backing
         * storage).
         */
        public static final int HINT_SCROLL_DESC = 1;

        /**
         * Scrolling in ascending order (from lower to higher positions in the order of the backing
         * storage).
         */
        public static final int HINT_SCROLL_ASC = 2;

        /**
         * Compute the range of visible item positions.
         * <p>
         * outRange[0] is the position of the first visible item (in the order of the backing
         * storage).
         * <p>
         * outRange[1] is the position of the last visible item (in the order of the backing
         * storage).
         * <p>
         * Negative positions and positions greater or equal to {@link #getItemCount} are invalid.
         * If the returned range contains invalid positions it is ignored (no item will be loaded).
         *
         * @param outRange The visible item range.
         */
        @UiThread
        public abstract void getItemRangeInto(@NonNull int[] outRange);

        /**
         * Compute a wider range of items that will be loaded for smoother scrolling.
         *
         * <p>
         * If there is no scroll hint, the default implementation extends the visible range by half
         * its length in both directions. If there is a scroll hint, the range is extended by
         * its full length in the scroll direction, and by half in the other direction.
         * <p>
         * For example, if <code>range</code> is <code>{100, 200}</code> and <code>scrollHint</code>
         * is {@link #HINT_SCROLL_ASC}, then <code>outRange</code> will be <code>{50, 300}</code>.
         * <p>
         * However, if <code>scrollHint</code> is {@link #HINT_SCROLL_NONE}, then
         * <code>outRange</code> will be <code>{50, 250}</code>
         *
         * @param range Visible item range.
         * @param outRange Extended range.
         * @param scrollHint The scroll direction hint.
         */
        @UiThread
        public void extendRangeInto(@NonNull int[] range, @NonNull int[] outRange, int scrollHint) {
            final int fullRange = range[1] - range[0] + 1;
            final int halfRange = fullRange / 2;
            outRange[0] = range[0] - (scrollHint == HINT_SCROLL_DESC ? fullRange : halfRange);
            outRange[1] = range[1] + (scrollHint == HINT_SCROLL_ASC ? fullRange : halfRange);
        }

        /**
         * Called when the entire data set has changed.
         */
        @UiThread
        public abstract void onDataRefresh();

        /**
         * Called when an item at the given position is loaded.
         * @param position Item position.
         */
        @UiThread
        public abstract void onItemLoaded(int position);
    }
}