diff options
author | 2020-03-05 17:09:39 -0700 | |
---|---|---|
committer | 2020-03-08 19:53:38 -0600 | |
commit | 229886406cd24d71985b32ebbfcc6581802b61ca (patch) | |
tree | 7ab8fc4f25d2f480ff6d9bd86d814259f4898eea | |
parent | d5a429288176f8e49489070dc9ca9d433de5e9d0 (diff) |
Performance improvements and benchmarks.
Since MediaProvider is in the critical path of Camera and Gallery
apps, we need to meet some pretty strict performance thresholds to
ensure that we offer a smoother user experience.
This change adds performance benchmarks that reflect requests from
both internal and external teams, and it averages several runs to
ensure that we're able to meet those deadlines.
This helped us uncover some regressions where we let some Binder
transactions leak back into these critical paths, so we also add
tracing to investigate and fix them. In particular, we bring back
caching of getVolumePath(), with some new fallback logic to recover
if we have a cache miss.
When responding to SQLite triggers, the only blocking action should
be collecting the relevant Uris for notifyChange() calls; other
actions can be deferred to a background thread to keep the critical
paths fast.
Here's the current measurements on a blueline-eng build; these will
be even faster on -userdebug and -user builds:
testBulk:
actionInsert count=5 duration=431329782 average=86
actionUpdate count=5 duration=252340025 average=50
actionDelete count=5 duration=486200309 average=97
notifInsert count=5 duration=258548358 average=51
notifUpdate count=5 duration=269693308 average=53
notifDelete count=5 duration=352254828 average=70
testSingle:
actionInsert count=5 duration=27319587 average=5
actionUpdate count=5 duration=16314063 average=3
actionDelete count=5 duration=22887607 average=4
notifInsert count=5 duration=12765887 average=2
notifUpdate count=5 duration=10215573 average=2
notifDelete count=5 duration=19112397 average=3
Bug: 147778404, 144464323
Test: atest --test-mapping packages/providers/MediaProvider
Change-Id: Ib7e2dee94aae0cb9725295df24b03e41ab823fdf
9 files changed, 531 insertions, 86 deletions
diff --git a/src/com/android/providers/media/CacheClearingActivity.java b/src/com/android/providers/media/CacheClearingActivity.java index f35f8d04d..46ee90d25 100644 --- a/src/com/android/providers/media/CacheClearingActivity.java +++ b/src/com/android/providers/media/CacheClearingActivity.java @@ -34,7 +34,7 @@ import android.view.Window; import android.view.WindowManager; import android.widget.TextView; -import com.android.providers.media.util.BackgroundThread; +import com.android.providers.media.util.ForegroundThread; public class CacheClearingActivity extends Activity implements DialogInterface.OnClickListener { private static final String TAG = "CacheClearingActivity"; @@ -125,7 +125,7 @@ public class CacheClearingActivity extends Activity implements DialogInterface.O public void onClick(DialogInterface dialog, int which) { try { if (which == AlertDialog.BUTTON_POSITIVE) { - BackgroundThread.getExecutor().execute(this::clearAppCache); + ForegroundThread.getExecutor().execute(this::clearAppCache); setResult(RESULT_OK); } } finally { diff --git a/src/com/android/providers/media/DatabaseHelper.java b/src/com/android/providers/media/DatabaseHelper.java index 05276b395..99e6c3170 100644 --- a/src/com/android/providers/media/DatabaseHelper.java +++ b/src/com/android/providers/media/DatabaseHelper.java @@ -58,6 +58,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; +import com.android.providers.media.util.BackgroundThread; import com.android.providers.media.util.DatabaseUtils; import com.android.providers.media.util.FileUtils; import com.android.providers.media.util.ForegroundThread; @@ -68,6 +69,7 @@ import java.io.File; import java.io.FilenameFilter; import java.lang.annotation.Annotation; import java.lang.reflect.Field; +import java.util.ArrayList; import java.util.Objects; import java.util.Set; import java.util.UUID; @@ -222,7 +224,13 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { final int mediaType = Integer.parseInt(split[2]); final boolean isDownload = Integer.parseInt(split[3]) != 0; - mFilesListener.onInsert(DatabaseHelper.this, volumeName, id, mediaType, isDownload); + Trace.beginSection("_INSERT"); + try { + mFilesListener.onInsert(DatabaseHelper.this, volumeName, id, + mediaType, isDownload); + } finally { + Trace.endSection(); + } } return null; }); @@ -236,8 +244,13 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { final int newMediaType = Integer.parseInt(split[4]); final boolean newIsDownload = Integer.parseInt(split[5]) != 0; - mFilesListener.onUpdate(DatabaseHelper.this, volumeName, id, - oldMediaType, oldIsDownload, newMediaType, newIsDownload); + Trace.beginSection("_UPDATE"); + try { + mFilesListener.onUpdate(DatabaseHelper.this, volumeName, id, + oldMediaType, oldIsDownload, newMediaType, newIsDownload); + } finally { + Trace.endSection(); + } } return null; }); @@ -249,7 +262,13 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { final int mediaType = Integer.parseInt(split[2]); final boolean isDownload = Integer.parseInt(split[3]) != 0; - mFilesListener.onDelete(DatabaseHelper.this, volumeName, id, mediaType, isDownload); + Trace.beginSection("_DELETE"); + try { + mFilesListener.onDelete(DatabaseHelper.this, volumeName, id, + mediaType, isDownload); + } finally { + Trace.endSection(); + } } return null; }); @@ -344,6 +363,14 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { * instead being collected due to this ongoing transaction. */ public final SparseArray<ArraySet<Uri>> notifyChanges = new SparseArray<>(); + + /** + * List of tasks that should be enqueued onto {@link BackgroundThread} + * after any {@link #notifyChanges} have been dispatched. We keep this + * as a separate pass to ensure that we don't risk running in parallel + * with other more important tasks. + */ + public final ArrayList<Runnable> backgroundTasks = new ArrayList<>(); } public void beginTransaction() { @@ -379,11 +406,21 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { db.endTransaction(); if (state.successful) { + // We carefully "phase" our two sets of work here to ensure that we + // completely finish dispatching all change notifications before we + // process background tasks, to ensure that the background work + // doesn't steal resources from the more important foreground work ForegroundThread.getExecutor().execute(() -> { for (int i = 0; i < state.notifyChanges.size(); i++) { notifyChangeInternal(state.notifyChanges.valueAt(i), state.notifyChanges.keyAt(i)); } + + // Now that we've finished with all our important work, we can + // finally kick off any internal background tasks + for (int i = 0; i < state.backgroundTasks.size(); i++) { + BackgroundThread.getExecutor().execute(state.backgroundTasks.get(i)); + } }); } } @@ -463,6 +500,20 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { } /** + * Post given task to be run in background. This enqueues the task if + * currently inside a transaction, and they'll be clustered and sent when + * the transaction completes. + */ + public void postBackground(@NonNull Runnable command) { + final TransactionState state = mTransactionState.get(); + if (state != null) { + state.backgroundTasks.add(command); + } else { + BackgroundThread.getExecutor().execute(command); + } + } + + /** * This method cleans up any files created by android.media.MiniThumbFile, removed after P. * It's triggered during database update only, in order to run only once. */ diff --git a/src/com/android/providers/media/MediaDocumentsProvider.java b/src/com/android/providers/media/MediaDocumentsProvider.java index faadee22e..d7caf1805 100644 --- a/src/com/android/providers/media/MediaDocumentsProvider.java +++ b/src/com/android/providers/media/MediaDocumentsProvider.java @@ -69,7 +69,6 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.content.MimeTypeFilter; -import com.android.providers.media.util.BackgroundThread; import com.android.providers.media.util.FileUtils; import java.io.FileNotFoundException; @@ -215,54 +214,50 @@ public class MediaDocumentsProvider extends DocumentsProvider { * refresh to clear a previously reported {@link Root#FLAG_EMPTY}. */ static void onMediaStoreInsert(Context context, String volumeName, int type, long id) { - BackgroundThread.getExecutor().execute(() -> { - if (!"external".equals(volumeName)) return; - - if (type == FileColumns.MEDIA_TYPE_IMAGE && sReturnedImagesEmpty) { - sReturnedImagesEmpty = false; - notifyRootsChanged(context); - } else if (type == FileColumns.MEDIA_TYPE_VIDEO && sReturnedVideosEmpty) { - sReturnedVideosEmpty = false; - notifyRootsChanged(context); - } else if (type == FileColumns.MEDIA_TYPE_AUDIO && sReturnedAudioEmpty) { - sReturnedAudioEmpty = false; - notifyRootsChanged(context); - } else if (type == FileColumns.MEDIA_TYPE_DOCUMENT && sReturnedDocumentsEmpty) { - sReturnedDocumentsEmpty = false; - notifyRootsChanged(context); - } - }); + if (!"external".equals(volumeName)) return; + + if (type == FileColumns.MEDIA_TYPE_IMAGE && sReturnedImagesEmpty) { + sReturnedImagesEmpty = false; + notifyRootsChanged(context); + } else if (type == FileColumns.MEDIA_TYPE_VIDEO && sReturnedVideosEmpty) { + sReturnedVideosEmpty = false; + notifyRootsChanged(context); + } else if (type == FileColumns.MEDIA_TYPE_AUDIO && sReturnedAudioEmpty) { + sReturnedAudioEmpty = false; + notifyRootsChanged(context); + } else if (type == FileColumns.MEDIA_TYPE_DOCUMENT && sReturnedDocumentsEmpty) { + sReturnedDocumentsEmpty = false; + notifyRootsChanged(context); + } } /** * When deleting an item, we need to revoke any outstanding Uri grants. */ static void onMediaStoreDelete(Context context, String volumeName, int type, long id) { - BackgroundThread.getExecutor().execute(() -> { - if (!"external".equals(volumeName)) return; - - if (type == FileColumns.MEDIA_TYPE_IMAGE) { - final Uri uri = DocumentsContract.buildDocumentUri( - AUTHORITY, getDocIdForIdent(TYPE_IMAGE, id)); - context.revokeUriPermission(uri, ~0); - notifyRootsChanged(context); - } else if (type == FileColumns.MEDIA_TYPE_VIDEO) { - final Uri uri = DocumentsContract.buildDocumentUri( - AUTHORITY, getDocIdForIdent(TYPE_VIDEO, id)); - context.revokeUriPermission(uri, ~0); - notifyRootsChanged(context); - } else if (type == FileColumns.MEDIA_TYPE_AUDIO) { - final Uri uri = DocumentsContract.buildDocumentUri( - AUTHORITY, getDocIdForIdent(TYPE_AUDIO, id)); - context.revokeUriPermission(uri, ~0); - notifyRootsChanged(context); - } else if (type == FileColumns.MEDIA_TYPE_DOCUMENT) { - final Uri uri = DocumentsContract.buildDocumentUri( - AUTHORITY, getDocIdForIdent(TYPE_DOCUMENT, id)); - context.revokeUriPermission(uri, ~0); - notifyRootsChanged(context); - } - }); + if (!"external".equals(volumeName)) return; + + if (type == FileColumns.MEDIA_TYPE_IMAGE) { + final Uri uri = DocumentsContract.buildDocumentUri( + AUTHORITY, getDocIdForIdent(TYPE_IMAGE, id)); + context.revokeUriPermission(uri, ~0); + notifyRootsChanged(context); + } else if (type == FileColumns.MEDIA_TYPE_VIDEO) { + final Uri uri = DocumentsContract.buildDocumentUri( + AUTHORITY, getDocIdForIdent(TYPE_VIDEO, id)); + context.revokeUriPermission(uri, ~0); + notifyRootsChanged(context); + } else if (type == FileColumns.MEDIA_TYPE_AUDIO) { + final Uri uri = DocumentsContract.buildDocumentUri( + AUTHORITY, getDocIdForIdent(TYPE_AUDIO, id)); + context.revokeUriPermission(uri, ~0); + notifyRootsChanged(context); + } else if (type == FileColumns.MEDIA_TYPE_DOCUMENT) { + final Uri uri = DocumentsContract.buildDocumentUri( + AUTHORITY, getDocIdForIdent(TYPE_DOCUMENT, id)); + context.revokeUriPermission(uri, ~0); + notifyRootsChanged(context); + } } private static class Ident { diff --git a/src/com/android/providers/media/MediaProvider.java b/src/com/android/providers/media/MediaProvider.java index d2af07dea..5a3c14a19 100644 --- a/src/com/android/providers/media/MediaProvider.java +++ b/src/com/android/providers/media/MediaProvider.java @@ -271,6 +271,8 @@ public class MediaProvider extends ContentProvider { @GuardedBy("sCacheLock") private static final Set<String> sCachedExternalVolumeNames = new ArraySet<>(); @GuardedBy("sCacheLock") + private static final Map<String, File> sCachedVolumePaths = new ArrayMap<>(); + @GuardedBy("sCacheLock") private static final Map<String, Collection<File>> sCachedVolumeScanPaths = new ArrayMap<>(); private void updateVolumes() { @@ -278,11 +280,14 @@ public class MediaProvider extends ContentProvider { sCachedExternalVolumeNames.clear(); sCachedExternalVolumeNames.addAll(MediaStore.getExternalVolumeNames(getContext())); + sCachedVolumePaths.clear(); sCachedVolumeScanPaths.clear(); try { sCachedVolumeScanPaths.put(MediaStore.VOLUME_INTERNAL, FileUtils.getVolumeScanPaths(getContext(), MediaStore.VOLUME_INTERNAL)); for (String volumeName : sCachedExternalVolumeNames) { + sCachedVolumePaths.put(volumeName, + FileUtils.getVolumePath(getContext(), volumeName)); sCachedVolumeScanPaths.put(volumeName, FileUtils.getVolumeScanPaths(getContext(), volumeName)); } @@ -293,26 +298,26 @@ public class MediaProvider extends ContentProvider { // Update filters to reflect mounted volumes so users don't get // confused by metadata from ejected volumes - BackgroundThread.getExecutor().execute(() -> { + ForegroundThread.getExecutor().execute(() -> { mExternalDatabase.setFilterVolumeNames(getExternalVolumeNames()); }); } public File getVolumePath(String volumeName) throws FileNotFoundException { - // TODO(b/144275217): A more performant invocation is - // MediaStore#getVolumePath(sCachedVolumes, volumeName) since we avoid a binder - // to StorageManagerService to getVolumeList. We need to delay the mount broadcasts - // from StorageManagerService so that sCachedVolumes is up to date in - // onVolumeStateChanged before we to call this method, otherwise we would crash - // when we don't find volumeName yet - // Ugly hack to keep unit tests passing, where we don't always have a // Context to discover volumes with if (getContext() == null) { return Environment.getExternalStorageDirectory(); } - return FileUtils.getVolumePath(getContext(), volumeName); + synchronized (sCacheLock) { + File res = sCachedVolumePaths.get(volumeName); + if (res == null) { + res = FileUtils.getVolumePath(getContext(), volumeName); + sCachedVolumePaths.put(volumeName, res); + } + return res; + } } public static Set<String> getExternalVolumeNames() { @@ -441,6 +446,15 @@ public class MediaProvider extends ContentProvider { }; private final void updateQuotaTypeForUri(@NonNull Uri uri, int mediaType) { + Trace.beginSection("updateQuotaTypeForUri"); + try { + updateQuotaTypeForUriInternal(uri, mediaType); + } finally { + Trace.endSection(); + } + } + + private final void updateQuotaTypeForUriInternal(@NonNull Uri uri, int mediaType) { File file; try { file = queryForDataFile(uri, null); @@ -472,20 +486,28 @@ public class MediaProvider extends ContentProvider { } } + /** + * Since these operations are in the critical path of apps working with + * media, we only collect the {@link Uri} that need to be notified, and all + * other side-effect operations are delegated to {@link BackgroundThread} so + * that we return as quickly as possible. + */ private final OnFilesChangeListener mFilesListener = new OnFilesChangeListener() { @Override public void onInsert(@NonNull DatabaseHelper helper, @NonNull String volumeName, long id, int mediaType, boolean isDownload) { acceptWithExpansion(helper::notifyInsert, volumeName, id, mediaType, isDownload); - if (helper.isExternal()) { - // Update the quota type on the filesystem - Uri fileUri = MediaStore.Files.getContentUri(volumeName, id); - updateQuotaTypeForUri(fileUri, mediaType); - } + helper.postBackground(() -> { + if (helper.isExternal()) { + // Update the quota type on the filesystem + Uri fileUri = MediaStore.Files.getContentUri(volumeName, id); + updateQuotaTypeForUri(fileUri, mediaType); + } - // Tell our SAF provider so it knows when views are no longer empty - MediaDocumentsProvider.onMediaStoreInsert(getContext(), volumeName, mediaType, id); + // Tell our SAF provider so it knows when views are no longer empty + MediaDocumentsProvider.onMediaStoreInsert(getContext(), volumeName, mediaType, id); + }); } @Override @@ -494,34 +516,44 @@ public class MediaProvider extends ContentProvider { int newMediaType, boolean newIsDownload) { final boolean isDownload = oldIsDownload || newIsDownload; acceptWithExpansion(helper::notifyUpdate, volumeName, id, oldMediaType, isDownload); - - // When media type changes, notify both old and new collections and - // invalidate any thumbnails if (newMediaType != oldMediaType) { - Uri fileUri = MediaStore.Files.getContentUri(volumeName, id); - if (helper.isExternal()) { - updateQuotaTypeForUri(fileUri, newMediaType); - } acceptWithExpansion(helper::notifyUpdate, volumeName, id, newMediaType, isDownload); - invalidateThumbnails(fileUri); + + helper.postBackground(() -> { + final Uri fileUri = MediaStore.Files.getContentUri(volumeName, id); + if (helper.isExternal()) { + // Update the quota type on the filesystem + updateQuotaTypeForUri(fileUri, newMediaType); + } + + // Invalidate any thumbnails when the media type changes + invalidateThumbnails(fileUri); + }); } } @Override public void onDelete(@NonNull DatabaseHelper helper, @NonNull String volumeName, long id, int mediaType, boolean isDownload) { - // Both notify apps and revoke any outstanding permission grants - final Context context = getContext(); - acceptWithExpansion((uri) -> { - helper.notifyDelete(uri); - context.revokeUriPermission(uri, ~0); - }, volumeName, id, mediaType, isDownload); + acceptWithExpansion(helper::notifyDelete, volumeName, id, mediaType, isDownload); - // Invalidate any thumbnails now that media is gone - invalidateThumbnails(MediaStore.Files.getContentUri(volumeName, id)); + helper.postBackground(() -> { + // Item no longer exists, so revoke all access to it + Trace.beginSection("revokeUriPermission"); + try { + acceptWithExpansion((uri) -> { + getContext().revokeUriPermission(uri, ~0); + }, volumeName, id, mediaType, isDownload); + } finally { + Trace.endSection(); + } - // Tell our SAF provider so it can revoke too - MediaDocumentsProvider.onMediaStoreDelete(getContext(), volumeName, mediaType, id); + // Invalidate any thumbnails now that media is gone + invalidateThumbnails(MediaStore.Files.getContentUri(volumeName, id)); + + // Tell our SAF provider so it can revoke too + MediaDocumentsProvider.onMediaStoreDelete(getContext(), volumeName, mediaType, id); + }); } }; @@ -3654,7 +3686,7 @@ public class MediaProvider extends ContentProvider { @Override public int delete(@NonNull Uri uri, @Nullable Bundle extras) { - Trace.beginSection("insert"); + Trace.beginSection("delete"); try { return deleteInternal(uri, extras); } catch (FallbackException e) { @@ -4660,7 +4692,7 @@ public class MediaProvider extends ContentProvider { for (int i = 0; i < updatedIds.size(); i++) { final long updatedId = updatedIds.get(i); final Uri updatedUri = Files.getContentUri(volumeName, updatedId); - BackgroundThread.getExecutor().execute(() -> { + helper.postBackground(() -> { invalidateThumbnails(updatedUri); }); @@ -6259,7 +6291,7 @@ public class MediaProvider extends ContentProvider { // Also notify on synthetic view of all devices resolver.notifyChange(getBaseContentUri(MediaStore.VOLUME_EXTERNAL), null); - BackgroundThread.getExecutor().execute(() -> { + ForegroundThread.getExecutor().execute(() -> { final DatabaseHelper helper = MediaStore.VOLUME_INTERNAL.equals(volume) ? mInternalDatabase : mExternalDatabase; ensureDefaultFolders(volume, helper); diff --git a/src/com/android/providers/media/util/BackgroundThread.java b/src/com/android/providers/media/util/BackgroundThread.java index dc03112a8..90d10a307 100644 --- a/src/com/android/providers/media/util/BackgroundThread.java +++ b/src/com/android/providers/media/util/BackgroundThread.java @@ -23,6 +23,17 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; +/** + * Thread for asynchronous event processing. This thread is configured as + * {@link android.os.Process#THREAD_PRIORITY_BACKGROUND}, which means fewer CPU + * resources will be dedicated to it, and it will "have less chance of impacting + * the responsiveness of the user interface." + * <p> + * This thread is best suited for tasks that the user is not actively waiting + * for, or for tasks that the user expects to be executed eventually. + * + * @see ForegroundThread + */ public final class BackgroundThread extends HandlerThread { private static BackgroundThread sInstance; private static Handler sHandler; diff --git a/src/com/android/providers/media/util/DatabaseUtils.java b/src/com/android/providers/media/util/DatabaseUtils.java index 11625a7d8..a42233fd0 100644 --- a/src/com/android/providers/media/util/DatabaseUtils.java +++ b/src/com/android/providers/media/util/DatabaseUtils.java @@ -41,6 +41,7 @@ import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteStatement; import android.net.Uri; import android.os.Bundle; +import android.os.Trace; import android.text.TextUtils; import android.util.ArrayMap; import android.util.Log; @@ -447,17 +448,23 @@ public class DatabaseUtils { public static long executeInsert(@NonNull SQLiteDatabase db, @NonNull String sql, @Nullable Object[] bindArgs) throws SQLException { + Trace.beginSection("executeInsert"); try (SQLiteStatement st = db.compileStatement(sql)) { bindArgs(st, bindArgs); return st.executeInsert(); + } finally { + Trace.endSection(); } } public static int executeUpdateDelete(@NonNull SQLiteDatabase db, @NonNull String sql, @Nullable Object[] bindArgs) throws SQLException { + Trace.beginSection("executeUpdateDelete"); try (SQLiteStatement st = db.compileStatement(sql)) { bindArgs(st, bindArgs); return st.executeUpdateDelete(); + } finally { + Trace.endSection(); } } diff --git a/src/com/android/providers/media/util/ForegroundThread.java b/src/com/android/providers/media/util/ForegroundThread.java index 67388fcfa..436c1b8ee 100644 --- a/src/com/android/providers/media/util/ForegroundThread.java +++ b/src/com/android/providers/media/util/ForegroundThread.java @@ -23,6 +23,17 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; +/** + * Thread for asynchronous event processing. This thread is configured as + * {@link android.os.Process#THREAD_PRIORITY_FOREGROUND}, which means more CPU + * resources will be dedicated to it, and it will be treated like "a user + * interface that the user is interacting with." + * <p> + * This thread is best suited for tasks that the user is actively waiting for, + * or for tasks that the user expects to be executed immediately. + * + * @see BackgroundThread + */ public final class ForegroundThread extends HandlerThread { private static ForegroundThread sInstance; private static Handler sHandler; diff --git a/tests/client/src/com/android/providers/media/client/PerformanceTest.java b/tests/client/src/com/android/providers/media/client/PerformanceTest.java new file mode 100644 index 000000000..d587c1bed --- /dev/null +++ b/tests/client/src/com/android/providers/media/client/PerformanceTest.java @@ -0,0 +1,337 @@ +/* + * Copyright (C) 2020 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 com.android.providers.media.client; + +import static org.junit.Assert.assertTrue; + +import android.content.ContentProviderOperation; +import android.content.ContentProviderResult; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.database.ContentObserver; +import android.net.Uri; +import android.os.SystemClock; +import android.provider.MediaStore; +import android.provider.MediaStore.MediaColumns; +import android.util.Log; + +import androidx.test.InstrumentationRegistry; +import androidx.test.runner.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * Since we're right in the critical path between camera and gallery apps, we + * need to meet some pretty strict performance deadlines. + */ +@RunWith(AndroidJUnit4.class) +public class PerformanceTest { + private static final String TAG = "PerformanceTest"; + + /** + * Number of times we should repeat each operation to get an average. + */ + private static final int COUNT_REPEAT = 5; + + /** + * Number of items to use for bulk operation tests. + */ + private static final int COUNT_BULK = 100; + + /** + * Verify performance of "single" standalone operations. + */ + @Test + public void testSingle() throws Exception { + final Timers timers = new Timers(); + for (int i = 0; i < COUNT_REPEAT; i++) { + doSingle(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, timers); + } + + timers.dump(); + + // Verify that core actions finished within 30ms deadline + final long actionDeadline = 30; + assertTrue(timers.actionInsert.getAverageDurationMillis() < actionDeadline); + assertTrue(timers.actionUpdate.getAverageDurationMillis() < actionDeadline); + assertTrue(timers.actionDelete.getAverageDurationMillis() < actionDeadline); + + // Verify that external notifications finished within 30ms deadline + final long notifyDeadline = 30; + assertTrue(timers.notifyInsert.getAverageDurationMillis() < notifyDeadline); + assertTrue(timers.notifyUpdate.getAverageDurationMillis() < notifyDeadline); + assertTrue(timers.notifyDelete.getAverageDurationMillis() < notifyDeadline); + } + + private void doSingle(Uri collection, Timers timers) throws Exception { + final ContentResolver resolver = InstrumentationRegistry.getContext().getContentResolver(); + + Uri res; + MediaStore.waitForIdle(resolver); + { + final ContentValues values = new ContentValues(); + values.put(MediaColumns.DISPLAY_NAME, System.nanoTime() + ".jpg"); + values.put(MediaColumns.MIME_TYPE, "image/jpeg"); + + final CountingContentObserver obs = CountingContentObserver.create( + collection, 1, ContentResolver.NOTIFY_INSERT); + + timers.actionInsert.start(); + res = resolver.insert(collection, values); + timers.actionInsert.stop(); + + timers.notifyInsert.start(); + obs.waitForChange(); + timers.notifyInsert.stop(); + } + MediaStore.waitForIdle(resolver); + { + final ContentValues values = new ContentValues(); + values.put(MediaColumns.IS_FAVORITE, 1); + + final CountingContentObserver obs = CountingContentObserver.create( + collection, 1, ContentResolver.NOTIFY_UPDATE); + + timers.actionUpdate.start(); + resolver.update(res, values, null); + timers.actionUpdate.stop(); + + timers.notifyUpdate.start(); + obs.waitForChange(); + timers.notifyUpdate.stop(); + } + MediaStore.waitForIdle(resolver); + { + final CountingContentObserver obs = CountingContentObserver.create( + collection, 1, ContentResolver.NOTIFY_DELETE); + + timers.actionDelete.start(); + resolver.delete(res, null); + timers.actionDelete.stop(); + + timers.notifyDelete.start(); + obs.waitForChange(); + timers.notifyDelete.stop(); + } + MediaStore.waitForIdle(resolver); + } + + /** + * Verify performance of "bulk" operations, typically encountered when the + * user is taking burst-mode photos or deleting many images. + */ + @Test + public void testBulk() throws Exception { + final Timers timers = new Timers(); + for (int i = 0; i < COUNT_REPEAT; i++) { + doBulk(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, timers); + } + + timers.dump(); + + // Verify that core actions finished within 30ms deadline + final long actionDeadline = 30 * COUNT_BULK; + assertTrue(timers.actionInsert.getAverageDurationMillis() < actionDeadline); + assertTrue(timers.actionUpdate.getAverageDurationMillis() < actionDeadline); + assertTrue(timers.actionDelete.getAverageDurationMillis() < actionDeadline); + + // Verify that external notifications finished within 100ms deadline + final long notifyDeadline = 100; + assertTrue(timers.notifyInsert.getAverageDurationMillis() < notifyDeadline); + assertTrue(timers.notifyUpdate.getAverageDurationMillis() < notifyDeadline); + assertTrue(timers.notifyDelete.getAverageDurationMillis() < notifyDeadline); + } + + private void doBulk(Uri collection, Timers timers) throws Exception { + final ContentResolver resolver = InstrumentationRegistry.getContext().getContentResolver(); + + ContentProviderResult[] res; + MediaStore.waitForIdle(resolver); + { + final ArrayList<ContentProviderOperation> ops = new ArrayList<>(); + for (int i = 0; i < COUNT_BULK; i++) { + ops.add(ContentProviderOperation.newInsert(collection) + .withValue(MediaColumns.DISPLAY_NAME, System.nanoTime() + ".jpg") + .withValue(MediaColumns.MIME_TYPE, "image/jpeg") + .build()); + } + + final CountingContentObserver obs = CountingContentObserver.create( + collection, COUNT_BULK, ContentResolver.NOTIFY_INSERT); + + timers.actionInsert.start(); + res = resolver.applyBatch(collection.getAuthority(), ops); + timers.actionInsert.stop(); + + timers.notifyInsert.start(); + obs.waitForChange(); + timers.notifyInsert.stop(); + } + MediaStore.waitForIdle(resolver); + { + final ArrayList<ContentProviderOperation> ops = new ArrayList<>(); + for (int i = 0; i < COUNT_BULK; i++) { + ops.add(ContentProviderOperation.newUpdate(res[i].uri) + .withValue(MediaColumns.IS_FAVORITE, 1) + .build()); + } + + final CountingContentObserver obs = CountingContentObserver.create( + collection, COUNT_BULK, ContentResolver.NOTIFY_UPDATE); + + timers.actionUpdate.start(); + resolver.applyBatch(collection.getAuthority(), ops); + timers.actionUpdate.stop(); + + timers.notifyUpdate.start(); + obs.waitForChange(); + timers.notifyUpdate.stop(); + } + MediaStore.waitForIdle(resolver); + { + final ArrayList<ContentProviderOperation> ops = new ArrayList<>(); + for (int i = 0; i < COUNT_BULK; i++) { + ops.add(ContentProviderOperation.newDelete(res[i].uri) + .build()); + } + + final CountingContentObserver obs = CountingContentObserver.create( + collection, COUNT_BULK, ContentResolver.NOTIFY_DELETE); + + timers.actionDelete.start(); + resolver.applyBatch(collection.getAuthority(), ops); + timers.actionDelete.stop(); + + timers.notifyDelete.start(); + obs.waitForChange(); + timers.notifyDelete.stop(); + } + MediaStore.waitForIdle(resolver); + } + + private static Set<Uri> asSet(Iterable<Uri> uris) { + final Set<Uri> asSet = new HashSet<>(); + uris.forEach(asSet::add); + return asSet; + } + + /** + * Timer that can be started/stopped with nanosecond accuracy, and later + * averaged based on the number of times it was cycled. + */ + private static class Timer { + private int count; + private long duration; + private long start; + + public void start() { + if (start != 0) { + throw new IllegalStateException(); + } else { + start = SystemClock.elapsedRealtimeNanos(); + } + } + + public void stop() { + if (start == 0) { + throw new IllegalStateException(); + } else { + duration += (SystemClock.elapsedRealtimeNanos() - start); + start = 0; + count++; + } + } + + public long getAverageDurationMillis() { + return TimeUnit.MILLISECONDS.convert(duration / count, TimeUnit.NANOSECONDS); + } + + @Override + public String toString() { + return String.format("count=%d duration=%dns average=%dms", count, duration, + getAverageDurationMillis()); + } + } + + private static class Timers { + public final Timer actionInsert = new Timer(); + public final Timer actionUpdate = new Timer(); + public final Timer actionDelete = new Timer(); + public final Timer notifyInsert = new Timer(); + public final Timer notifyUpdate = new Timer(); + public final Timer notifyDelete = new Timer(); + + public void dump() { + Log.v(TAG, "actionInsert " + actionInsert); + Log.v(TAG, "actionUpdate " + actionUpdate); + Log.v(TAG, "actionDelete " + actionDelete); + Log.v(TAG, "notifyInsert " + notifyInsert); + Log.v(TAG, "notifyUpdate " + notifyUpdate); + Log.v(TAG, "notifyDelete " + notifyDelete); + } + } + + /** + * Observer that will wait for a specific change event to be delivered. + */ + public static class CountingContentObserver extends ContentObserver { + private final int uriCount; + private final int flags; + + private final CountDownLatch latch = new CountDownLatch(1); + + private CountingContentObserver(int uriCount, int flags) { + super(null); + this.uriCount = uriCount; + this.flags = flags; + } + + @Override + public void onChange(boolean selfChange, Iterable<Uri> uris, int flags) { + Log.v(TAG, String.format("onChange(%b, %s, %d)", + selfChange, asSet(uris).toString(), flags)); + + if ((asSet(uris).size() == this.uriCount) && (flags == this.flags)) { + latch.countDown(); + } + } + + public static CountingContentObserver create(Uri uri, int uriCount, int flags) { + final CountingContentObserver obs = new CountingContentObserver(uriCount, flags); + InstrumentationRegistry.getContext().getContentResolver() + .registerContentObserver(uri, true, obs); + return obs; + } + + public void waitForChange() { + try { + assertTrue(latch.await(5, TimeUnit.SECONDS)); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + InstrumentationRegistry.getContext().getContentResolver() + .unregisterContentObserver(this); + } + } +} diff --git a/trace.sh b/trace.sh new file mode 100755 index 000000000..410e8f073 --- /dev/null +++ b/trace.sh @@ -0,0 +1 @@ +./external/chromium-trace/systrace.py -b 128768 -a com.google.android.providers.media.module,com.android.providers.media.module binder_driver |