Hello guys, why does google discourage use of `Ch...
# android
r
Hello guys, why does google discourage use of `Channel`s to pass UI Events from the VM to the UI ? Note: In some apps, you might have seen ViewModel events being exposed to the UI using Kotlin Channels or other reactive streams. These solutions usually require workarounds such as event wrappers in order to guarantee that events are not lost and that they're consumed only once. I dont see a problem with that, while I see a threat of Ui Events being dropped if we use
SharedFlow
(event is sent while the UI is not collecting).
đŸ€” 1
Using a
Channel(capacity=UNLIMITED)
seems to be just perfect for this use case. In what scenario can it lead to an event being consumed twice or lost?
l
StateFlow should not have the problem of events being sent before the UI starts collecting. It will always keep the latest emitted value in memory until it is closed. If a new collector starts collecting, StateFlow will send the saved event.
You can get more control with SharedFlow, but StateFlow is just the most common type of SharedFlow.
r
Actually I don't think you want to use a
SharedFlow
(or
StateFlow
) for events. They can both emit a value multiple times (the replayCache). And a
StateFlow
will actually drop values if they aren't being collected. I would agree with @Radoslaw Juszczyk that an unlimited
Channel
is the better choice. However make sure you are not collecting the channel multiple times.
Actually I think this is the important part of that note:
Requiring workarounds is an indication that there's a problem with these approaches. The problem with exposing events from the ViewModel is that it goes against the state-down-events-up principle of Unidirectional Data Flow.
If you're in that situation, reconsider what that one-off ViewModel event actually means for your UI and convert it to UI state. UI state better represents the UI at a given point in time, it gives you more delivery and processing guarantees, it's usually easier to test, and it integrates consistently with the rest of your app.
https://developer.android.com/jetpack/guide/ui-layer/events So the note isn't really about Channel vs SharedFlow vs StateFlow. It's actually about state vs events.
👍 1
☝ 1
a
https://github.com/Kotlin/kotlinx.coroutines/issues/2886 has more information about the limitations. Even with an
UNLIMITED
Channel
, there is the potential for an element to be lost after it is received from the
Channel
, but before you can do anything with it (run some code as a result). You can try that out here: https://pl.kotl.in/pMRgpkghc. It’s a bit of a contrived example to show the issue, but this could occur whenever you have a collector disappearing and reappearing. In that playground, we send in 500 elements to a Channel, and then we attempt to receive those 500 elements and save them to a list. However, we also repeatedly cancel and relaunch the collector, and as a result you’ll see that not all elements make it into the list.
👀 1
Taking that to the extreme, let’s say you have an app setup where everything you do is sent through a
Channel
from the
ViewModel
to the UI. Every once in a while, if the collector on the UI side is cancelled and restarted (such as around configuration changes) you might lose something being sent through the
Channel
unless you take additional steps to guard against that. State fundamentally doesn’t have those issues. You can have the state in the
ViewModel
change as much as you’d like. Even if the UI isn’t present, it doesn’t matter: once it is present, it will render the most recent state. There’s a bit of extra work needed for figuring out how to interpret actions that the user takes as state, but the resulting logic should end up being simpler.
r
Thanks for the clarification @Alex Vanyo! I see why state would be better to prevent losing an event, however I am not quite sure how you would prevent the event from being “handled” multiple times. E.g. in this example: https://developer.android.com/jetpack/guide/ui-layer/events#views_3
Copy code
viewModel.uiState.collect { uiState ->
    uiState.userMessages.firstOrNull()?.let { userMessage ->
        // TODO: Show Snackbar with userMessage.
        // Once the message is displayed and
        // dismissed, notify the ViewModel.
        viewModel.userMessageShown(userMessage.id)
    }
    ...
}
Wouldn’t an update of the state cause a second snackbar (or dialog) to be shown?
a
Ah, I’ll admit the
Compose
version there is more clear on how to avoid that with a
LaunchedEffect
. As written, I think it’s implied that
viewModel.userMessageShown
will immediately change the
uiState
, so there won’t be an intermediate emission. If you’re seeing another
uiState
comes through (with the old
userMessages
) after
viewModel.userMessageShown
is called, then you could use some form of
distinctUntilChangedBy
to prevent that, something like:
Copy code
viewModel.uiState
    .map { uiState ->
        uiState.userMessages.firstOrNull()
    }
    .distinctUntilChangedBy { userMessage -> userMessage?.id }
    .collect { userMessage ->
        if (userMessage != null) {
            // TODO: Show Snackbar with userMessage.
            // Once the message is displayed and
            // dismissed, notify the ViewModel.
            viewModel.userMessageShown(userMessage.id)
        }
    }
đŸ‘đŸŒ 1
đŸ‘đŸ» 1
r
Thanks. That indeed makes more sense 😄
r
I am exposing StateFlow for UI drawing purposes, but let's say I want to display a Toast (one time only) , this is where I consider using Channel. Obviously I cannot drive it with StateFlow as it will cause showing that Toast multiple times when for example there is a config change. And also it's problematic as the Toast will disappear after a second while viewModel will still hold StateFlow with value “showToast” - not being synced with the UI anymore.
@Alex Vanyo but what if the VM sends two 'events' with StateFlow while the Activity is going through config changes (not listening) - in such a case only second one will be consumed
r
In the above example the 'events' are stored in a list (
userMessages
). Where the VM only adds messages to the list (it never replaces existing messages). The UI is then responsible for removing a message once it's shown to / dismissed by the user (the
userMessageShown
call). So in your example the two events would be added to the list during the config change. Once the activity starts collecting the state again it will receive the list with both 'events'.
r
ok I like that approach! the only issue is that it has to notify back the VM that it was consumed
r
Yeah that's true. Especially for something like a toast. But if you think about some kind of banner with a close button (or a snackbar or something). Then it makes more sense. Since you would probably want to show that message again after a config change until the user actually clicks the close/dismiss button.
r
So inspired with this
StateFlow
approach where you store list of events in the state and remove from it once consumed I came up with a wrapper. I ran it against that code example which @Alex Vanyo shown (showing issue with Channels) and it looks like all the events are consumed (
All Sent! 500
) Can you take a look and let me know if you dont see any obvious issues with it: https://pl.kotl.in/cg0iXcPNY
r
I don't see any obvious issues with the wrapper. Instead of
synchronized
you could use the
update
extension function on the
StateFlow
(which is available on all targets not just JVM): https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/update.html I would also use
drop
instead of
subList
since drop will actually create a new list instead of a view of the current one: https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/drop.html
r
Thank you for these suggestions, I was not aware of this methods. Especially update() is interesting one. Will try it tomorrow and see if it works. Also I am considering not using StateFlow at all as suggested here: https://github.com/Kotlin/kotlinx.coroutines/issues/2886#issuecomment-901201125
đŸ‘đŸ» 1