I'm developing an Android app that gathers a list of currently installed apps, both system & user-installed, and displays them on screen via LazyColumn using a Card composable. And when the user taps any Card from this list, the view model will keep track of it, until the user is satisfied with their picks.
By the time I either select my second application or deselect my previous app, the app freezes, followed by a barrage of Logcat messages, as seen here (this is on my Samsung Galaxy Note 9 running iodeOS 4.23, Android 13):
2025-08-15 14:43:04.690 21721-21721 SelectAppsViewModel com.example.appinfotester E Selected app: AppInfo(label=Brave, packageName=com.brave.browser)
2025-08-15 14:43:04.690 21721-21721 SelectAppsViewModel com.example.appinfotester E Current count: [AppInfo(label=Brave, packageName=com.brave.browser)]
2025-08-15 14:43:22.994 21721-21721 SelectAppsViewModel com.example.appinfotester E Selected app: AppInfo(label=Discord, packageName=com.discord)
2025-08-15 14:43:22.994 21721-21721 SelectAppsViewModel com.example.appinfotester E Current count: [AppInfo(label=Brave, packageName=com.brave.browser), AppInfo(label=Discord, packageName=com.discord)]
2025-08-15 14:43:24.722 21721-21758 OpenGLRenderer com.example.appinfotester I Davey! duration=1705ms; Flags=0, FrameTimelineVsyncId=137348124, IntendedVsync=205704950715283, Vsync=205704950715283, InputEventId=92854293, HandleInputStart=205704951143444, AnimationStart=205704951146905, PerformTraversalsStart=205706634503058, DrawStart=205706634608519, FrameDeadline=205704967381949, FrameInterval=205704951133136, FrameStartTime=16730205, SyncQueued=205706640309212, SyncStart=205706640371327, IssueDrawCommandsStart=205706648072635, SwapBuffers=205706652782173, FrameCompleted=205706656262635, DequeueBufferDuration=20269, QueueBufferDuration=198115, GpuCompleted=205706656262635, SwapBuffersCompleted=205706654275212, DisplayPresentTime=205701507169292, CommandSubmissionCompleted=205706652782173,
2025-08-15 14:43:27.132 21721-21736 OpenGLRenderer com.example.appinfotester I Davey! duration=2428ms; Flags=0, FrameTimelineVsyncId=137348265, IntendedVsync=205706635577804, Vsync=205706652244470, InputEventId=0, HandleInputStart=205706655168866, AnimationStart=205706655170866, PerformTraversalsStart=205709043922172, DrawStart=205709044032749, FrameDeadline=205706673862859, FrameInterval=205706655158635, FrameStartTime=16709061, SyncQueued=205709056304018, SyncStart=205709056508325, IssueDrawCommandsStart=205709060475095, SwapBuffers=205709061317287, FrameCompleted=205709063887364, DequeueBufferDuration=18807, QueueBufferDuration=180500, GpuCompleted=205709063887364, SwapBuffersCompleted=205709061787595, DisplayPresentTime=205707071139481, CommandSubmissionCompleted=205709061317287,
2025-08-15 14:43:30.545 21721-21731 e.appinfotester com.example.appinfotester I NativeAlloc concurrent copying GC freed 12141(511KB) AllocSpace objects, 0(0B) LOS objects, 79% free, 6171KB/30MB, paused 126us,81us total 115.597ms
2025-08-15 14:43:31.277 21721-21737 OpenGLRenderer com.example.appinfotester I Davey! duration=1784ms; Flags=0, FrameTimelineVsyncId=137350734, IntendedVsync=205711433430105, Vsync=205711433430105, InputEventId=0, HandleInputStart=205711433927747, AnimationStart=205711433930439, PerformTraversalsStart=205713193868438, DrawStart=205713193951053, FrameDeadline=205711999260138, FrameInterval=205711433919862, FrameStartTime=16711093, SyncQueued=205713195881130, SyncStart=205713195949938, IssueDrawCommandsStart=205713209776207, SwapBuffers=205713216270092, FrameCompleted=205713217911322, DequeueBufferDuration=45000, QueueBufferDuration=166500, GpuCompleted=205713217911322, SwapBuffersCompleted=205713216808246, DisplayPresentTime=205710012114863, CommandSubmissionCompleted=205713216270092,
2025-08-15 14:43:32.928 21721-21737 OpenGLRenderer com.example.appinfotester I Davey! duration=1656ms; Flags=0, FrameTimelineVsyncId=137351077, IntendedVsync=205713204315733, Vsync=205713204315733, InputEventId=0, HandleInputStart=205713213289630, AnimationStart=205713213291015, PerformTraversalsStart=205714848140167, DrawStart=205714848227514, FrameDeadline=205713238183722, FrameInterval=205713213284169, FrameStartTime=16705327, SyncQueued=205714850149129, SyncStart=205714850216975, IssueDrawCommandsStart=205714855743552, SwapBuffers=205714858393398, FrameCompleted=205714860531783, DequeueBufferDuration=50230, QueueBufferDuration=589346, GpuCompleted=205714860531783, SwapBuffersCompleted=205714860248091, DisplayPresentTime=205710028669517, CommandSubmissionCompleted=205714858393398,
2025-08-15 14:43:34.534 21721-21737 OpenGLRenderer com.example.appinfotester I Davey! duration=1605ms; Flags=0, FrameTimelineVsyncId=137351159, IntendedVsync=205714857786844, Vsync=205714857786844, InputEventId=0, HandleInputStart=205714861618783, AnimationStart=205714861625860, PerformTraversalsStart=205716454996051, DrawStart=205716455072897, FrameDeadline=205714891515099, FrameInterval=205714861595552, FrameStartTime=16704971, SyncQueued=205716456863436, SyncStart=205716456924512, IssueDrawCommandsStart=205716460508589, SwapBuffers=205716461368705, FrameCompleted=205716463368782, DequeueBufferDuration=18923, QueueBufferDuration=198193, GpuCompleted=205716463368782, SwapBuffersCompleted=205716461850551, DisplayPresentTime=205710045363325, CommandSubmissionCompleted=205716461368705,
2025-08-15 14:43:34.573 21721-21731 e.appinfotester com.example.appinfotester I NativeAlloc concurrent copying GC freed 29284(1083KB) AllocSpace objects, 2(40KB) LOS objects, 78% free, 6892KB/30MB, paused 118us,29us total 106.699ms
2025-08-15 14:43:35.094 21721-21731 e.appinfotester com.example.appinfotester I NativeAlloc concurrent copying GC freed 26424(1302KB) AllocSpace objects, 0(0B) LOS objects, 79% free, 6149KB/30MB, paused 119us,39us total 144.333ms
The messages are even worse when running it on my Google Pixel 7 (iodeOS 6.6, Android 15)
2025-08-15 14:57:07.502 18408-18408 SelectAppsViewModel com.example.appinfotester E Selected app: AppInfo(label=3 Button Navigation Bar, packageName=com.android.internal.systemui.navbar.threebutton)
2025-08-15 14:57:07.502 18408-18408 SelectAppsViewModel com.example.appinfotester E Current count: [AppInfo(label=3 Button Navigation Bar, packageName=com.android.internal.systemui.navbar.threebutton)]
2025-08-15 14:57:07.587 18408-18414 e.appinfotester com.example.appinfotester W Cleared Reference was only reachable from finalizer (only reported once)
2025-08-15 14:57:08.212 18408-18479 ProfileInstaller com.example.appinfotester D Installing profile for com.example.appinfotester
2025-08-15 14:57:21.239 18408-18408 SelectAppsViewModel com.example.appinfotester E Selected app: AppInfo(label=AccuBattery, packageName=com.digibites.accubattery)
2025-08-15 14:57:21.239 18408-18408 SelectAppsViewModel com.example.appinfotester E Current count: [AppInfo(label=3 Button Navigation Bar, packageName=com.android.internal.systemui.navbar.threebutton), AppInfo(label=AccuBattery, packageName=com.digibites.accubattery)]
2025-08-15 14:57:21.278 18408-18408 e.appinfotester com.example.appinfotester I AssetManager2(0xb40000773941bdf8) locale list changing from [] to [en-US]
2025-08-15 14:57:21.286 18408-18408 HWUI com.example.appinfotester W Image decoding logging dropped!
2025-08-15 14:57:21.291 18408-18408 HWUI com.example.appinfotester W Image decoding logging dropped!
2025-08-15 14:57:21.297 18408-18408 e.appinfotester com.example.appinfotester I AssetManager2(0xb40000773941da18) locale list changing from [] to [en-US]
2025-08-15 14:57:21.310 18408-18408 e.appinfotester com.example.appinfotester I AssetManager2(0xb400007739413798) locale list changing from [] to [en-US]
2025-08-15 14:57:21.314 18408-18408 e.appinfotester com.example.appinfotester I AssetManager2(0xb40000773941ecd8) locale list changing from [] to [en-US]
2025-08-15 14:57:21.320 18408-18408 e.appinfotester com.example.appinfotester I AssetManager2(0xb40000773941cd98) locale list changing from [] to [en-US]
2025-08-15 14:57:21.329 18408-18408 e.appinfotester com.example.appinfotester I AssetManager2(0xb4000077394208f8) locale list changing from [] to [en-US]
2025-08-15 14:57:21.332 18408-18408 e.appinfotester com.example.appinfotester I AssetManager2(0xb40000773941f958) locale list changing from [] to [en-US]
2025-08-15 14:57:21.336 18408-18408 e.appinfotester com.example.appinfotester I AssetManager2(0xb4000077394202b8) locale list changing from [] to [en-US]
2025-08-15 14:57:21.339 18408-18408 HWUI com.example.appinfotester W Image decoding logging dropped!
2025-08-15 14:57:21.341 18408-18408 HWUI com.example.appinfotester W Image decoding logging dropped!
2025-08-15 14:57:21.345 18408-18408 e.appinfotester com.example.appinfotester I AssetManager2(0xb40000773941d3d8) locale list changing from [] to [en-US]
2025-08-15 14:57:21.349 18408-18408 e.appinfotester com.example.appinfotester I AssetManager2(0xb40000773941ae58) locale list changing from [] to [en-US]
2025-08-15 14:57:21.359 18408-18408 e.appinfotester com.example.appinfotester I AssetManager2(0xb40000773941ca78) locale list changing from [] to [en-US]
2025-08-15 14:57:21.361 18408-18408 HWUI com.example.appinfotester W Image decoding logging dropped!
2025-08-15 14:57:21.364 18408-18408 HWUI com.example.appinfotester W Image decoding logging dropped!
2025-08-15 14:57:21.371 18408-18408 e.appinfotester com.example.appinfotester I AssetManager2(0xb40000773941b178) locale list changing from [] to [en-US]
2025-08-15 14:57:21.377 18408-18408 e.appinfotester com.example.appinfotester I AssetManager2(0xb400007739414d78) locale list changing from [] to [en-US]
2025-08-15 14:57:21.378 18408-18408 HWUI com.example.appinfotester W Image decoding logging dropped!
As far as what I've tried to resolve this issue
My only real suspicion is with LocalContext.current, which I use to get the context instance to get an instance of PackageManger. However, I can't tell if that is the case or not.
// This is the data to be saved in-app later using Room database
data class AppInfo(
val label: String,
val packageName: String
)
// Used a temporary data type to hold info of ApplicationInfo
data class ApplicationInfoWrapper(
val label: String,
val icon: ImageBitmap,
val packageName: String
)
fun ApplicationInfoWrapper.toAppInfo() = AppInfo(label = label, packageName = packageName)
// Ui State for use by the view model
data class SelectAppsUiState(
val selectedApps: MutableSet<AppInfo> = mutableSetOf(),
val installedApps: List<ApplicationInfoWrapper> = emptyList(),
val isLoading: Boolean = false
)
class SelectAppsViewModel(val packageManager: PackageManager): ViewModel() {
// StateFlow for UI state
private val _uiState = MutableStateFlow(SelectAppsUiState())
val uiState: StateFlow<SelectAppsUiState> = _uiState.asStateFlow()
// Fetch installed apps
init {
fetchInstalledApps()
}
// Idea is to do get the list of installed apps in the background
// Using coroutines is recommended, but currently this is my implementation
private fun fetchInstalledApps() {
_uiState.update { currentState -> currentState.copy(isLoading = true) }
val installedApps: List<ApplicationInfoWrapper> = packageManager.getInstalledApplications(
PackageManager.GET_META_DATA)
.mapNotNull { application -> ApplicationInfoWrapper(
packageName = application.packageName,
label = packageManager.getApplicationLabel(application).toString(),
icon = packageManager.getApplicationIcon(application).toBitmap()
.asImageBitmap()
)
}.sortedBy { appInfo -> appInfo.label.lowercase() }
_uiState.update { currentState ->
currentState.copy(
installedApps = installedApps,
isLoading = false)
}
}
// Add the selected app from screen to the view model list
fun selectApp(app: AppInfo) {
val isAppSelected = _uiState.value.selectedApps.contains(app)
// Add the app to the list if it has no entry yet; otherwise, proceed with its inverse operation
if (!isAppSelected) {
_uiState.update { currentState ->
currentState.copy(
selectedApps = currentState.selectedApps.toMutableSet().apply {
add(app)
}
)
}
Log.e("SelectAppsViewModel", "Selected app: $app")
Log.e("SelectAppsViewModel", "Current count: ${_uiState.value.selectedApps}")
}
else {
_uiState.update { currentState ->
currentState.copy(
selectedApps = currentState.selectedApps.toMutableSet().apply {
remove(app)
}
)
}
Log.e("SelectAppsViewModel", "Deselected app: $app")
Log.e("SelectAppsViewModel", "Current count: ${_uiState.value.selectedApps}")
}
}
}
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
val context = LocalContext.current
SelectAppsScreen(
context = context,
modifier = Modifier.padding(innerPadding)
)
}
@Composable
fun SelectAppsScreen(
modifier: Modifier = Modifier,
context: Context,
selectAppsViewModel: SelectAppsViewModel = SelectAppsViewModel(context.packageManager)
) {
// View Model
val selectAppsUiState by selectAppsViewModel.uiState.collectAsState()
val selectedAppsCount = selectAppsUiState.selectedApps.size
val selectedAppsCountText: String = when (selectedAppsCount) {
0 -> "No app has been selected"
1 -> "1 app has been selected"
else -> "$selectedAppsCount apps have been selected"
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier.fillMaxWidth()
) {
Text(
text = "Select Apps",
style = MaterialTheme.typography.headlineLarge,
modifier = Modifier.padding(16.dp)
)
Text(
text = selectedAppsCountText
)
Spacer(modifier = Modifier.height(32.dp))
if (selectAppsUiState.isLoading) {
Text(text = "Please Wait...")
} else {
LazyColumn(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.padding(8.dp)
.weight(1f)
) {
items(items = selectAppsUiState.installedApps) { applicationInfo ->
ApplicationInfoCard(
icon = applicationInfo.icon,
label = applicationInfo.label,
onClick = {selectAppsViewModel.selectApp(applicationInfo.toAppInfo())}
)
}
}
}
}
}
@Composable
fun ApplicationInfoCard(
icon: ImageBitmap,
label: String,
modifier: Modifier = Modifier,
onClick: () -> Unit = { },
) {
Card(
onClick = onClick,
modifier = modifier.padding(8.dp),
) {
// Fill the rest from here
}
}
You must never directly instantiate a view model in a composable.
This is the culprit:
@Composable
fun SelectAppsScreen(
modifier: Modifier = Modifier,
context: Context,
selectAppsViewModel: SelectAppsViewModel = SelectAppsViewModel(context.packageManager)
) {
// ...
}
Whenever SelectAppsScreen is recomposed, SelectAppsViewModel(context.packageManager)
is executed again which creates a new view model instance. That new instance's init block is then executed, fetching all installed apps on the main thread (which you should not do either), blocking the UI which explains the freezes you experience. Furthermore, the entire uiState is recreated from scratch, so you will also start out with an empty set of selected apps, everything the user has selected before is lost.
You cannot really control when recompositions happen. The Compose runtime tries to minimize that as much as possible, but you, as the developer, should write code that works regardless of when or how often recompositions occur. You do not want to create a new view model on each recomposition, that's why directly calling the constructor SelectAppsViewModel(context.packageManager)
is not an option.
The alternative is to use a factory function that creates the view model when it is first called, and on successive calls simply returns the same view model instance. Fortunately, that already exists:
selectAppsViewModel: SelectAppsViewModel = viewModel {
SelectAppsViewModel(context.packageManager)
}
In cases where you don't have a parameter for the view model, you can simply call viewModel()
and omit the lambda.
Your issue should be fixed with this.
There are several other issues in your code that you should fix, though:
As mentioned above, retrieving the installed packages shouldn't be done on the main thread. Although that only happens once now, it still freezes the ui a little when the view model is created.
That is easily fixed, though. First, make fetchInstalledApps()
a suspend function, then you can move it with withContext
to the IO dispatcher:
private suspend fun fetchInstalledApps() = withContext(Dispatchers.IO) {
// ...
}
Now you just need to create a coroutine in the init block so you can call that suspend function:
init {
viewModelScope.launch {
fetchInstalledApps()
}
}
Now you will actually see the "Please Wait..." placeholder that was previously blocked from being displayed.
Never use mutable properties in your ui state:
data class SelectAppsUiState(
val selectedApps: MutableSet<AppInfo> = mutableSetOf(),
// ...
)
You do not even rely on the Set being mutable (which is good because it wouldn't work as expected), but the set shouldn't be mutable in the first place:
val selectedApps: Set<AppInfo> = emptySet(),
And related, your selectApp
function can be simplified a lot. Kotlin has a very elegant syntax to create a new set (or list) with just an element added or removed:
fun selectApp(app: AppInfo) {
_uiState.update { currentState ->
if (currentState.selectedApps.contains(app))
currentState.copy(
selectedApps = currentState.selectedApps - app,
)
else
currentState.copy(
selectedApps = currentState.selectedApps + app,
)
}
}
This also fixes a potential race condition because you checked if the app already exists outside of the update
lambda. It needs to be inside, though, otherwise another thread would be able to change that fact in between. Inside the update
lambda is safe because that is executed atomically.