My `UiState` contains an `action` property, which ...
# compose
l
My
UiState
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 🧵
Copy code
data 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.
Also if I wrap the
if
block inside a
LaunchedEffect(uiState)
it doesn't help.
s
I guess because we are not in control of how often recompositions happen… > Also if I wrap the
if
block inside a
LaunchedEffect(uiState)
it doesn’t help. What if you do
LaunchedEffect(uiState.startActivity)
?
l
Shouldn't that be effectively the same? It's a data class.
I have added the
LaunchedEffect(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.
s
Shouldn’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?
l
Should not be, the ViewModel logic looks sound. And strange that it seems to happen only on some devices.
🤔 1
E.g. not on Pixel
And with LaunchedEffect it happens on these devices quite rarely, sometimes 1 in 10 times.
Maybe after all I have a race condition in the ViewModel
s
Hard to tell what’s exactly going wrong here, perhaps you can add some logs to see what exactly happens? Check LaunchedEffect argument etc. Anyway, I wouldn’t recommend putting action into
UiState
, 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
Copy code
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
Copy code
@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))
        }
      }
    }
  }
}
l
Yes, we used to do this with
Channel
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.
s
This is the case with
SharedFlow
only and with use of
RENDEZVOUS Channel
you can make sure that the even is consumed properly if we add
Copy code
fun onButtonClick() {
  viewModelScope.launch {
    actions.send(Action) // this will suspend until received by the consumer
  }
}
Or am I getting something wrong?
l
This is actually the current recommendation of handling navigation through uiState, I will try that https://developer.android.com/topic/architecture/ui-layer/events#navigation-events-destination-back-stack
No, the point is, even with a
RENDEZVOUS
channel the event can get lost "in between". I.e. after being emitted but before it's consumed.
I'm not sure myself how/when it can happen, I imagine when the Activity gets destroyed in between etc.
s
I see, I was under impression that it will suspend until there is a new receiver connected to the Channel 🤔
l
It's a hot flow
It doesn't care about collectors
BTW you cannot collect a Channel directly, that's why we used to use the
receiveAsFlow()
so that it emits as a
Flow
But I added some more logging to the
LaunchedEffect
and looks like the ViewModel triggers the
startActivity
as
true
again after first navigation. So there is definitely some error in my code.
👍 1
s
BTW can’t we collect the channel via
Copy code
for (action in actions) {
  // Handle Action         
}
? This way
actions.send()
will suspend indefinitely until we handle it on the receiving side?
l
Like I said, I'm not sure why the events from the channel can get lost, but I got it already confirmed from another expert on Coroutines as well that it's possible.
s
Alright alright, I’m just not quite enjoying the recommended way 🙂
l
See also Manuel Vivo's comment to the linked article
Hi! 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.
🙌 1
Alright alright, I’m just not quite enjoying the recommended way
Yes, 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
Also with navigation usually the user can trigger the action again (if it's through a button press)
The cause of the problem was that we trigger the navigation on a barcode scan. The barcode scanner delivered the scanned code twice (in each processed frame) and that's what triggered the navigation twice in the ViewModel (we didn't have proper thread safe locking in the method that was processing the barcode).
👍 1
s
Glad you found the root cause! And I’ve been rethinking quite a lot regarding ViewModel actions yesterday 😄
😄 1
l
Actually, if you read Roman Elizarov's comment in the Github issue, it should be enough to emit/collect the events from a
Channel
on
Dispatchers.Main.immediate
to guarantee consumption. It makes the whole Manuel Vivo's article a bit misleading.
Namely
viewModelScope
and
lifecycleScope
use
Dispatchers.Main.immediate
already.
👌 1
s
Roman Elizarov’s comment
Yes 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
👍 1
I wonder why this topic didn’t get enough traction to proceed with something like
ExactlyOnceEventBus
, I guess folks are still somewhat satisfied with the workarounds blob shrug
l
I think using `viewModelScope`/`lifecycleScope` is not actually a workaround, but a proper solution in Android world.
Also the issue got a little blown up by Manuel Vivo, I have the impression.
s
> using `viewModelScope`/`lifecycleScope` is not actually a workaround, but a proper solution in Android world. Yeah, this is why I’ve never encountered an issue and until yesterday was under impression that
Channel
is the right tool for the job 🙂
l
Yes, we also never got any complaints while using
Channel
...
👍 1