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