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.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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
Reference in new issue
Block a user