Issue with Side-effects - LaunchedEffect and SideEffect in Jetpack Compose - android

Why SideEffect gets called ever-time my composable is invalidated , but same does-not hold true for LaunchedEffect?
sealed class SomeState {
object Error:SomeState()
data class Content(): SomeState
}
class MyViewModel:ViewModel {
internal val response: MutableLiveData<SomeState> by lazy {
MutableLiveData<SomeState>()
}
}
// This is top-level composable, it wont be recomposed ever
#Composable
fun MyComposableScreen(
viewModel:MyVm,
launchActivity:()->Unit
){
val someDialog = remember { mutableStateOf(false) }
MyComposableContent()
GenericErrorDialog(someDialog = someDialog)
when (val state = viewModel.response.observeAsState().value) {
// Query 1
is Content -> LaunchedEffect(Unit) { launchActivity() }
Error -> {
// Query 2
// Gets called everytime this composable gets invalidated, for eg in case of TextField change, compiler is invalidating it.
// But if i change it to LaunchedEffect(Unit), invalidation has no effect,LaunchedEffect only gets called when there is new update to the LiveData. why?
SideEffect { someDialog.value = true}
}
}
}
// This is the content, which can be recomposed in case of email is changed
#Composable
fun MyComposableContent(
onEmailChange:(email) -> Unit,
email:String,
){
TextField(
email = email,
onValueChange = onEmailChange
)
}
I have doubts related to Query 1 and Query 2 both are part of top-level composable which will never be re-composed, but can be invalidated,
when (val state = viewModel.response.observeAsState().value) { // observing to live-data
// Query 1
is Content -> LaunchedEffect(Unit) { launchActivity() }
Error -> {
// Query 2
SideEffect { someDialog.value = true}
}
}
In case of is
Content -> LaunchedEffect(Unit) { launchActivity() }
I believe this should be fine as we want to launch an activity only when LaunchedEffect is part of the first time composition, and it will be only part of the composition if live data state is Content
I faced issue in second scenario,
Error -> {
// Query 2
SideEffect { someDialog.value = true // shows a dialog}
}
If last state of live-data, is Error in viewModel. And every time i make changes in the TextField my top level MyComposableScreen was getting invalidated(not recomposed) by compose compiler, and since last state of live-data was set as error, SideEffect was running every time, which is fine as it should run for every successful composition and re-composition.
But, if i change it from SideEffect to LaunchedEffect(Unit){someDialog.value = true} dialog box was not showing up every time MyComposableScreen was invalidated, thats the desired behavior.
LaunchedEffect(Unit) gets called only if there live-data emits the new state again because of any UI-action.
But, I am not sure regarding the reasoning behind it, why the code inside LaunchedEffect(Unit){someDialog.value = true} does not trigger after composable gets invalidated but the code inside SideEffect gets triggered after composable gets invalidated?
To make it more clear
I understand the difference
SideEffect -> on every successful composition and re-composition, if it's part of it
LaunchedEffect -> when its enters composition and span across re-composition unless the keys are changed.
But in above scenario - this code particularly
#Composable
fun MyTopLevelComposable(viewModel:MyViewModel){
when (val state = viewModel.response.observeAsState().value) { // observing live-data state
is Content -> LaunchedEffect(Unit) { launchActivity() }
Error -> SideEffect { someDialog.value = true}
}
}
It will never get recomposed. The only reason for this composable to be called again could be if compose compiler invalidates the view.
My Query is -> when view/composable gets invalidated
SideEffect {someDialog.value = true} executes, because it will again go through composition not re-composition as viewModel.response(which is live-data) last state was Error
But if change it to LaunchedEffect(Unit) {someDialog.value = true} it doesn't executes again after the composable is invalidated. It only reacts to a new state emitted by the live-data.
Question is why? Invalidate should start composition again, and since it's a composition. not re-composition LaunchedEffect should behave similarly to SideEffect in this scenario, as both are reacting to composition.

In Compose, there is no such thing as invalidating a view.
When you keep your when in the same scope as the state variable, changing the state variable recomposes the contents of when, but when you move it to a separate composable, only updating viewModel.response can recompose it - Compose tries to reduce the number of views to recompose as much as possible.
LaunchedEffect(Unit) will be re-run in two cases:
If it was removed from the view tree during one of the previous recompositions and then added again. For example, if you wrap LaunchedEffect in if and the condition is first false and then true. Or, in your case, if when will choose Error -> after is Content ->, this will also remove LaunchedEffect from the view tree.
If one of the keys passed to LaunchedEffect has changed.
It looks like your problem is that LaunchedEffect does not restart when new content value come in, to solve this, you need to pass this value as key in LaunchedEffect, instead of Unit:
LaunchedEffect(state) { launchActivity() }

They just behave differently for a reason. Have a look at the documentation.
For LaunchEffect, it only gets call the first time because you've specified Unit for its key. If you'd like it to trigger at a specific recomposition, use the state value you'd like to observe. Each time it changes, the LaunchEffect will be triggered.

Related

Lambda function used as input argument causes recomposition

Consider snippet below
fun doSomething(){
}
#Composable
fun A() {
Column() {
val counter = remember { mutableStateOf(0) }
B {
doSomething()
}
Button(onClick = { counter.value += 1 }) {
Text("Click me 2")
}
Text(text = "count: ${counter.value}")
}
}
#Composable
fun B(onClick: () -> Unit) {
Button(onClick = onClick) {
Text("click me")
}
}
Now when pressing "click me 2" button the B compose function will get recomposed although nothing inside it is got changed.
Clarification: doSomething is for demonstration purposes. If you insist on having a practical example you can consider below usage of B:
B{
coroutinScope.launch{
bottomSheetState.collapse()
doSomething()
}
}
My questions:
Why this lamda function causes recomposition
Best ways to fix it
My understanding of this problem
From compose compiler report I can see B is an skippable function and the input onClick is stable. At first I though its because lambda function is recreated on every recomposition of A and it is different to previous one. And this difference cause recomposition of B. But it's not true because if I use something else inside the lambda function, like changing a state, it won't cause recomposition.
My solutions
use delegates if possible. Like viewmode::doSomething or ::doSomething. Unfortunately its not always possible.
Use lambda function inside remember:
val action = remember{
{
doSomething()
}
}
B(action)
It seems ugly =)
3. Combinations of above.
When you click the Button "Click me 2" the A composable is recomposed because of Text(text = "count: ${counter.value}"). It happens because it recompose the scope that are reading the values that can change.
If you are using something like:
B {
Log.i("TAG","xxxx")
}
the B composable is NOT recomposed clicking the Button "Click me 2".
If you are using
B{
coroutinScope.launch{
Log.i("TAG","xxxx")
}
}
the B composable is recomposed.
When a State is read it triggers recomposition in nearest scope. And a scope is a function that is not marked with inline and returns Unit.
To use a coroutinScope you have to use rememberCoroutineScope that is a composable inline function. The the body of inline composable functions are simply copied into their call sites, such functions do not get their own recompose scopes.
To avoid it you can use:
B {
Log.i("TAG","xxxx")
}
and
#Composable
fun B(onClick: () -> Unit) {
val scope = rememberCoroutineScope()
Button(
onClick = {
scope.launch {
onClick()
}
}
) {
Text(
"click me ",
)
}
}
Sources and credits:
Thracian's answer: Jetpack Compose Smart Recomposition
What is “donut-hole skipping” in Jetpack Compose? post: https://www.jetpackcompose.app/articles/donut-hole-skipping-in-jetpack-compose
scoped recomposition: https://dev.to/zachklipp/scoped-recomposition-jetpack-compose-what-happens-when-state-changes-l78
You can use the LogCompositions composable described in the 2nd post to check the recomposition in your code.
Generally speaking, if you are using a property inside a lambda function that is unstable, it causes the child compose function unskippable and thus gets recomposed every time its parent gets recomposed. This is not something easily visible and you need to be careful with it. For example, the bellow code will cause B to get recomposed because coroutinScope is an unstable property and we are using it as an indirect input to our lambda function.
fun A(){
...
val coroutinScope = rememberCoroutineScope()
B{
coroutineScope.launch {
doSomething()
}
}
}
To bypass this you need to use remember around your lambda or delegation (:: operator). There is a note inside this video about it. around 40:05
There are many other parameters that are unstable like context. To figure them out you need to use compose compiler report.
Here is a good explanation about the why: https://multithreaded.stitchfix.com/blog/2022/08/05/jetpack-compose-recomposition/

