public class

AsyncListUtil<T>

extends java.lang.Object

 java.lang.Object

↳androidx.recyclerview.widget.AsyncListUtil<T>

Gradle dependencies

compile group: 'androidx.recyclerview', name: 'recyclerview', version: '1.3.0-alpha02'

  • groupId: androidx.recyclerview
  • artifactId: recyclerview
  • version: 1.3.0-alpha02

Artifact androidx.recyclerview:recyclerview:1.3.0-alpha02 it located at Google repository (https://maven.google.com/)

Androidx artifact mapping:

androidx.recyclerview:recyclerview com.android.support:recyclerview-v7

Androidx class mapping:

androidx.recyclerview.widget.AsyncListUtil android.support.v7.util.AsyncListUtil

Overview

A utility class that supports asynchronous content loading.

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.

It loads the data on a background thread and keeps only a limited number of fixed sized chunks in memory at all times.

AsyncListUtil queries the currently visible range through AsyncListUtil.ViewCallback, loads the required data items in the background through AsyncListUtil.DataCallback, and notifies a AsyncListUtil.ViewCallback when the data is loaded. It may load some extra items for smoother scrolling.

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.

This class is designed to work with RecyclerView, but it does not depend on it and can be used with other list views.

Summary

Constructors
publicAsyncListUtil(java.lang.Class<java.lang.Object> klass, int tileSize, AsyncListUtil.DataCallback<java.lang.Object> dataCallback, AsyncListUtil.ViewCallback viewCallback)

Creates an AsyncListUtil.

Methods
public java.lang.ObjectgetItem(int position)

Returns the data item at the given position or null if it has not been loaded yet.

public intgetItemCount()

Returns the number of items in the data set.

public voidonRangeChanged()

Updates the currently visible item range.

public voidrefresh()

Forces reloading the data.

from java.lang.Objectclone, equals, finalize, getClass, hashCode, notify, notifyAll, toString, wait, wait, wait

Constructors

public AsyncListUtil(java.lang.Class<java.lang.Object> klass, int tileSize, AsyncListUtil.DataCallback<java.lang.Object> dataCallback, AsyncListUtil.ViewCallback viewCallback)

Creates an AsyncListUtil.

Parameters:

klass: Class of the data item.
tileSize: Number of item per chunk loaded at once.
dataCallback: Data access callback.
viewCallback: Callback for querying visible item range and update notifications.

Methods

public void onRangeChanged()

Updates the currently visible item range.

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 RecyclerView.OnScrollListener.onScrolled(RecyclerView, int, int)).

public void refresh()

Forces reloading the data.

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 java.lang.Object getItem(int position)

Returns the data item at the given position or null if it has not been loaded yet.

If this method has been called for a specific position and returned null, then AsyncListUtil.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 AsyncListUtil.ViewCallback.extendRangeInto(int[], int[], int) method), then the callback will never be called for this position.

Parameters:

position: Item position.

Returns:

The data item at the given position or null if it has not been loaded yet.

public int getItemCount()

Returns the number of items in the data set.

This is the number returned by a recent call to AsyncListUtil.DataCallback.refreshData().

Returns:

Number of items.

Source

/*
 * 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);
    }
}