A Deep Dive into One-Time Events in Jetpack Compose

Introduction

After using Jetpack Compose and MVI architecture development for a period of time, we often deal with one-time events. This is a very common problem in Android development. So what exactly is a one-time event? What about sexual events?

For example: after we execute a certain piece of logic code in ViewModel, we need to send an event to UI and the event will only be executed once on UI. The general ways people deal with it are: Channel (channel), SharedFlow (shared flow), but have you ever thought that these two methods can really guarantee one-time processing? Can events be received by UI without being lost?

Example

Let’s use a classic login scenario to analyze the advantages and disadvantages of these two methods and introduce the third solution.

First, the sealed class of UiEvent is declared in MainViewModel, which is used to send one-time events to UI. In the method of calling simulated login, modify The current status is logging in, and then a one-time event of navigating to the personal page is sent to UI after a delay of 3 seconds.

class MainViewModel : ViewModel() {<!-- -->
//Channel
private val _channel = Channel<UiEvent>()
val channel = _channel.receiveAsFlow()
\t
//SharedFlow
private val _sharedFlow = MutableSharedFlow<UiEvent>( )
val sharedFlow = _sharedFlow.asSharedFlow()
\t
    //state
var state by mutableStateOf(LoginState())
private set
\t
\t
fun login() {<!-- -->
//Simulate the login process
viewModelScope.launch {<!-- -->
state = state.copy(isLoading = true)
delay(3000L)
\t\t\t
//Send event to UI
_channel.send(UiEvent.NavigateToProfile)
//_sharedFlow.emit(UiEvent.NavigateToProfile)
\t\t\t
state = state.copy(isLoading = false)
}
\t
}

\t
sealed class UiEvent {<!-- -->
object NavigateToProfile : UiEvent()
}
    
    data class LoginState(
      val isLoading: Boolean = false,
)
}

Create two Screen in MainActivity. When the login button is clicked, the progress indicator will display the loading according to the current status. Monitor the life in LaunchedEffect Cycle changes, and calls the repeatOnLifecycle method, which can safely collect streams during the life cycle. If the application enters the background, this method will not be called. When the life cycle is STARTED will re-collect the stream.

setContent {<!-- -->
OneOffEventTheme {<!-- -->
        val navController = rememberNavController()

NavHost(
navController = navController,
startDestination = "login"
) {<!-- -->
            composable("login") {<!-- -->
val viewModel = viewModel<MainViewModel>()
val state = viewModel.state
               
                val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(lifecycleOwner.lifecycle) {<!-- -->
                    lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {<!-- -->
viewModel.channel.collect {<!-- --> event ->
                        when(event){<!-- -->
                                is UiEvent.NavigateToProfile->{<!-- -->
                                    navController.navigate("profile")
                                }
                            }
}
                    }
                }
            
      
                LoginScreen(state = state, viewModel = viewModel)
            }
            
            composable("profile") {<!-- -->
                  ProfileScreen()
            }
        }
    }
}


//------------------------------------------------ --------------------------
@Composable
funLoginScreen(
      state: LoginState,
      viewModel: MainViewModel
) {<!-- -->
      Column(
            modifier = Modifier.fillMaxSize(),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally
      ) {<!-- -->
            Text(text = "Currently using one-time events as state")
            Button(onClick = {<!-- -->
                  viewModel.login()
            }) {<!-- -->
                  Text(text = "Login")
            }
            if (state.isLoading) {<!-- -->
                  CircularProgressIndicator()
            }
      }
}


@Composable
fun ProfileScreen() {<!-- -->
      Box(
            modifier = Modifier.fillMaxSize(),
            contentAlignment = Alignment.Center
      ) {<!-- -->
            Text(text = "Personal Page")
      }
}

Let’s take a look at the running effect first. As expected, you can navigate to the personal page normally. Some people may say that you shouldn’t be able to return to the login page after logging in! Don’t worry, I didn’t do the back stack processing here, just to lay the groundwork. Note: The effects of Channel and SharedFlow here are the same!

Analyze the problem

First, we found the problem in the two pictures below. After clicking the button, the user clicked the Home key. After re-entering the application, it did not navigate to the personal page as expected. At this time, the application entered the background. As we said earlier When it comes to repeatOnLifecycle, it ensures that the stream will not be collected when the application is in the background, and will be re-collected starting from the life cycle of STARTED. The answer is readily apparent. The collector no longer works when the application is in the background. When returning, the new collector does not receive any streams, indicating that the streams are lost.

Observe carefully that Channel does not lose the stream like SharedFlow. The reason is that Channel has a buffer function, and the stream will enter the buffer after it is emitted. Wait for the collector, so even when returning to the app, the new collector will immediately collect the stream and navigate to the profile page.

