Create a new icon cache mechanism for memory improvement

- Avoid loading all app icons at once to decrease memory usage.
- Implement AppIconCacheManager with LruCache to optimize cache usage.
- Create related APIs to access the icon cache conveniently.
- Enable the new cache mechanism for the Settings process.

Bug: 187118427
Bug: 209898662
Test: robotests & check memory trace of apps pages.
Change-Id: I3843d7ed00812b6923e3c6dc175f98d507f81b7b
This commit is contained in:
Yanting Yang
2022-01-07 02:01:50 +08:00
parent c5900ec571
commit eefcdeefb3
6 changed files with 526 additions and 12 deletions

View File

@@ -0,0 +1,112 @@
/*
* Copyright (C) 2022 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.settingslib.applications;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.os.UserHandle;
import android.util.Log;
import android.util.LruCache;
/**
* Cache app icon for management.
*/
public class AppIconCacheManager {
private static final String TAG = "AppIconCacheManager";
private static final float CACHE_RATIO = 0.1f;
private static final int MAX_CACHE_SIZE_IN_KB = getMaxCacheInKb();
private static final String DELIMITER = ":";
private static AppIconCacheManager sAppIconCacheManager;
private final LruCache<String, Drawable> mDrawableCache;
private AppIconCacheManager() {
mDrawableCache = new LruCache<String, Drawable>(MAX_CACHE_SIZE_IN_KB) {
@Override
protected int sizeOf(String key, Drawable drawable) {
if (drawable instanceof BitmapDrawable) {
return ((BitmapDrawable) drawable).getBitmap().getByteCount() / 1024;
}
// Rough estimate each pixel will use 4 bytes by default.
return drawable.getIntrinsicHeight() * drawable.getIntrinsicWidth() * 4 / 1024;
}
};
}
/**
* Get an {@link AppIconCacheManager} instance.
*/
public static synchronized AppIconCacheManager getInstance() {
if (sAppIconCacheManager == null) {
sAppIconCacheManager = new AppIconCacheManager();
}
return sAppIconCacheManager;
}
/**
* Put app icon to cache
*
* @param packageName of icon
* @param uid of packageName
* @param drawable app icon
*/
public void put(String packageName, int uid, Drawable drawable) {
final String key = getKey(packageName, uid);
if (key == null || drawable == null || drawable.getIntrinsicHeight() < 0
|| drawable.getIntrinsicWidth() < 0) {
Log.w(TAG, "Invalid key or drawable.");
return;
}
mDrawableCache.put(key, drawable);
}
/**
* Get app icon from cache.
*
* @param packageName of icon
* @param uid of packageName
* @return app icon
*/
public Drawable get(String packageName, int uid) {
final String key = getKey(packageName, uid);
if (key == null) {
Log.w(TAG, "Invalid key with package or uid.");
return null;
}
final Drawable cachedDrawable = mDrawableCache.get(key);
return cachedDrawable != null ? cachedDrawable.mutate() : null;
}
/**
* Release cache.
*/
public static void release() {
if (sAppIconCacheManager != null) {
sAppIconCacheManager.mDrawableCache.evictAll();
}
}
private static String getKey(String packageName, int uid) {
if (packageName == null || uid < 0) {
return null;
}
return packageName + DELIMITER + UserHandle.getUserId(uid);
}
private static int getMaxCacheInKb() {
return Math.round(CACHE_RATIO * Runtime.getRuntime().maxMemory() / 1024);
}
}

View File

