in the below code:
@Composable
fun Device(contentPadding: PaddingValues, modifier: Modifier = Modifier) {
val vm:DeviceList = viewModel()
vm.getDevices()
var devices = vm.uiState.collectAsState();
LazyColumn(contentPadding = contentPadding) {
items(devices.value) { device -> DeviceItem(device) }
}
}
the vm.getDevices() calls remote API and get devices which are stated in vm.uiState.
it causes infinite UI recomposition as the code clearly shows. vm.getDevices() updates state and new state that is vm.uiState causes UI recomposition. as a result, vm.getDevices() is recalled and updates state again.
I want a recommended solution(best practice).Moreover, I can put some dirty codes for example if/else condition to prevent infinite UI recomposition. However, I think there my be a better clean solution for this kind of problems.
class DeviceList : ViewModel() {
private var deviceListUIState: MutableStateFlow<List<Device>> = MutableStateFlow(
listOf()
)
val uiState
get() = deviceListUIState.asStateFlow()
fun getDevices() {
viewModelScope.launch {
try {
val result: List<Device> = myApi.retrofitService.getDevices()
deviceListUIState.value = result
} catch (e: Exception) {
Log.e(this.toString(), e.message ?: "")
}
}
}
}
As you've found, you shouldn't be requesting any data as part of composition - as explained in the documentation, composition should be side effect free. Besides this infinite recomposition problem, many operations, such as animations, can cause frequent recompositions.
To solve this, you need to move what calls your getDevices out of composition.
There's three ways to do this:
Not the best: 1. Use an effect like LaunchedEffect
val vm:DeviceList = viewModel()
LaunchedEffect(vm) {
vm.getDevices()
}
var devices = vm.uiState.collectAsState();
This moves the call out of composition, but still requires a manual call in your composable code. It also means that every time you come back to this screen (e.g., the screen 'enters composition'), this will get called again instead of using the data you've already loaded.
Better: 2. Load the data once when the ViewModel is created
class DeviceList : ViewModel() {
private var deviceListUIState: MutableStateFlow<List<Device>> = MutableStateFlow(
listOf()
)
val uiState
get() = deviceListUIState.asStateFlow()
init {
// Call getDevices() only once when the ViewModel is created
getDevices()
}
fun getDevices() {
viewModelScope.launch {
try {
val result: List<Device> = myApi.retrofitService.getDevices()
deviceListUIState.value = result
} catch (e: Exception) {
Log.e(this.toString(), e.message ?: "")
}
}
}
}
By calling getDevices in the init of your ViewModel, it gets called just once. This means the logic doesn't have to exist in your composable at all:
// Just by calling this, the loading has already started
val vm:DeviceList = viewModel()
var devices = vm.uiState.collectAsState();
However, this makes testing the ViewModel considerably harder since you can't control exactly when the loading starts.
Best: 3. Make your ViewModel get its data from a cold Flow
Instead of having a separate MutableStateFlow and using viewModelScope.launch to fill it in, use a Flow to encapsulate the loading of your data and then just store the result of that Flow using stateIn:
class DeviceList : ViewModel() {
val uiState = flowOf {
val result: List<Device> = myApi.retrofitService.getDevices()
// We got a valid result, send it to the UI
emit(result)
}.catch { e ->
// Any exceptions the Flow throws, we can catch them here
Log.e(this.toString(), e.message ?: "")
}.stateIn(
viewModelScope, // Save the result so the Flow only gets called once
SharingStarted.Lazily,
initialValue = listOf()
)
}
We still get the same composable as we saw above:
val vm:DeviceList = viewModel()
val devices = vm.uiState.collectAsState();
But now it is very first call to collectAsState in the UI is what kicks off the flowOf. This makes it easy to test the ViewModel (since you can call uiState and collect on it to verify that it returns your value).
This also opens up more flexibility for making the system smarter in the future - if you later add a data layer and a repository that controls both Retrofit data and local data (say, something stored in a database), you could easily replace the flowOf {} with a call to your repository layer, swapping out the source without changing any of the rest of your logic.
The SharingStarted also allows you to use something like SharingStarted.WhileSubscribed(5000L) - if you actually had a Flow of data that changes all the time (say, you had push messages that changed your data while the user is on that screen), that would ensure that your ViewModel isn't doing unnecessary work while your UI isn't visible (i.e., your app is in the background), but instantly restarts once the user reopens your app.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With