Since SharedFlow does not have the concept of a buffer, when the application enters the background, the collector no longer works, and when it returns again, the flow has been lost. However, SharedFlow also has a type cache function replay. The default is 0 and the cache value needs to be set manually.

//If replay is set, SharedFlow will store the flow and will receive the past 3 flow values when the new collector collects it.
private val _sharedFlow = MutableSharedFlow<UiEvent>(
replay=3
)
val sharedFlow = _sharedFlow.asSharedFlow()

Some people abroad have mentioned that one-off events are an anti-pattern, and it is recommended to use states to handle one-time events: ViewModel: One-off event antipatterns

fun login() {<!-- -->
//Simulate the login process
viewModelScope.launch {<!-- -->
state = state.copy(isLoading = true)
delay(3000L)
\t
        //Use status and let ui monitor isLoggedIn
state = state.copy(isLoading = false,isLoggedIn = true)
}
LaunchedEffect(state.isLoggedIn){<!-- -->
if (state.isLoggedIn){<!-- -->
navController.navigate("profile")
}
}

We can see that using state processing can also meet our needs. At this time, recall the foreshadowing above. You will find that I returned to the back stack but failed and stayed on the personal page. Because the state is persistent and there is no loss. The reason for this is that LaunchedEffect will be triggered when returning to the login page, and isLoggedIn is always true after logging in and has not been modified, so it stays On the personal page.

Solution: Reset the status. After logging in again, you need to set isLoggedIn to false.

//ViewModel
fun resetState(){<!-- -->
      state = state.copy(isLoggedIn = false)
}

//MainActivity
LaunchedEffect(state.isLoggedIn){<!-- -->
if (state.isLoggedIn){<!-- -->
navController.navigate("profile")
viewModel.resetState()
}
}

When the application returns to the STARTED state, the stream will be collected again. If it is in the DESTROYED state, the ViewModel will still emit the stream to the UI , meaning the stream will be lost. Here is an example: launch 1000 streams, and then continuously rotate the screen during the launch process to make Activity in the onDestory state to see if the stream will be lost.

fun login() {<!-- -->
    viewModelScope.launch {<!-- -->
          repeat(1000){<!-- -->
                delay(3L)
                _channel.send(UiEvent.CounterEvent(it))//Emit a count stream
          }
    }
}

sealed class UiEvent {<!-- -->
      object NavigateToProfile : UiEvent()
      data class CounterEvent(val count:Int) : UiEvent()
}
LaunchedEffect(lifecycleOwner.lifecycle) {<!-- -->
    lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {<!-- -->
      viewModel.channel.collect {<!-- --> event ->
        when(event){<!-- -->
                ...
is MainViewModel.UiEvent.CounterEvent->{<!-- -->
Log.d("Count", "COUNT:${<!-- -->event.count}")
                }
            }
}
    }
}

//...

override fun onDestroy() {<!-- -->
      super.onDestroy()
      Log.d("Count", "COUNT: Counting interrupt")
}

Looking at the log, when the collector is interrupted, the stream of 845 is lost, indicating that the stream will be lost when Activity is in the destroyed state. This is also a problem discussed in foreign articles. It does not guarantee the flow performance. UI receives.

The final solution: handle the event immediately on the main thread, by default in Dispatchers.Main which uses a standard message queue to schedule coroutine tasks, which means it will schedule the coroutine task according to the usual main thread Execute coroutines in event loop mode. If you set Dispatchers.Main.immediate it will execute the coroutine task immediately regardless of the status of the message queue, which means it will execute the coroutine code immediately regardless of whether there are pending message queue tasks or not. , thereby reducing latency.

LaunchedEffect(lifecycleOwner.lifecycle) {<!-- -->
    lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {<!-- -->
      viewModel.channel.collect {<!-- --> event ->
withContext(Dispatchers.Main.immediate){<!-- -->
                //when(event)...
}
}
    }
}

NOTE: Dispatchers.Main.immediate needs to be used with caution as it may cause unpredictable behavior when the coroutine is executed on the main thread, especially when executing long-running tasks.

Typically, you should use the default Dispatchers.Main as it provides good performance and predictability in most cases. Only in special circumstances should you consider using Dispatchers.Main.immediate to avoid certain problems.

Summary

There are three methods of handling one-time events, each with its own advantages and disadvantages. In daily development, I use Channel and SharedFlow the most, although State can solve the problem of flow loss, but it will have duplicate codes and needs to reset the state, which is not very friendly to me. The second is SharedFlow, although it can also implement the cache function, but it needs to be set manually. I would choose Channel to handle this.