@@ -25,6 +25,7 @@ import android.content.pm.ApplicationInfo;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.graphics.drawable.Drawable;
import android.hardware.usb.IUsbManager;
import android.net.Uri;
import android.os.Environment;
@@ -35,7 +36,9 @@ import android.text.TextUtils;
import android.util.Log;
import com.android.settingslib.R;
import com.android.settingslib.Utils;
import com.android.settingslib.applications.instantapps.InstantAppDataProvider;
import com.android.settingslib.utils.ThreadUtils;
import java.util.ArrayList;
import java.util.List;
@@ -212,4 +215,82 @@ public class AppUtils {
UserHandle.myUserId());
return TextUtils.equals(packageName, defaultBrowserPackage);
}
/**
* Get the app icon by app entry.
*
* @param context caller's context
* @param appEntry AppEntry of ApplicationsState
* @return app icon of the app entry
*/
public static Drawable getIcon(Context context, ApplicationsState.AppEntry appEntry) {
if (appEntry == null || appEntry.info == null) {
return null;
}
final AppIconCacheManager appIconCacheManager = AppIconCacheManager.getInstance();
final String packageName = appEntry.info.packageName;
final int uid = appEntry.info.uid;
Drawable icon = appIconCacheManager.get(packageName, uid);
if (icon == null) {
if (appEntry.apkFile != null && appEntry.apkFile.exists()) {
icon = Utils.getBadgedIcon(context, appEntry.info);
appIconCacheManager.put(packageName, uid, icon);
} else {
setAppEntryMounted(appEntry, /* mounted= */ false);
icon = context.getDrawable(
com.android.internal.R.drawable.sym_app_on_sd_unavailable_icon);
}
} else if (!appEntry.mounted && appEntry.apkFile != null && appEntry.apkFile.exists()) {
// If the app wasn't mounted but is now mounted, reload its icon.
setAppEntryMounted(appEntry, /* mounted= */ true);
icon = Utils.getBadgedIcon(context, appEntry.info);
appIconCacheManager.put(packageName, uid, icon);
}
return icon;
}
/**
* Get the app icon from cache by app entry.
*
* @param appEntry AppEntry of ApplicationsState
* @return app icon of the app entry
*/
public static Drawable getIconFromCache(ApplicationsState.AppEntry appEntry) {
return appEntry == null || appEntry.info == null ? null
: AppIconCacheManager.getInstance().get(
appEntry.info.packageName,
appEntry.info.uid);
}
/**
* Preload the top N icons of app entry list.
*
* @param context caller's context
* @param appEntries AppEntry list of ApplicationsState
* @param number the number of Top N icons of the appEntries
*/
public static void preloadTopIcons(Context context,
ArrayList<ApplicationsState.AppEntry> appEntries, int number) {
if (appEntries == null || appEntries.isEmpty() || number <= 0) {
return;
}
for (int i = 0; i < Math.min(appEntries.size(), number); i++) {
final ApplicationsState.AppEntry entry = appEntries.get(i);
ThreadUtils.postOnBackgroundThread(() -> {
getIcon(context, entry);
});
}
}
private static void setAppEntryMounted(ApplicationsState.AppEntry appEntry, boolean mounted) {
if (appEntry.mounted != mounted) {
synchronized (appEntry) {
appEntry.mounted = mounted;
}
}
}
}

View File

