Hi iam attempting to implement MVI in my current A...
# coroutines
t
Hi iam attempting to implement MVI in my current Android compose application My composable screen has an associated viewmodel which holds two flows that i combine and collect as follows:-
Copy code
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
the "main" flow holds the data the user sees contained in a lazyColumn the "secondary" flow is triggered when the user clicks on one of the list items i then dispaly the selected list item in detail outside my app as a web page in a linked website. this all works fine, up to a point as both these flows are stateflows when the user has clicked on a list item and i rotates the device both flows re emit the last value. for the list ("main" state flow) this is not an issue, however it means when the user has previously clicked on a list item, navigated to the external web site and returned to my application, on rotation the secondary stateflow re emits the list item click event and again the user is sent to the external website. i have "fixed" this behaviour by adding an .onStart{} to my combine flow statement as follows:-
private val listiState: Flow<ListUiState> = repository.listItems().map { ListUiState(it) }
private val displayItemState = MutableStateFlow(DisplayItemState())
val uiState: StateFlow<ListScreenUiState> =
combine(ListUiState, displayItemState) { Lists, displayItem -> ListScreenUiState(Lists, displayItem) }
.onStart { displayItemState.emit(DisplayItemState()) }
.stateIn(scope = viewModelScope, started = SharingStarted.WhileSubscribed(), initialValue = ListScreenUiState())
i do not like having this "hacky" fix if i could replace the secondary StateFlow with a SharedFlow it would be great as SharedFlow is Fire-and-Forget however when i make this change the user never sees the list items as combine doesnt appear to emit anything until all its combined flows have emitted a value can I improve on the above some how?
c
It’s not good to handle “one-off events” with a StateFlow, because they will keep holding on that value even after it’s “consumed”, potentially making it get handled again (as you’ve noticed with configuration changes). It’s better to use a
Channel
for sending these one-off events to ensure they only get handled once, which basically runs alongside the persistent state, not as part of it. I’ve also been building an MVI library, Ballast to handle this kind of stuff for you so you can just focus on the application logic, which you may find useful. You can join the #ballast channel if you have any questions about how to use the library, or about MVI in general
t
nice work on Ballast 😄 i hadnt thought of employing a channel thanks for looking at my post 😄
c
Yeah, i see a lot of folks suggesting
SharedFlow
for this use-case, but that is not usually the best since it could handle the event more than once if there are multiple subscribers to the
SharedFlow
. A Channel is guaranteed to only process each item once, and the API for configuring and using them both is nearly the same. I think a lot of folks see a Channel as having been replaced by SharedFlow, but in reality they’re both two different things with different use-cases, and this situation of handling one-off events in an MVI model is definitely a case where you want a Channel instead of SharedFlow
Using a Channel also enforces the idea of “reacting” to those one-off events, rather than SharedFlow which can tend to be seen more like a StateFlow without an initial value, which has different semantics.
f
for Jetpack Compose, Google recommend to add the events to the state, as a list, and then have a
LaunchedEffect
on the list and communicating to the viewmodel that an event has been handled, once the UI consumes it
c
@Francesc Can you find the link to that recommendation? It seems like such an ugly pattern to set and then reset a State value in the ViewModel, but I’d be curious to see the rationale. At one point, the Android team also created
SingleLiveEvent
as a response to people doing that same kind of pattern with
LiveData
, because it’s easy to mess it up and forget to reset the “event” in the state
f
p
One time off events don't play well with stateful representation of an App. The trick is finding the way to represent that one-time event as a state
I don't think there is a one size fits all, it will depend on the business logic
f
that applies to pretty much anything. I find that this has worked well for me
Copy code
data class AddEntryState(
    // other stuff
    val events: List<AddEntryEvent>,
) : State

@Composable
fun AddEntryScreen(
    state: AddEntryState,
    onDescriptionChange: (String) -> Unit,
    onBackClick: () -> Unit,
    onEventConsumed: () -> Unit,
    modifier: Modifier = Modifier,
    entry: ListEntry? = null,
    screenState: AddEntryScreenState = rememberAddEntryScreenState(entry),
) {
    LaunchedEffect(key1 = state.events) {
        state.events.firstOrNull()?.let { event ->
            when (event) {
                AddEntryEvent.Dismiss -> onBackClick()
                is AddEntryEvent.PasswordGenerated -> screenState.onPasswordUpdated(event.password)
            }
            onEventConsumed()
        }
    }
    // other stuff
}
c
Thank you for those links, they are interesting reads indeed! The main argument for putting one-off events (like navigation) into the State rather than using a Channel to react to the change, is that the Channel may 1) drop the event, and 2) the change is not immediate, and thus the UI may delay the handling of the event and cause inconsistency between the app state and the UI. It’s definitely not easy to model one-off events in a purely stateful model, and while the ideal is everything to be fully reactive, the reality is that the majority of the Android SDK is built with an imperative model which needs to be used in that one-off event manner. To that point, 1) can be solved by using a
BUFFERED
channel instead of the default
RENDEZVOUS
as used in the code snippet from the second article. When you do this, the Channel becomes a kind of State, and if the UI is not around to read from the Channel when the event is sent to it, the event stays in the Channel until the UI is able to consume it (this is how Events are implemented in Ballast, too). And 2) is done as the article suggested, collecting from the Channel on
Dispatchers.Main.immediate
(which is the default for Compose). So my take is that a Channel isn’t necessarily an anti-pattern to the one-off events, but you do need to know a bit about Coroutines to use this pattern “correctly”, while the method of setting-and-resetting in the state is more fool-proof. It’s all in a more nuanced understanding of “state” and how it gets delivered to the UI, with the understanding that State sometimes is ephemeral, and Events are sometimes stateful
f
right, the idea with the events as state is that they are not lost if the user backgrounds the app or the composable goes away for any other reason
and it also allows you to handle them one at a time to ensure they are serialized properly, as they are generated
both of which you can do with a channel too, as you mentioned
c
And this is a good point for me to remember 😄. One of my main arguments for using a Channel instead of SharedFlow is that its easy for the SharedFlow to drop events if there are no subscribers, which can be fixed by setting a buffer/replay cache on it, but you have to remember that this is not the default. But Events can be similarly problematic with the default Channel, too, so I need to remember to recommend a
BUFFERED
channel for this case
p
I think what the article is referencing, is that there could be a race condition in the time the channel emits the event and the actual UI consuming it. Let's say 10 milliseconds after taking the event from the Channel a rotation happens. The event consumption won't finish properly, however, you have no way of knowing that. There is no way for the ViewModel to actually know that the event was successfully consumed, unless you create this external mechanism that explicitly talk back to the ViewModel and tell the event was successfully consumed.
The mechanics is up to you, marking it as consumed, or removing it from a list of pending consumption events, many ways
t
an interesting discussion with some great points.... in my opinion, the real issue (Big Picture) is android developers are always "Up against" the Android Lifecycle, and attemting to develop an architectural solution (e.g. MVVM, MVI, etc) is always going to be "flawed" if that solution doesnt play well with this lifecycle. I think MVI is the best or has the potential to be the best approach, IMO 😉
p
Lifecycle is necessary, like it or not. In fact you see them in any other platform. You need them because you don’t want your App doing stuff while it sits in the background. The problem is not really lifecycle but “one time event delivery guarantee”, it is a general problem in event driven systems/reactive systems. It is known as “Exactly-once semantics” in messaging systems like Kafka, Pulsar. It is solved having some type of dialog/communication between the producer <-> consumer. In Android the communication would be between ViewModel/Presenter <-> UI
109 Views