perf: optimize app icon loading with asynchronous Coil retrieval

- Remove `appIcon: Drawable` from `AppInfo` to significantly reduce memory footprint and prevent OOM.
- Replace synchronous icon loading in `AppManagerUtil` with on-demand asynchronous loading.
- Implement custom `AppIconFetcher` using `appicon:` URI scheme for system icon retrieval.
- Use localized `fetcherFactory` in `AppListItem` to keep loading logic isolated and minimize global side effects.
- Improve app list scanning speed by skipping icon extraction during initial load.
This commit is contained in:
2dust committed 2026-08-04 21:22:19 +08:00
1 parent 2a51e7e767
commit 2dcc7781a5
9 files changed
+75 -62

No files matched your search

+1
View File
@@ -153,6 +153,7 @@ dependencies {
implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.lifecycle.runtime.compose) implementation(libs.lifecycle.runtime.compose)
implementation(libs.coil.compose)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
@@ -1,11 +1,8 @@
package com.v2ray.ang.dto package com.v2ray.ang.dto
import android.graphics.drawable.Drawable
data class AppInfo( data class AppInfo(
val appName: String, val appName: String,
val packageName: String, val packageName: String,
val appIcon: Drawable,
val isSystemApp: Boolean, val isSystemApp: Boolean,
var isSelected: Int var isSelected: Int
) )
@@ -187,7 +187,7 @@ fun AppPickerScreen(
AppListItem( AppListItem(
appName = app.appName, appName = app.appName,
packageName = app.packageName, packageName = app.packageName,
icon = app.appIcon, icon = null,
checked = checked, checked = checked,
onCheckedChange = { onToggleApp(app.packageName) } onCheckedChange = { onToggleApp(app.packageName) }
) )
@@ -120,13 +120,9 @@ class AppPickerViewModel(application: Application) : BaseViewModel(application)
} }
private fun createSpecialItemUnidentified(context: Context): AppInfo { private fun createSpecialItemUnidentified(context: Context): AppInfo {
val icon = context.getDrawable(android.R.drawable.ic_menu_help)
?: context.getDrawable(android.R.drawable.sym_def_app_icon)
?: error("No fallback drawable available")
return AppInfo( return AppInfo(
appName = context.getString(R.string.app_picker_unknown_app), appName = context.getString(R.string.app_picker_unknown_app),
packageName = AppConfig.UNIDENTIFIED_PACKAGE, packageName = AppConfig.UNIDENTIFIED_PACKAGE,
appIcon = icon,
isSystemApp = false, isSystemApp = false,
isSelected = 0 isSelected = 0
) )
@@ -1,14 +1,10 @@
package com.v2ray.ang.ui.compose package com.v2ray.ang.ui.compose
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -45,7 +41,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
@@ -53,7 +48,11 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.v2ray.ang.util.AppIconFetcher
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.v2ray.ang.R import com.v2ray.ang.R
import sh.calvin.reorderable.ReorderableCollectionItemScope import sh.calvin.reorderable.ReorderableCollectionItemScope
@@ -163,6 +162,7 @@ fun AppListItem(
onCheckedChange: (Boolean) -> Unit, onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val context = LocalContext.current
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
@@ -170,41 +170,26 @@ fun AppListItem(
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (icon != null) { val model = remember(icon, packageName) {
when (icon) { if (icon != null) {
is Bitmap -> { icon
Image( } else {
bitmap = icon.asImageBitmap(), val data = "appicon:$packageName"
contentDescription = null, ImageRequest.Builder(context)
modifier = Modifier.size(40.dp), .data(data)
contentScale = ContentScale.Fit .fetcherFactory(AppIconFetcher.Factory(context))
) .build()
}
is Int -> {
Image(
painter = painterResource(id = icon),
contentDescription = null,
modifier = Modifier.size(40.dp),
contentScale = ContentScale.Fit
)
}
is Drawable -> {
val bitmap = remember(icon) { createBitmapFromDrawable(icon) }
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.size(40.dp),
contentScale = ContentScale.Fit
)
}
else -> {
Box(modifier = Modifier.size(40.dp))
}
} }
} }
AsyncImage(
model = model,
contentDescription = null,
modifier = Modifier.size(40.dp),
contentScale = ContentScale.Fit,
error = painterResource(R.drawable.ic_image_24dp),
fallback = painterResource(R.drawable.ic_image_24dp)
)
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
@@ -230,19 +215,6 @@ fun AppListItem(
} }
} }
private fun createBitmapFromDrawable(drawable: Drawable): Bitmap {
if (drawable is BitmapDrawable) {
return drawable.bitmap
}
val width = drawable.intrinsicWidth.takeIf { it > 0 } ?: 96
val height = drawable.intrinsicHeight.takeIf { it > 0 } ?: 96
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
drawable.setBounds(0, 0, canvas.width, canvas.height)
drawable.draw(canvas)
return bitmap
}
@Composable @Composable
fun ItemDivider() { fun ItemDivider() {
AppDivider(modifier = Modifier.padding(horizontal = 12.dp)) AppDivider(modifier = Modifier.padding(horizontal = 12.dp))
@@ -272,7 +272,7 @@ fun PerAppProxyScreen(
AppListItem( AppListItem(
appName = app.appName, appName = app.appName,
packageName = app.packageName, packageName = app.packageName,
icon = app.appIcon, icon = null,
checked = checked, checked = checked,
onCheckedChange = { onToggleApp(app.packageName) } onCheckedChange = { onToggleApp(app.packageName) }
) )
@@ -0,0 +1,46 @@
package com.v2ray.ang.util
import android.content.Context
import coil.ImageLoader
import coil.decode.DataSource
import coil.fetch.DrawableResult
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.request.Options
import com.v2ray.ang.AppConfig
class AppIconFetcher(
private val packageName: String,
private val context: Context
) : Fetcher {
override suspend fun fetch(): FetchResult? {
val drawable = try {
if (packageName == AppConfig.UNIDENTIFIED_PACKAGE) {
context.getDrawable(android.R.drawable.ic_menu_help)
?: context.getDrawable(android.R.drawable.sym_def_app_icon)
} else {
context.packageManager.getApplicationIcon(packageName)
}
} catch (_: Exception) {
null
} ?: return null
return DrawableResult(
drawable = drawable,
isSampled = false,
dataSource = DataSource.DISK
)
}
class Factory(private val context: Context) : Fetcher.Factory<Any> {
override fun create(data: Any, options: Options, imageLoader: ImageLoader): Fetcher? {
val dataString = data.toString()
if (dataString.startsWith("appicon:")) {
val pkg = dataString.substringAfter("appicon:")
return AppIconFetcher(pkg, context)
}
return null
}
}
}
@@ -24,10 +24,9 @@ object AppManagerUtil {
val applicationInfo = pkg.applicationInfo ?: continue val applicationInfo = pkg.applicationInfo ?: continue
val appName = applicationInfo.loadLabel(packageManager).toString() val appName = applicationInfo.loadLabel(packageManager).toString()
val appIcon = applicationInfo.loadIcon(packageManager) ?: continue
val isSystemApp = applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM > 0 val isSystemApp = applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM > 0
val appInfo = AppInfo(appName, pkg.packageName, appIcon, isSystemApp, 0) val appInfo = AppInfo(appName, pkg.packageName, isSystemApp, 0)
apps.add(appInfo) apps.add(appInfo)
} }
+2
View File
@@ -19,6 +19,7 @@ mockitoMockitoInline = "5.2.0"
camerax = "1.6.1" camerax = "1.6.1"
composeBom = "2026.06.01" composeBom = "2026.06.01"
reorderable = "3.1.0" reorderable = "3.1.0"
coil = "2.7.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
desugar-jdk-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugarJdkLibs" } desugar-jdk-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugarJdkLibs" }
@@ -54,6 +55,7 @@ androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleViewmodelKtx" } lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleViewmodelKtx" }
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" }