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.ui.tooling.preview)
implementation(libs.lifecycle.runtime.compose)
implementation(libs.coil.compose)
debugImplementation(libs.androidx.compose.ui.tooling)
@@ -1,11 +1,8 @@
package com.v2ray.ang.dto
import android.graphics.drawable.Drawable
data class AppInfo(
val appName: String,
val packageName: String,
val appIcon: Drawable,
val isSystemApp: Boolean,
var isSelected: Int
)
@@ -187,7 +187,7 @@ fun AppPickerScreen(
AppListItem(
appName = app.appName,
packageName = app.packageName,
icon = app.appIcon,
icon = null,
checked = checked,
onCheckedChange = { onToggleApp(app.packageName) }
)
@@ -120,13 +120,9 @@ class AppPickerViewModel(application: Application) : BaseViewModel(application)
}
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(
appName = context.getString(R.string.app_picker_unknown_app),
packageName = AppConfig.UNIDENTIFIED_PACKAGE,
appIcon = icon,
isSystemApp = false,
isSelected = 0
)
@@ -1,14 +1,10 @@
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 androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
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.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale
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.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.platform.LocalContext
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 com.v2ray.ang.R
import sh.calvin.reorderable.ReorderableCollectionItemScope
@@ -163,6 +162,7 @@ fun AppListItem(
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
Row(
modifier = modifier
.fillMaxWidth()
@@ -170,41 +170,26 @@ fun AppListItem(
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (icon != null) {
when (icon) {
is Bitmap -> {
Image(
bitmap = icon.asImageBitmap(),
contentDescription = null,
modifier = Modifier.size(40.dp),
contentScale = ContentScale.Fit
)
}
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))
}
val model = remember(icon, packageName) {
if (icon != null) {
icon
} else {
val data = "appicon:$packageName"
ImageRequest.Builder(context)
.data(data)
.fetcherFactory(AppIconFetcher.Factory(context))
.build()
}
}
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))
Column(modifier = Modifier.weight(1f)) {
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
fun ItemDivider() {
AppDivider(modifier = Modifier.padding(horizontal = 12.dp))
@@ -272,7 +272,7 @@ fun PerAppProxyScreen(
AppListItem(
appName = app.appName,
packageName = app.packageName,
icon = app.appIcon,
icon = null,
checked = checked,
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 appName = applicationInfo.loadLabel(packageManager).toString()
val appIcon = applicationInfo.loadIcon(packageManager) ?: continue
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)
}
+2
View File
@@ -19,6 +19,7 @@ mockitoMockitoInline = "5.2.0"
camerax = "1.6.1"
composeBom = "2026.06.01"
reorderable = "3.1.0"
coil = "2.7.0"
[libraries]
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" }
@@ -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-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleViewmodelKtx" }
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }