Lukasz Kalnik
09/02/2025, 9:34 AMUiState
contains an action
property, which when set, should start an activity from the Composable. However on some devices the activity is started twice.
Code in 🧵Lukasz Kalnik
09/02/2025, 9:40 AMdata class UiState(val startActivity: Boolean)
class MyViewModel : ViewModel {
val uiState = MutableStateFlow(UiState(startActivity = false))
fun startActivity() {
uiState.value = UiState(startActivity = true)
}
fun onActivityStarted() {
uiState.value = UiState(startActivity = false)
}
}
@Composable
fun MyComposable(viewModel: MyViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val activity = LocalActivity.current
if (uiState.startActivity) {
viewModel.onActivityStarted()
activity.startActivity(Intent(activity, MyOtherActivity::class.java))
}
}
On some devices the if (uiState.startActivity)
block inside the composable is started twice.Lukasz Kalnik
09/02/2025, 9:40 AMif
block inside a LaunchedEffect(uiState)
it doesn't help.Sergey Dmitriev
09/02/2025, 9:41 AMif
block inside a LaunchedEffect(uiState)
it doesn’t help.
What if you do LaunchedEffect(uiState.startActivity)
?Lukasz Kalnik
09/02/2025, 10:51 AMLukasz Kalnik
09/02/2025, 11:01 AMLaunchedEffect(uiState.startActivity)
and it seems the problem comes not so often, but still.
This must be simply a race condition. I wonder how a pattern like this should be properly implemented in Compose.Sergey Dmitriev
09/02/2025, 11:23 AMShouldn’t that be effectively the same? It’s a data class.It should, you are right
This must be simply a race condition.I doubt it, it is guaranteed that
LaunchedEffect
runs only once for the given arguments.
Is it possible that the ViewModel
is initialized twice or something like that?Lukasz Kalnik
09/02/2025, 11:32 AMLukasz Kalnik
09/02/2025, 11:32 AMLukasz Kalnik
09/02/2025, 11:36 AMLukasz Kalnik
09/02/2025, 11:36 AMSergey Dmitriev
09/02/2025, 11:43 AMUiState
, feels like it’s not a good place for it and we shouldn’t change the state based on action.
Instead we can have another field in the ViewModel
class MyViewModel : ViewModel() {
sealed interface Action {
object StartAnotherActivity : Action
}
val actions = Channel<Action>() // or SharedFlow
fun onButtonClick() {
actions.trySend(Action.StartAnotherActivity)
}
}
And then in compose
@Composable
fun MyComposable(viewModel: MyViewModel) {
val activity = LocalActivity.current
LaunchedEffect(Unit) {
viewModel.actions.collect { action ->
when (action) {
Action.StartAnotherActivity -> {
activity.startActivity(Intent(activity, MyOtherActivity::class.java))
}
}
}
}
}
Lukasz Kalnik
09/02/2025, 11:45 AMChannel
which then through receiveAsFlow()
was converted to a hot Flow.
However Google advises against it, as hot flows don't care about the consumers and e.g. if the Activity gets destroyed after the event has been emitted, but before it got consumed, the event can get lost.Lukasz Kalnik
09/02/2025, 11:46 AMSergey Dmitriev
09/02/2025, 11:50 AMSharedFlow
only and with use of RENDEZVOUS Channel
you can make sure that the even is consumed properly if we add
fun onButtonClick() {
viewModelScope.launch {
actions.send(Action) // this will suspend until received by the consumer
}
}
Or am I getting something wrong?Lukasz Kalnik
09/02/2025, 11:50 AMLukasz Kalnik
09/02/2025, 11:50 AMRENDEZVOUS
channel the event can get lost "in between". I.e. after being emitted but before it's consumed.Lukasz Kalnik
09/02/2025, 11:51 AMSergey Dmitriev
09/02/2025, 11:52 AMLukasz Kalnik
09/02/2025, 11:53 AMLukasz Kalnik
09/02/2025, 11:53 AMLukasz Kalnik
09/02/2025, 11:53 AMreceiveAsFlow()
so that it emits as a Flow
Lukasz Kalnik
09/02/2025, 11:54 AMLaunchedEffect
and looks like the ViewModel triggers the startActivity
as true
again after first navigation. So there is definitely some error in my code.Sergey Dmitriev
09/02/2025, 12:02 PMfor (action in actions) {
// Handle Action
}
?
This way actions.send()
will suspend indefinitely until we handle it on the receiving side?Lukasz Kalnik
09/02/2025, 12:03 PMSergey Dmitriev
09/02/2025, 12:04 PMLukasz Kalnik
09/02/2025, 12:05 PMHi! It doesn't guarantee the processing of the event because of the prompt cancellation guarantee changes that landed in Coroutines 1.4. More info here:
https://github.com/Kotlin/kotlinx.coroutines/issues/2886
It could happen that the Channel sends the event and the collector just goes to the background (missing the event) pretty much instantly.
Lukasz Kalnik
09/02/2025, 12:06 PMAlright alright, I’m just not quite enjoying the recommended wayYes, it's more hassle. Also we actually didn't have any customer complaints, so I guess this is rather an "err on the safe side" approach
Lukasz Kalnik
09/02/2025, 12:06 PMLukasz Kalnik
09/02/2025, 1:55 PMSergey Dmitriev
09/03/2025, 7:18 AMLukasz Kalnik
09/03/2025, 7:21 AMChannel
on Dispatchers.Main.immediate
to guarantee consumption. It makes the whole Manuel Vivo's article a bit misleading.Lukasz Kalnik
09/03/2025, 7:22 AMviewModelScope
and lifecycleScope
use Dispatchers.Main.immediate
already.Lukasz Kalnik
09/03/2025, 7:22 AMSergey Dmitriev
09/03/2025, 7:23 AMRoman Elizarov’s commentYes that is my conclusion so far, because I really don’t want to model Actions as State. Even though there are some valid arguments advocating for such approach
Sergey Dmitriev
09/03/2025, 7:26 AMExactlyOnceEventBus
, I guess folks are still somewhat satisfied with the workarounds blob shrugLukasz Kalnik
09/03/2025, 7:28 AMLukasz Kalnik
09/03/2025, 7:28 AMSergey Dmitriev
09/03/2025, 7:30 AMChannel
is the right tool for the job 🙂Lukasz Kalnik
09/03/2025, 7:30 AMChannel
...