Jetpack Compose do on compose, but not on recomposition - track ContentViewed

I'm trying to implement some kind of LaunchedEffectOnce as I want to track a ContentViewed event. So my requirement is that every time the user sees the content provided by the composable, an event should get tracked.
Here is some example code of my problem:
#Composable
fun MyScreen(viewModel: MyViewModel = get()){
val items by viewModel.itemsToDisplay.collectAsState(initial = emptyList())
ItemList(items)
// when the UI is displayed, the VM should track an event (only once)
LaunchedEffectOnce { viewModel.trackContentViewed() }
}
#Composable
private fun LaunchedEffectOnce(doOnce: () -> Unit) {
var wasExecuted by rememberSaveable { mutableStateOf(false) }
if (!wasExecuted) {
LaunchedEffect(key1 = rememberUpdatedState(newValue = executed)) {
doOnce()
wasExecuted = true
}
}
}
This code is doing do the following:
Tracks event when MyScreen is composed
Does NOT track when the user enters a list item screen and navigates back to MyScreen
Does NOT track the event on recomposition (like orientation change)
But what I wan't to achieve is the following:
Tracks event when MyScreen is composed
Tracks when the user enters a list item screen and navigates back to MyScreen
Does NOT track the event on recomposition (like orientation change)
My ViewModel looks like that:
class MyViewModel() : ViewModel() {
val itemsToDisplay: Flow<List<Item>> = GetItemsUseCase()
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(), replay = 1)
val contentTracking: Flow<Tracking?> = GetTrackingUseCase()
.distinctUntilChanged { old, new -> old === new }
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(), replay = 1)
fun trackContentViewed(){
// track last element in contentTracking
}
}
I really hope someone can help me and can explain what I'm doing wrong here. Thanks in advance!
Assuming the following are true
your view model is scoped to the Fragment in which MyScreen enters composition
your composables leave the composition when you navigate to an item screen and re-enter composition when you navigate back
then you can simply track inside the view model itself whether specific content was already viewed in this view model's scope. Then when you navigate to any of the items screens you reset that "tracking state".
If you need to track only a single element of content then just a Boolean variable would be enough, but in case you need to track more than one element, you can use either a HashSet or a mutableSetOf (which returns a LinkedHashSet instead). Then when you navigate to any of the item screen you reset that variable or clear the Set.
Your VM code would then change to
class MyViewModel() : ViewModel() {
// ... you existing code remains unchanged
private var viewedContent = mutableSetOf<Any>()
fun trackContentViewed(key: Any){
if (viewedContent.add(key)) {
// track last element in contentTracking
Log.d("Example", "Key $key tracked for 'first time'")
} else {
// content already viewed for this key
Log.d("Example", "Key $key already tracked before")
}
}
fun clearTrackedContent() {
viewedContent.clear()
}
}
and the MyScreen composable would change to
#Composable
fun MyScreen(viewModel: MyViewModel = get()){
// ... you existing code remains unchanged
// Every time this UI enters the composition (but not on recomposition)
// the VM will be notified
LaunchedEffect(Unit) {
viewModel.trackContentViewed(key = "MyScreen") // or some other key
}
}
Where you start the navigation to an item screen (probably in some onClick handler on items) you would call viewmodel.clearTrackedContent().
Since (1) is true when ViewModels are requested inside a Fragment/Activity and if (2) is also true in your case, then the VM instance will survive configuration changes (orientation change, language change...) and the Set will take care of tracking.
If (2) is not true in your case, then you have two options:
if at least recomposition happens when navigating back, replace LaunchedEffect with SideEffect { viewModel.trackContentViewed(key = "MyScreen") }
if your composables are not even recomposed then you will have to call viewModel.trackContentViewed also when navigating back.

