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:
1 parent
2a51e7e767
commit
2dcc7781a5
9 files changed
+75
-62
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in new issue
Block a user