@@ -95,6 +95,7 @@ public class ApplicationsState {
private static final Object sLock = new Object();
private static final Pattern REMOVE_DIACRITICALS_PATTERN
= Pattern.compile("\\p{InCombiningDiacriticalMarks}+");
private static final String SETTING_PKG = "com.android.settings";
@VisibleForTesting
static ApplicationsState sInstance;
@@ -492,6 +493,9 @@ public class ApplicationsState {
return null;
}
/**
* Starting Android T, this method will not be used if {@link AppIconCacheManager} is applied.
*/
public void ensureIcon(AppEntry entry) {
if (entry.icon != null) {
return;
@@ -758,6 +762,10 @@ public class ApplicationsState {
return null;
}
private static boolean isAppIconCacheEnabled(Context context) {
return SETTING_PKG.equals(context.getPackageName());
}
void rebuildActiveSessions() {
synchronized (mEntriesMap) {
if (!mSessionsChanged) {
@@ -806,6 +814,11 @@ public class ApplicationsState {
} else {
mHasLifecycle = false;
}
if (isAppIconCacheEnabled(mContext)) {
// Skip the preloading all icons step to save memory usage.
mFlags = mFlags & ~FLAG_SESSION_REQUEST_ICONS;
}
}
@SessionFlags
@@ -814,7 +827,12 @@ public class ApplicationsState {
}
public void setSessionFlags(@SessionFlags int flags) {
mFlags = flags;
if (isAppIconCacheEnabled(mContext)) {
// Skip the preloading all icons step to save memory usage.
mFlags = flags & ~FLAG_SESSION_REQUEST_ICONS;
} else {
mFlags = flags;
}
}
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
@@ -1576,6 +1594,10 @@ public class ApplicationsState {
// Need to synchronize on 'this' for the following.
public ApplicationInfo info;
/**
* Starting Android T, this field will not be used if {@link AppIconCacheManager} is
* applied.
*/
public Drawable icon;
public String sizeStr;
public String internalSizeStr;
@@ -1596,15 +1618,11 @@ public class ApplicationsState {
this.size = SIZE_UNKNOWN;
this.sizeStale = true;
ensureLabel(context);
// Speed up the cache of the icon and label description if they haven't been created.
ThreadUtils.postOnBackgroundThread(() -> {
if (this.icon == null) {
this.ensureIconLocked(context);
}
if (this.labelDescription == null) {
this.ensureLabelDescriptionLocked(context);
}
});
// Speed up the cache of the label description if they haven't been created.
if (this.labelDescription == null) {
ThreadUtils.postOnBackgroundThread(
() -> this.ensureLabelDescriptionLocked(context));
}
}
public void ensureLabel(Context context) {
@@ -1620,7 +1638,15 @@ public class ApplicationsState {
}
}
/**
* Starting Android T, this method will not be used if {@link AppIconCacheManager} is
* applied.
*/
boolean ensureIconLocked(Context context) {
if (isAppIconCacheEnabled(context)) {
return false;
}
if (this.icon == null) {
if (this.apkFile.exists()) {
this.icon = Utils.getBadgedIcon(context, info);

View File

@@ -0,0 +1,109 @@
/*
* Copyright (C) 2022 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.settingslib.applications;
import static com.google.common.truth.Truth.assertThat;
import static org.mockito.Mockito.doReturn;
import android.graphics.drawable.Drawable;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.robolectric.RobolectricTestRunner;
@RunWith(RobolectricTestRunner.class)
public class AppIconCacheManagerTest {
private static final String APP_PACKAGE_NAME = "com.test.app";
private static final int APP_UID = 9999;
@Mock
private Drawable mIcon;
private AppIconCacheManager mAppIconCacheManager;
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
mAppIconCacheManager = AppIconCacheManager.getInstance();
doReturn(10).when(mIcon).getIntrinsicHeight();
doReturn(10).when(mIcon).getIntrinsicWidth();
doReturn(mIcon).when(mIcon).mutate();
}
@After
public void tearDown() {
AppIconCacheManager.release();
}
@Test
public void get_invalidPackageOrUid_shouldReturnNull() {
assertThat(mAppIconCacheManager.get(/* packageName= */ null, /* uid= */ -1)).isNull();
}
@Test
public void put_invalidPackageOrUid_shouldNotCrash() {
mAppIconCacheManager.put(/* packageName= */ null, /* uid= */ 0, mIcon);
// no crash
}
@Test
public void put_invalidIcon_shouldNotCacheIcon() {
mAppIconCacheManager.put(APP_PACKAGE_NAME, APP_UID, /* drawable= */ null);
assertThat(mAppIconCacheManager.get(APP_PACKAGE_NAME, APP_UID)).isNull();
}
@Test
public void put_invalidIconSize_shouldNotCacheIcon() {
doReturn(-1).when(mIcon).getIntrinsicHeight();
doReturn(-1).when(mIcon).getIntrinsicWidth();
mAppIconCacheManager.put(APP_PACKAGE_NAME, APP_UID, mIcon);
assertThat(mAppIconCacheManager.get(APP_PACKAGE_NAME, APP_UID)).isNull();
}
@Test
public void put_shouldCacheIcon() {
mAppIconCacheManager.put(APP_PACKAGE_NAME, APP_UID, mIcon);
assertThat(mAppIconCacheManager.get(APP_PACKAGE_NAME, APP_UID)).isEqualTo(mIcon);
}
@Test
public void release_noInstance_shouldNotCrash() {
mAppIconCacheManager = null;
AppIconCacheManager.release();
// no crash
}
@Test
public void release_existInstance_shouldClearCache() {
mAppIconCacheManager.put(APP_PACKAGE_NAME, APP_UID, mIcon);
AppIconCacheManager.release();
assertThat(mAppIconCacheManager.get(APP_PACKAGE_NAME, APP_UID)).isNull();
}
}

View File

@@ -0,0 +1,167 @@
/*
* Copyright (C) 2022 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.settingslib.applications;
import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assert.fail;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import android.content.Context;
import android.content.pm.ApplicationInfo;
import android.graphics.drawable.Drawable;
import com.android.settingslib.Utils;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.annotation.Config;
import org.robolectric.annotation.Implementation;
import org.robolectric.annotation.Implements;
import java.io.File;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;
@RunWith(RobolectricTestRunner.class)
public class AppUtilsTest {
private static final String APP_PACKAGE_NAME = "com.test.app";
private static final int APP_UID = 9999;
@Mock
private Drawable mIcon;
private Context mContext;
private AppIconCacheManager mAppIconCacheManager;
private ApplicationInfo mAppInfo;
private ApplicationsState.AppEntry mAppEntry;
private ArrayList<ApplicationsState.AppEntry> mAppEntries;
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
mContext = RuntimeEnvironment.application;
mAppIconCacheManager = AppIconCacheManager.getInstance();
mAppInfo = createApplicationInfo(APP_PACKAGE_NAME, APP_UID);
mAppEntry = createAppEntry(mAppInfo, /* id= */ 1);
mAppEntries = new ArrayList<>(Arrays.asList(mAppEntry));
doReturn(mIcon).when(mIcon).mutate();
}
@After
public void tearDown() {
AppIconCacheManager.release();
}
@Test
public void getIcon_nullAppEntry_shouldReturnNull() {
assertThat(AppUtils.getIcon(mContext, /* appEntry= */ null)).isNull();
}
@Test
@Config(shadows = ShadowUtils.class)
public void getIcon_noCachedIcon_shouldNotReturnNull() {
assertThat(AppUtils.getIcon(mContext, mAppEntry)).isNotNull();
}
@Test
public void getIcon_existCachedIcon_shouldReturnCachedIcon() {
mAppIconCacheManager.put(APP_PACKAGE_NAME, APP_UID, mIcon);
assertThat(AppUtils.getIcon(mContext, mAppEntry)).isEqualTo(mIcon);
}
@Test
public void getIconFromCache_nullAppEntry_shouldReturnNull() {
assertThat(AppUtils.getIconFromCache(/* appEntry= */ null)).isNull();
}
@Test
public void getIconFromCache_shouldReturnCachedIcon() {
mAppIconCacheManager.put(APP_PACKAGE_NAME, APP_UID, mIcon);
assertThat(AppUtils.getIconFromCache(mAppEntry)).isEqualTo(mIcon);
}
@Test
public void preloadTopIcons_nullAppEntries_shouldNotCrash() {
AppUtils.preloadTopIcons(mContext, /* appEntries= */ null, /* number= */ 1);
// no crash
}
@Test
public void preloadTopIcons_zeroPreloadIcons_shouldNotCacheIcons() {
AppUtils.preloadTopIcons(mContext, mAppEntries, /* number= */ 0);
assertThat(mAppIconCacheManager.get(APP_PACKAGE_NAME, APP_UID)).isNull();
}
@Test
@Config(shadows = ShadowUtils.class)
public void preloadTopIcons_shouldCheckIconFromCache() throws InterruptedException {
AppUtils.preloadTopIcons(mContext, mAppEntries, /* number= */ 1);
TimeUnit.SECONDS.sleep(1);
assertThat(mAppIconCacheManager.get(APP_PACKAGE_NAME, APP_UID)).isNotNull();
}
private ApplicationsState.AppEntry createAppEntry(ApplicationInfo appInfo, int id) {
ApplicationsState.AppEntry appEntry = new ApplicationsState.AppEntry(mContext, appInfo, id);
appEntry.label = "label";
appEntry.mounted = true;
final File apkFile = mock(File.class);
doReturn(true).when(apkFile).exists();
try {
Field field = ApplicationsState.AppEntry.class.getDeclaredField("apkFile");
field.setAccessible(true);
field.set(appEntry, apkFile);
} catch (NoSuchFieldException | IllegalAccessException e) {
fail("Not able to mock apkFile: " + e);
}
return appEntry;
}
private ApplicationInfo createApplicationInfo(String packageName, int uid) {
ApplicationInfo appInfo = new ApplicationInfo();
appInfo.sourceDir = "appPath";
appInfo.packageName = packageName;
appInfo.uid = uid;
return appInfo;
}
@Implements(Utils.class)
private static class ShadowUtils {
@Implementation
public static Drawable getBadgedIcon(Context context, ApplicationInfo appInfo) {
final Drawable icon = mock(Drawable.class);
doReturn(10).when(icon).getIntrinsicHeight();
doReturn(10).when(icon).getIntrinsicWidth();
doReturn(icon).when(icon).mutate();
return icon;
}
}
}

View File

@@ -33,6 +33,7 @@ import static org.mockito.Mockito.when;
import static org.robolectric.shadow.api.Shadow.extract;
import android.annotation.UserIdInt;
import android.app.Application;
import android.app.ApplicationPackageManager;
import android.app.usage.StorageStats;
import android.app.usage.StorageStatsManager;
@@ -110,6 +111,7 @@ public class ApplicationsStateRoboTest {
private ApplicationsState mApplicationsState;
private Session mSession;
private Application mApplication;
@Mock
private Callbacks mCallbacks;
@@ -190,6 +192,7 @@ public class ApplicationsStateRoboTest {
ShadowContextImpl shadowContext = Shadow.extract(
RuntimeEnvironment.application.getBaseContext());
shadowContext.setSystemService(Context.STORAGE_STATS_SERVICE, mStorageStatsManager);
mApplication = spy(RuntimeEnvironment.application);
StorageStats storageStats = new StorageStats();
storageStats.codeBytes = 10;
storageStats.cacheBytes = 30;
@@ -207,8 +210,7 @@ public class ApplicationsStateRoboTest {
anyLong() /* flags */, anyInt() /* userId */)).thenReturn(new ParceledListSlice(infos));
ApplicationsState.sInstance = null;
mApplicationsState =
ApplicationsState.getInstance(RuntimeEnvironment.application, mPackageManagerService);
mApplicationsState = ApplicationsState.getInstance(mApplication, mPackageManagerService);
mApplicationsState.clearEntries();
mSession = mApplicationsState.newSession(mCallbacks);
@@ -703,6 +705,23 @@ public class ApplicationsStateRoboTest {
verify(mApplicationsState, never()).clearEntries();
}
@Test
public void testDefaultSession_enabledAppIconCache_shouldSkipPreloadIcon() {
when(mApplication.getPackageName()).thenReturn("com.android.settings");
mSession.onResume();
addApp(HOME_PACKAGE_NAME, 1);
addApp(LAUNCHABLE_PACKAGE_NAME, 2);
mSession.rebuild(ApplicationsState.FILTER_EVERYTHING, ApplicationsState.SIZE_COMPARATOR);
processAllMessages();
verify(mCallbacks).onRebuildComplete(mAppEntriesCaptor.capture());
List<AppEntry> appEntries = mAppEntriesCaptor.getValue();
for (AppEntry appEntry : appEntries) {
assertThat(appEntry.icon).isNull();
}
}
private void setupDoResumeIfNeededLocked(ArrayList<ApplicationInfo> ownerApps,
ArrayList<ApplicationInfo> profileApps)
throws RemoteException {