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