Java tutorial
/* * Copyright (C) 2017 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 android.support.content; import static android.support.v4.util.Preconditions.checkArgument; import static android.support.v4.util.Preconditions.checkState; import android.content.ContentResolver; import android.database.CrossProcessCursor; import android.database.Cursor; import android.database.CursorWindow; import android.database.CursorWrapper; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.os.CancellationSignal; import android.os.OperationCanceledException; import android.support.annotation.GuardedBy; import android.support.annotation.IntDef; import android.support.annotation.MainThread; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.annotation.RequiresPermission; import android.support.annotation.VisibleForTesting; import android.support.annotation.WorkerThread; import android.support.v4.util.LruCache; import android.util.Log; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.HashSet; import java.util.Set; /** * {@link ContentPager} provides support for loading "paged" data on a background thread * using the {@link ContentResolver} framework. This provides an effective compatibility * layer for the ContentResolver "paging" support added in Android O. Those Android O changes, * like this class, help reduce or eliminate the occurrence of expensive inter-process * shared memory operations (aka "CursorWindow swaps") happening on the UI thread when * working with remote providers. * * <p>The list of terms used in this document: * * <ol>"The provider" is a {@link android.content.ContentProvider} supplying data identified * by a specific content {@link Uri}. A provider is the source of data, and for the sake of * this documents, the provider resides in a remote process. * <ol>"supports paging" A provider supports paging when it returns a pre-paged {@link Cursor} * that honors the paging contract. See @link ContentResolver#QUERY_ARG_OFFSET} and * {@link ContentResolver#QUERY_ARG_LIMIT} for details on the contract. * <ol>"CursorWindow swaps" The process by which new data is loaded into a shared memory * via a CursorWindow instance. This is a prominent contributor to UI jank in applications * that use Cursor as backing data for UI elements like {@code RecyclerView}. * * <p><b>Details</b> * * <p>Data will be loaded from a content uri in one of two ways, depending on the runtime * environment and if the provider supports paging. * * <li>If the system is Android O and greater and the provider supports paging, the Cursor * will be returned, effectively unmodified, to a {@link ContentCallback} supplied by * your application. * * <li>If the system is less than Android O or the provider does not support paging, the * loader will fetch an unpaged Cursor from the provider. The unpaged Cursor will be held * by the ContentPager, and data will be copied into a new cursor in a background thread. * The new cursor will be returned to a {@link ContentCallback} supplied by your application. * * <p>In either cases, when an application employs this library it can generally assume * that there will be no CursorWindow swap. But picking the right limit for records can * help reduce or even eliminate some heavy lifting done to guard against swaps. * * <p>How do we avoid that entirely? * * <p><b>Picking a reasonable item limit</b> * * <p>Authors are encouraged to experiment with limits using real data and the widest column * projection they'll use in their app. The total number of records that will fit into shared * memory varies depending on multiple factors. * * <li>The number of columns being requested in the cursor projection. Limit the number * of columns, to reduce the size of each row. * <li>The size of the data in each column. * <li>the Cursor type. * * <p>If the cursor is running in-process, there may be no need for paging. Depending on * the Cursor implementation chosen there may be no shared memory/CursorWindow in use. * NOTE: If the provider is running in your process, you should implement paging support * inorder to make your app run fast and to consume the fewest resources possible. * * <p>In common cases where there is a low volume (in the hundreds) of records in the dataset * being queried, all of the data should easily fit in shared memory. A debugger can be handy * to understand with greater accuracy how many results can fit in shared memory. Inspect * the Cursor object returned from a call to * {@link ContentResolver#query(Uri, String[], String, String[], String)}. If the underlying * type is a {@link android.database.CrossProcessCursor} or * {@link android.database.AbstractWindowedCursor} it'll have a {@link CursorWindow} field. * Check {@link CursorWindow#getNumRows()}. If getNumRows returns less than * {@link Cursor#getCount}, then you've found something close to the max rows that'll * fit in a page. If the data in row is expected to be relatively stable in size, reduce * row count by 15-20% to get a reasonable max page size. * * <p><b>What if the limit I guessed was wrong?</b> * <p>The library includes safeguards that protect against situations where an author * specifies a record limit that exceeds the number of rows accessible without a CursorWindow swap. * In such a circumstance, the Cursor will be adapted to report a count ({Cursor#getCount}) * that reflects only records available without CursorWindow swap. But this involves * extra work that can be eliminated with a correct limit. * * <p>In addition to adjusted coujnt, {@link #EXTRA_SUGGESTED_LIMIT} will be included * in cursor extras. When EXTRA_SUGGESTED_LIMIT is present in extras, the client should * strongly consider using this value as the limit for subsequent queries as doing so should * help avoid the ned to wrap pre-paged cursors. * * <p><b>Lifecycle and cleanup</b> * * <p>Cursors resulting from queries are owned by the requesting client. So they must be closed * by the client at the appropriate time. * * <p>However, the library retains an internal cache of content that needs to be cleaned up. * In order to cleanup, call {@link #reset()}. * * <p><b>Projections</b> * * <p>Note that projection is ignored when determining the identity of a query. When * adding or removing projection, clients should call {@link #reset()} to clear * cached data. */ public class ContentPager { @VisibleForTesting static final String CURSOR_DISPOSITION = "android.support.v7.widget.CURSOR_DISPOSITION"; @IntDef(value = { ContentPager.CURSOR_DISPOSITION_COPIED, ContentPager.CURSOR_DISPOSITION_PAGED, ContentPager.CURSOR_DISPOSITION_REPAGED, ContentPager.CURSOR_DISPOSITION_WRAPPED }) @Retention(RetentionPolicy.SOURCE) public @interface CursorDisposition { } /** The cursor size exceeded page size. A new cursor with with page data was created. */ public static final int CURSOR_DISPOSITION_COPIED = 1; /** * The cursor was provider paged. */ public static final int CURSOR_DISPOSITION_PAGED = 2; /** The cursor was pre-paged, but total size was larger than CursorWindow size. */ public static final int CURSOR_DISPOSITION_REPAGED = 3; /** * The cursor was not pre-paged, but total size was smaller than page size. * Cursor wrapped to supply data in extras only. */ public static final int CURSOR_DISPOSITION_WRAPPED = 4; /** @see ContentResolver#EXTRA_HONORED_ARGS */ public static final String EXTRA_HONORED_ARGS = ContentResolver.EXTRA_HONORED_ARGS; /** @see ContentResolver#EXTRA_TOTAL_COUNT */ public static final String EXTRA_TOTAL_COUNT = ContentResolver.EXTRA_TOTAL_COUNT; /** @see ContentResolver#QUERY_ARG_OFFSET */ public static final String QUERY_ARG_OFFSET = ContentResolver.QUERY_ARG_OFFSET; /** @see ContentResolver#QUERY_ARG_LIMIT */ public static final String QUERY_ARG_LIMIT = ContentResolver.QUERY_ARG_LIMIT; /** Denotes the requested limit, if the limit was not-honored. */ public static final String EXTRA_REQUESTED_LIMIT = "android-support:extra-ignored-limit"; /** Specifies a limit likely to fit in CursorWindow limit. */ public static final String EXTRA_SUGGESTED_LIMIT = "android-support:extra-suggested-limit"; private static final boolean DEBUG = false; private static final String TAG = "ContentPager"; private static final int DEFAULT_CURSOR_CACHE_SIZE = 1; private final QueryRunner mQueryRunner; private final QueryRunner.Callback mQueryCallback; private final ContentResolver mResolver; private final Object mContentLock = new Object(); private final @GuardedBy("mContentLock") Set<Query> mActiveQueries = new HashSet<>(); private final @GuardedBy("mContentLock") CursorCache mCursorCache; private final Stats mStats = new Stats(); /** * Creates a new ContentPager with a default cursor cache size of 1. */ public ContentPager(ContentResolver resolver, QueryRunner queryRunner) { this(resolver, queryRunner, DEFAULT_CURSOR_CACHE_SIZE); } /** * Creates a new ContentPager. * * @param cursorCacheSize Specifies the size of the unpaged cursor cache. If you will * only be querying a single content Uri, 1 is sufficient. If you wish to use * a single ContentPager for queries against several independent Uris this number * should be increased to reflect that. Remember that adding or modifying a * query argument creates a new Uri. * @param resolver The content resolver to use when performing queries. * @param queryRunner The query running to use. This provides a means of executing * queries on a background thread. */ public ContentPager(@NonNull ContentResolver resolver, @NonNull QueryRunner queryRunner, int cursorCacheSize) { checkArgument(resolver != null, "'resolver' argument cannot be null."); checkArgument(queryRunner != null, "'queryRunner' argument cannot be null."); checkArgument(cursorCacheSize > 0, "'cursorCacheSize' argument must be greater than 0."); mResolver = resolver; mQueryRunner = queryRunner; mQueryCallback = new QueryRunner.Callback() { @WorkerThread @Override public @Nullable Cursor runQueryInBackground(Query query) { return loadContentInBackground(query); } @MainThread @Override public void onQueryFinished(Query query, Cursor cursor) { ContentPager.this.onCursorReady(query, cursor); } }; mCursorCache = new CursorCache(cursorCacheSize); } /** * Initiates loading of content. * For details on all params but callback, see * {@link ContentResolver#query(Uri, String[], Bundle, CancellationSignal)}. * * @param uri The URI, using the content:// scheme, for the content to retrieve. * @param projection A list of which columns to return. Passing null will return * the default project as determined by the provider. This can be inefficient, * so it is best to supply a projection. * @param queryArgs A Bundle containing any arguments to the query. * @param cancellationSignal A signal to cancel the operation in progress, or null if none. * If the operation is canceled, then {@link OperationCanceledException} will be thrown * when the query is executed. * @param callback The callback that will receive the query results. * * @return A Query object describing the query. */ @MainThread public @NonNull Query query(@NonNull @RequiresPermission.Read Uri uri, @Nullable String[] projection, @NonNull Bundle queryArgs, @Nullable CancellationSignal cancellationSignal, @NonNull ContentCallback callback) { checkArgument(uri != null, "'uri' argument cannot be null."); checkArgument(queryArgs != null, "'queryArgs' argument cannot be null."); checkArgument(callback != null, "'callback' argument cannot be null."); Query query = new Query(uri, projection, queryArgs, cancellationSignal, callback); if (DEBUG) Log.d(TAG, "Handling query: " + query); if (!mQueryRunner.isRunning(query)) { synchronized (mContentLock) { mActiveQueries.add(query); } mQueryRunner.query(query, mQueryCallback); } return query; } /** * Clears any cached data. This method must be called in order to cleanup runtime state * (like cursors). */ @MainThread public void reset() { synchronized (mContentLock) { if (DEBUG) Log.d(TAG, "Clearing un-paged cursor cache."); mCursorCache.evictAll(); for (Query query : mActiveQueries) { if (DEBUG) Log.d(TAG, "Canceling running query: " + query); mQueryRunner.cancel(query); query.cancel(); } mActiveQueries.clear(); } } @WorkerThread private Cursor loadContentInBackground(Query query) { if (DEBUG) Log.v(TAG, "Loading cursor for query: " + query); mStats.increment(Stats.EXTRA_TOTAL_QUERIES); synchronized (mContentLock) { // We have a existing unpaged-cursor for this query. Instead of running a new query // via ContentResolver, we'll just copy results from that. // This is the "compat" behavior. if (mCursorCache.hasEntry(query.getUri())) { if (DEBUG) Log.d(TAG, "Found unpaged results in cache for: " + query); return createPagedCursor(query); } } // We don't have an unpaged query, so we run the query using ContentResolver. // It may be that no query for this URI has ever been run, so no unpaged // results have been saved. Or, it may be the the provider supports paging // directly, and is returning a pre-paged result set...so no unpaged // cursor will ever be set. Cursor cursor = query.run(mResolver); mStats.increment(Stats.EXTRA_RESOLVED_QUERIES); // for the window. If so, communicate the overflow back to the client. if (cursor == null) { Log.e(TAG, "Query resulted in null cursor. " + query); return null; } if (isProviderPaged(cursor)) { return processProviderPagedCursor(query, cursor); } // Cache the unpaged results so we can generate pages from them on subsequent queries. synchronized (mContentLock) { mCursorCache.put(query.getUri(), cursor); return createPagedCursor(query); } } @WorkerThread @GuardedBy("mContentLock") private Cursor createPagedCursor(Query query) { Cursor unpaged = mCursorCache.get(query.getUri()); checkState(unpaged != null, "No un-paged cursor in cache, or can't retrieve it."); mStats.increment(Stats.EXTRA_COMPAT_PAGED); if (DEBUG) Log.d(TAG, "Synthesizing cursor for page: " + query); int count = Math.min(query.getLimit(), unpaged.getCount()); // don't wander off the end of the cursor. if (query.getOffset() + query.getLimit() > unpaged.getCount()) { count = unpaged.getCount() % query.getLimit(); } if (DEBUG) Log.d(TAG, "Cursor count: " + count); Cursor result = null; // If the cursor isn't advertising support for paging, but is in-fact smaller // than the page size requested, we just decorate the cursor with paging data, // and wrap it without copy. if (query.getOffset() == 0 && unpaged.getCount() < query.getLimit()) { result = new CursorView(unpaged, unpaged.getCount(), CURSOR_DISPOSITION_WRAPPED); } else { // This creates an in-memory copy of the data that fits the requested page. // ContentObservers registered on InMemoryCursor are directly registered // on the unpaged cursor. result = new InMemoryCursor(unpaged, query.getOffset(), count, CURSOR_DISPOSITION_COPIED); } mStats.includeStats(result.getExtras()); return result; } @WorkerThread private @Nullable Cursor processProviderPagedCursor(Query query, Cursor cursor) { CursorWindow window = getWindow(cursor); int windowSize = cursor.getCount(); if (window != null) { if (DEBUG) Log.d(TAG, "Returning provider-paged cursor."); windowSize = window.getNumRows(); } // Android O paging APIs are *all* about avoiding CursorWindow swaps, // because the swaps need to happen on the UI thread in jank-inducing ways. // But, the APIs don't *guarantee* that no window-swapping will happen // when traversing a cursor. // // Here in the support lib, we can guarantee there is no window swapping // by detecting mismatches between requested sizes and window sizes. // When a mismatch is detected we can return a cursor that reports // a size bounded by its CursorWindow size, and includes a suggested // size to use for subsequent queries. if (DEBUG) Log.d(TAG, "Cursor window overflow detected. Returning re-paged cursor."); int disposition = (cursor.getCount() <= windowSize) ? CURSOR_DISPOSITION_PAGED : CURSOR_DISPOSITION_REPAGED; Cursor result = new CursorView(cursor, windowSize, disposition); Bundle extras = result.getExtras(); // If the orig cursor reports a size larger than the window, suggest a better limit. if (cursor.getCount() > windowSize) { extras.putInt(EXTRA_REQUESTED_LIMIT, query.getLimit()); extras.putInt(EXTRA_SUGGESTED_LIMIT, (int) (windowSize * .85)); } mStats.increment(Stats.EXTRA_PROVIDER_PAGED); mStats.includeStats(extras); return result; } private CursorWindow getWindow(Cursor cursor) { if (cursor instanceof CursorWrapper) { return getWindow(((CursorWrapper) cursor).getWrappedCursor()); } if (cursor instanceof CrossProcessCursor) { return ((CrossProcessCursor) cursor).getWindow(); } // TODO: Any other ways we can find/access windows? return null; } // Called in the foreground when the cursor is ready for the client. @MainThread private void onCursorReady(Query query, Cursor cursor) { synchronized (mContentLock) { mActiveQueries.remove(query); } query.getCallback().onCursorReady(query, cursor); } /** * @return true if the cursor extras contains all of the signs of being paged. * Technically we could also check SDK version since facilities for paging * were added in SDK 26, but if it looks like a duck and talks like a duck * itsa duck (especially if it helps with testing). */ @WorkerThread private boolean isProviderPaged(Cursor cursor) { Bundle extras = cursor.getExtras(); extras = extras != null ? extras : Bundle.EMPTY; String[] honoredArgs = extras.getStringArray(EXTRA_HONORED_ARGS); return (extras.containsKey(EXTRA_TOTAL_COUNT) && honoredArgs != null && contains(honoredArgs, QUERY_ARG_OFFSET) && contains(honoredArgs, QUERY_ARG_LIMIT)); } private static <T> boolean contains(T[] array, T value) { for (T element : array) { if (value.equals(element)) { return true; } } return false; } /** * @return Bundle populated with existing extras (if any) as well as * all usefule paging related extras. */ static Bundle buildExtras(@Nullable Bundle extras, int recordCount, @CursorDisposition int cursorDisposition) { if (extras == null || extras == Bundle.EMPTY) { extras = new Bundle(); } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { extras = extras.deepCopy(); } // else we modify cursor extras directly, cuz that's our only choice. extras.putInt(CURSOR_DISPOSITION, cursorDisposition); if (!extras.containsKey(EXTRA_TOTAL_COUNT)) { extras.putInt(EXTRA_TOTAL_COUNT, recordCount); } if (!extras.containsKey(EXTRA_HONORED_ARGS)) { extras.putStringArray(EXTRA_HONORED_ARGS, new String[] { ContentPager.QUERY_ARG_OFFSET, ContentPager.QUERY_ARG_LIMIT }); } return extras; } /** * Builds a Bundle with offset and limit values suitable for with * {@link #query(Uri, String[], Bundle, CancellationSignal, ContentCallback)}. * * @param offset must be greater than or equal to 0. * @param limit can be any value. Only values greater than or equal to 0 are respected. * If any other value results in no upper limit on results. Note that a well * behaved client should probably supply a reasonable limit. See class * documentation on how to select a limit. * * @return Mutable Bundle pre-populated with offset and limits vales. */ public static @NonNull Bundle createArgs(int offset, int limit) { checkArgument(offset >= 0); Bundle args = new Bundle(); args.putInt(ContentPager.QUERY_ARG_OFFSET, offset); args.putInt(ContentPager.QUERY_ARG_LIMIT, limit); return args; } /** * Callback by which a client receives results of a query. */ public interface ContentCallback { /** * Called when paged cursor is ready. Null, if query failed. * @param query The query having been executed. * @param cursor the query results. Null if query couldn't be executed. */ @MainThread void onCursorReady(@NonNull Query query, @Nullable Cursor cursor); } /** * Provides support for adding extras to a cursor. This is necessary * as a cursor returning an extras Bundle that is either Bundle.EMPTY * or null, cannot have information added to the cursor. On SDKs earlier * than M, there is no facility to replace the Bundle. */ private static final class CursorView extends CursorWrapper { private final Bundle mExtras; private final int mSize; CursorView(Cursor delegate, int size, @CursorDisposition int disposition) { super(delegate); mSize = size; mExtras = buildExtras(delegate.getExtras(), delegate.getCount(), disposition); } @Override public int getCount() { return mSize; } @Override public Bundle getExtras() { return mExtras; } } /** * LruCache holding at most {@code maxSize} cursors. Once evicted a cursor * is immediately closed. The only cursor's held in this cache are * unpaged results. For this purpose the cache is keyed by the URI, * not the entire query. Cursors that are pre-paged by the provider * are never cached. */ private static final class CursorCache extends LruCache<Uri, Cursor> { CursorCache(int maxSize) { super(maxSize); } @WorkerThread @Override protected void entryRemoved(boolean evicted, Uri uri, Cursor oldCursor, Cursor newCursor) { if (!oldCursor.isClosed()) { oldCursor.close(); } } /** @return true if an entry is present for the Uri. */ @WorkerThread @GuardedBy("mContentLock") boolean hasEntry(Uri uri) { return get(uri) != null; } } /** * Implementations of this interface provide the mechanism * for execution of queries off the UI thread. */ public interface QueryRunner { /** * Execute a query. * @param query The query that will be run. This value should be handed * back to the callback when ready to run in the background. * @param callback The callback that should be called to both execute * the query (in the background) and to receive the results * (in the foreground). */ void query(@NonNull Query query, @NonNull Callback callback); /** * @param query The query in question. * @return true if the query is already running. */ boolean isRunning(@NonNull Query query); /** * Attempt to cancel a (presumably) running query. * @param query The query in question. */ void cancel(@NonNull Query query); /** * Callback that receives a cursor once a query as been executed on the Runner. */ interface Callback { /** * Method called on background thread where actual query is executed. This is provided * by ContentPager. * @param query The query to be executed. */ @Nullable Cursor runQueryInBackground(@NonNull Query query); /** * Called on main thread when query has completed. * @param query The completed query. * @param cursor The results in Cursor form. Null if not successfully completed. */ void onQueryFinished(@NonNull Query query, @Nullable Cursor cursor); } } static final class Stats { /** Identifes the total number of queries handled by ContentPager. */ static final String EXTRA_TOTAL_QUERIES = "android-support:extra-total-queries"; /** Identifes the number of queries handled by content resolver. */ static final String EXTRA_RESOLVED_QUERIES = "android-support:extra-resolved-queries"; /** Identifes the number of pages produced by way of copying. */ static final String EXTRA_COMPAT_PAGED = "android-support:extra-compat-paged"; /** Identifes the number of pages produced directly by a page-supporting provider. */ static final String EXTRA_PROVIDER_PAGED = "android-support:extra-provider-paged"; // simple stats objects tracking paged result handling. private int mTotalQueries; private int mResolvedQueries; private int mCompatPaged; private int mProviderPaged; private void increment(String prop) { switch (prop) { case EXTRA_TOTAL_QUERIES: ++mTotalQueries; break; case EXTRA_RESOLVED_QUERIES: ++mResolvedQueries; break; case EXTRA_COMPAT_PAGED: ++mCompatPaged; break; case EXTRA_PROVIDER_PAGED: ++mProviderPaged; break; default: throw new IllegalArgumentException("Unknown property: " + prop); } } private void reset() { mTotalQueries = 0; mResolvedQueries = 0; mCompatPaged = 0; mProviderPaged = 0; } void includeStats(Bundle bundle) { bundle.putInt(EXTRA_TOTAL_QUERIES, mTotalQueries); bundle.putInt(EXTRA_RESOLVED_QUERIES, mResolvedQueries); bundle.putInt(EXTRA_COMPAT_PAGED, mCompatPaged); bundle.putInt(EXTRA_PROVIDER_PAGED, mProviderPaged); } } }