Redundant recomposition happenning in my layout. Why does it recompose even though inputs haven't changed?

I have a SnapshotStateMap that I use to track updates in my layout, this map is stored in a viewmodel.
This the site call:
val roundState = viewModel.roundState
for (index in 0 until attempts) {
val state = roundState[index] ?: WordState.Empty
Row {
RoundWord(state, letters)
}
}
In my program there are changes to only one item at the time, so basically my train of thought is:
I add a new state or update the old in map -> I pass it to RoundWord -> If there is no state for index I pass in empty state -> RoundWord Composable relies on state to display the needed UI.
Here is the body of RoundWord
#Composable
private fun RoundWord(
state: WordState,
letters: Int,
) {
when (state) {
is WordState.Progress -> ProgressWord(letters)
is WordState.Empty -> WordCells(letters)
is WordState.Resolved -> WordCells(letters) { WiPLetter(state.value.word[it]) }
}
}
From what I understand if there is no state in roundState map for a given index I provide Empty state that is defined as an object in a sealed interface hierarchy. Same object -> no recomposition. But for some reason it recomposes every time. I have been at this for a few days now and despite going though tons of documentation I can't see what I am missing here. Why does this recomposition happens for empty state?

MutableLiveData observe method runs but doesn't update Jetpack Compose list

