Hello. Can anyone help me with a best practice for...
# multiplatform
a
Hello. Can anyone help me with a best practice for the following scenario: • existing kotlin app working with Fragments • migrating the screens individually using Compose and using composeView in the onCreateView of the Fragment • viewModel is in KMM using KMMViewModel • viewModel passes stateFlows back to the composables -- this is fine • problem is when I need to pass 1 single event back to the composable, for example i need viewModel to let composable know that it needs to navigate to another Fragment. What's the best practice for that? Using a stateFlow in my mind is also risky and also kinda ... meh ...
v
Hi! I don't know the perfect solution, but the best I found for one-time events is such: when the event is consumed, we "erase" the state from the StateFlow.
Copy code
private val _showError = MutableStateFlow<String?>(null)
    val showError: StateFlow<String?> = _showError

    fun onErrorShown() {
        // we reset flow states to avoid showing errors after subscribing again
        _showError.tryEmit(null)
    }
For navigation we still use conflated channels in the native app, so I'm also curious to know the best practises for KMP ViewModels:
Copy code
private val _navigateTo = Channel<NavigationDirection>(Channel.CONFLATED)
    val navigateTo = _navigateTo.receiveAsFlow()
a
interestingly enough, chatGPT suggested something similar ... basically after the composable handles the event, it notifies the viewModel to reset the state. I couldn't believe it, that's why i also asked here ... it's strange that there's no mechanism for this implemented ... 😑
thanks for the info! if you're using it, then i'll do the same ... c'est la vie 🙂 cute cat btw, chucky cat in fact 😄
😁 1
m
In most cases I'm using the "erase" approach as well. When I receive a success state for example in a compose LaunchedEffect I run the navigation code and then I clear the success state so the navigation is not triggered twice.
Copy code
LaunchedEffect(state.isSuccess) {
    if (state.isSuccess() {
        onNavigate()
        onClearSuccessState()
    }
}
a
Thank you ! So it seems this is the "best practice" then ... even thought it doesn't look like a good practice, but what can we do, until they come up with some other mechanism 😄 Cool! Thanks !
m
Haha, the best practice is to have a
SharedFlow
for your navigation and to have a
ScreenNavigationEvent
sealed interface for each screen and you can emit events from your view model and collect them in your screen and navigate to the destination depending on the event. But I always get lazy to do that because it requires some more additional work and the other approach is working fine 😄 With this approach you don't need the erase hack because
SharedFlow
is going to ensure that the event is going to be collected only once.
a
awesome! i'll take a look over what you mentioned though ... maybe for a new project at some point in the future, who knows.
👍 1
j
If you know your view will receive the event, a
MutableSharedFlow
or
Channel
with
receiveAsFlow
works without the need to reset the state.
a
Yeah, but i wouldn't risk it. from what I've read, Channel does not reemit the event, so ... meh. But I'll keep that in mind, thanks! I'd need to find some sort of Channel interop though, 'cause the viewModels is in KMM.
j
Yeah, makes sense especially for Android, where the view might not be listening while in the background. Ideally I'd like to use something like this, a hot flow that buffers like
StateFlow
, but once a value is collected, resets itself.
a
yeah, something like that. from what i can tell, it's just a discussion on the matter, but nothing conclusive, right ?
j
The
Channel
with
receiveAsFlow
described in the issue description does work for this, as long as you only intend for and need one collector to receive the events.
p
I believe there is no guarantee of delivery using flows in general. There are some articles around, one popular from Manuel Vivo. Ultimately that leads to use of a MutableState or a SharedState(reply = 1) and send a consumed event signal from the view so the server(State/ViewModel) cleans it up or mark it as consumed.
j
The channel flow is what Roman recommends for this single collection behavior. Values will be buffered and sent to the first collector. I just tested and confirmed.
job1
misses
SharedFlow
values from when it wasn't yet collecting.
job2
collects all the
Channel
values, even the one sent before it was collecting.
job3
doesn't collect any
Channel
values because
job2
receives them instead.
p
The problem with events is that there is no reliability of delivery. Consumers can unsubscribe just a nano second before delivery for a crash or some other reason like another App taking the screen. I know chances are slim but let's stay in the theoretical side. So given that, the most reliable way is indicating event received success from the client. It puts some overhead, basically is mocking a client - server communication between View and ViewModel, but is the best we have right now. The flows API doesn't have at the moment a way to indicate successful event received/delivered. At least I am not aware of
j
The way I see it, the channel solution is quite similar to state with a consume event. Both would only be consumed by a single collector. The difference is the consume event is implicit for the channel flow, without the need to write additional boilerplate code. When it collects the value it's consumed from the channel. The channel will buffer event state until a consumer arrives and collects it. The view model won't lose any of these events if they aren't collected by the UI. It can always check
channel.isEmpty
if it wants to know if the events haven't been consumed. The channel can also control its buffer size and overflow behavior. Depending on the type of event, it might make sense for the channel to be conflated like a
StateFlow
, like for navigation events. Or it might make sense to buffer multiple events, like for snackbar notifications.
p
I thought there was no guarantee of the collection process. But if there is a 100% guarantee that the event is marked as consumed (on the channel side) only when it was satisfactory delivered to the collector consuming it, then it is more than ok, no need for extra boilerplate.
j
As long as the UI collector handles the event in a non-cancelable way and the view model creates the channel with the proper buffer size and overflow behavior, a channel should work well for most one-off UI events. Unless the view model actually needs to do something in response to the event being consumed, the consumed event from the UI is just boilerplate in my opinion.
p
That is definitely a must
non-cancelable
. And that is basically the concern these articles talk about. 1- Start consumption, then 2- Cancelation request comes before the consumption codes executes. What happens here? - when the consumption starts, if a cancellation comes before the code finishes, is it guaranteed to complete and then cancel? Or it can cancel before finishes. I recall that was Manuel Vivo's concern.
j
The UI collector will run in a coroutine, which can be canceled. But only cancelable calls aren't guaranteed to complete. Depending on what work the collector does and when an event is considered handled, it could be appropriate to explicitly communicate when it's been handled to the view model. But most UI event state collectors probably aren't calling suspending functions that can be canceled.
p
That is clearer now. I really appreciate these insights, thanks
kodee welcoming 1
j
Ok, now that I read the issue about this, I understand there is still a non-zero chance that a channel could lose an event sent to the UI, if the UI's collector is canceled immediately after the channel sends it the event and before the collector is called. I modified my test code to demonstrate it. If the collectors are canceled right before sending the event, then the channel will not be empty in this case. Interestingly, the
SharedFlow
still receives the event when canceled in the same way. It's a shame this issue hasn't received any further attention or a proper solution after so many years. The workaround to both send and collect on
Dispatchers.Main.immediate
is valid for Android, but unfortunately isn't multiplatform-supported, since
immediate
only supports the
Main
dispatcher, which isn't available on all platforms.
😢 1
p
That's the one issue I remember. I couldn't find it before. So unfortunately it's still going on 😞
😞 1