so I wasted a good couple of days on this, and my deadline's tomorrow
basically, I have a mutableLiveData var which is a companion object,
and when the data is updated it calls the observe function inside the grid.
The observe function gets called fine, it does the print statements which you can see,
but it completely skips everything in the compose "items()" method.
Can someone please help me? I could not find anything useful online...
#ExperimentalFoundationApi
#Composable
fun ItemsGrid() {
LazyVerticalGrid(
cells = GridCells.Fixed(3),
contentPadding = PaddingValues(8.dp)
) {
mProductsByCategoryID.observe(viewLifecycleOwner,
{ products ->
println("DATA CHANGGED ${products[0].name}")
println(mProductsByCategoryID.value?.get(0)?.name)
items(products) {
println("INSIDE ITEMS for products ${it.name}") // never gets inside of here except for the first time the view loads
DemoCards(demo = it)
}
}
)
}
}
In Compose you shouldn't observe LiveData directly inside a #Composable, but observe it as State. Instead of callbacks to update UI, now we have Recomposition (#Composable function getting called automatically over and over again every time an observed value in the #Composable function changes).
More info here
Your code should look more like:
#Composable
fun ItemsGrid() {
val productsByCategory = mProductsByCategoryID.observeAsState()
LazyVerticalGrid(
cells = GridCells.Fixed(3),
contentPadding = PaddingValues(8.dp)
) {
//here goes code that does what your callback was doing before.
//when val productsByCategory.value changes, ItemsGrid()
//gets recomposed (called again) and this code will execute
}
}

Why recomposition happens when call ViewModel in a callback?

I completely confused with compose conception.
I have a code
#Composable
fun HomeScreen(viewModel: HomeViewModel = getViewModel()) {
Scaffold {
val isTimeEnable by viewModel.isTimerEnable.observeAsState()
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.background(Color.Black),
) {
Switch(
checked = isTimeEnable ?: false,
onCheckedChange = {
viewModel.setTimerEnable(it)
},
)
Clock(viewModel.timeSelected.value!!) {
viewModel.setTime(it)
}
}
}
}
#Composable
fun Clock(date: Long, selectTime: (date: Date) -> Unit) {
NumberClock(Date(date)) {
val time = SimpleDateFormat("HH:mm", Locale.ROOT).format(it)
Timber.d("Selected time: time")
selectTime(it)
}
}
Why Clock widget recomposes when I tap switch. If I remove line selectTime(it) from Clock widget callback recomposition doesn't happen.
Compose version: 1.0.2
This is because in terms of compose, you are creating a new selectTime lambda every time, so recomposition is necessary. If you pass setTime function as a reference, compose will know that it is the same function, so no recomposition is needed:
Clock(viewModel.timeSelected.value!!, viewModel::setTime)
Alternatively if you have more complex handler, you can remember it. Double brackets ({{ }}) are critical here, because you need to remember the lambda.
Clock(
date = viewModel.timeSelected.value!!,
selectTime = remember(viewModel) {
{
viewModel.setTimerEnable(it)
}
}
)
I know it looks kind of strange, you can use rememberLambda which will make your code more readable:
selectTime = rememberLambda(viewModel) {
viewModel.setTimerEnable(it)
}
Note that you need to pass all values that may change as keys, so remember will be recalculated on demand.
In general, recomposition is not a bad thing. Of course, if you can decrease it, you should do that, but your code should work fine even if it is recomposed many times. For example, you should not do heavy calculations right inside composable to do this, but instead use side effects.
So if recomposing Clock causes weird UI effects, there is probably something wrong with your NumberClock that cannot survive the recomposition. If so, please add the NumberClock code to your question for advice on how to improve it.
This is the intended behaviour. You are clearly modifying the isTimeEnabled field inside your viewmodel when the user toggles the switch (by calling vm.setTimeenabled). Now, it is apparent that the isTimeEnabled in your viewmodel is a LiveData instance, and you are referring to that instance from within your Composable by calling observeAsState on it. Hence, when you modify the value from the switch's onValueChange, you are essentially modifying the state that the Composable depends on. Hence, to render the updated state, a recomposition is triggered

Categories

Resources