I'm wondering: is there a callback on `Flow` that ...
# coroutines
m
I'm wondering: is there a callback on
Flow
that let me trigger an event when an emission has been collected? I imagine it would be similar to onCompleted but triggered whenever an emission has been successfully collected (the collect lambda has been fully completed and reached the end without interruptions). Something like
onCollected
or
onCollectCompleted
. My use case would be to implement the solution proposed in ViewModel One-off event antipatterns from the view model side and reduce the boilerplate.
b
There is no such built-in option in flow. One reason is because the downstream might apply
.buffer()
operator or it can be some transform operator introducing intermediate collector. Another reason is that flow is designed to be unidirectional you should be able to achieve it with operators inversion though, instead of desired
Copy code
someFlow
   .onCollected { item -> //some side effect  }
   .collect { // collector }
Just do
Copy code
someFlow
    .onEach { // collector  }
    .onEach { // item -> //some side effect }
    .collect()
If you can't do this (like desired
onCollection { }
somehow must be applied on the upstream, I think the only option is to introduce the wrapper like
Message(item: T, collectionSignal: Deferred<Unit>)
which will allow you to perform bi-directional communication
c
For basic flows, a call to
emit()
will suspend until the collector has finished processing that item. This is the core design of Flows. However, I believe this is no such guarantee if you account for things like buffering or other Flow operators. Instead of using a Flow for one-off usecases, you might consider a
Channel
instead. Using a Channel, you can send an Event to be processed, which you don’t need to manually clear once it’s been handled.
Channel<Events>(RENDEZVOUS, BufferOverflow.SUSPEND)
will mean that a call to
channel.send()
will suspend until the item has been processed by the UI. If the VM doesn’t need to suspend until after it’s been processed and you just need a fire-and-forget type one-off event,
Channel(BUFFERED, BufferOverflow.SUSPEND)
might be preferable
And FWIW, the article argues that Channels are not suitable for one-off events mainly because there’s not a guarantee of immediate processing once an item is sent to the channel. This is in part due to how channels interact with cancellation. But in my experience, I have always used
BUFFERED
channels for my one-off events, and while that doesn’t give the VM the immediate feedback that an event has actually been handled, I’ve never found that to be an issue. I have a reasonably-strong guarantee that the item will live in the channel until it’s consumed, which works even if the UI is in the background when the item gets sent, and when the UI comes back it will re-subscribe to the channel and accept that event, so it doesn’t get lost
There may be some really specific use-cases where this is unacceptable, but in my understanding, this pattern is sufficient for the majority of use-cases like navigation. If the user leaves the screen, it’s perfectly acceptable to silently drop a final navigation request, since they’ve left to do something else
p
I use channel too for the
effects
. I use it with channel.unlimmited so it buffers until memory is exhausted. In my experience using a channel has worked better than sharedflow or a custom event dispatcher
m
First of all, thank you all for the great answers! @bezrukov I understand now why that wouldn't be possible. I think I can leverage your idea with
Deferred
to build such a mechanism with minimum boilerplate, and that would cover my use case. Thanks for the ideas, I will play with it. @Casey Brooks, @Pablichjenkov I used to do likewise but Manuel does a good job describing the issue with channels and ViewModels one shot, which won't be solved by changing the capacity.
That is: 1) the producer (ViewModel) sends the event, 2) the consumer (View) receives the event which is scheduled for dispatch, but then, 3) the consumer is cancelled. The event was received but never processed.
I agree the issue has low chance of happening but it may happen, and can be a problem. My goal here is to explore alternative solutions from Manuel's one. For example: the Channel sends an event, the View receivers but there is a configuration change. The event may be lost. If I misunderstood your messages, please correct me.
c
You’re right, that in some situations, the lack of airtight delivery guarantees with channels can be a deal-breaker. But my opinion is that Channels are a good choice for the 95%-99% of use-cases where folks need one-off events, and shouldn’t just be immediately discarded as an option because of some theoretical issue. In most scenarios, the simplicity of a Channel, even with drawbacks, is better than the boilerplate and lack of readability of the proposed alternatives. And this is my philosophy with most other things too, like optimizing for performance or memory: don’t agonize over optimizing for some theoretical issue if you haven’t noticed it as a problem in your application.
And of course, poking around at alternatives just to understand the pros/cons of each is highly encouraged. But I’ve seen this article (or variants thereof) passed around a lot, and while it does introduce a very real potential issue, I’m not convinced it’s as significant of a problem in the majority of applications that the article makes it seem
p
But the same scenario doesn't apply to SharedFlow or flow? isn't the same mechanic under the hood?
I read the article again and I would agree in the fact that if you are dealing with payment or things like that, then better to model your logic with a state flow and only move to the next state when receiving a confirmation from the view that the event was processed. But for things like show/hide a loader or present an error message dialog , I believe the channel is fine, even better with dispatch.main.immediate
m
@Casey Brooks I think we may be getting off-topic here but I do have a use case in which losing an event is not acceptable. I agree with you the solution increases complexity for correctness, and the chance of happening is really low and maybe it is not justifiable for all events. That is the reason why I'm exploring alternative solutions. @Pablichjenkov yes but no. SharedFlows with replay 1+ will re-emit the event, and in Manuel's solution the View will call back the View Model once it is completed to erase the event.
p
With reply 1 is a state flow basically. And yes I agree, if you want 100% guarantee you have to write more code
c
The thing to watch out for with SharedFlow, though, is that because of the replay, you might end up where the event is handled partially multiple times. If the entire collector doesn’t suspend then you know it either runs completely or doesn’t, but it’s something to watch out for there. In the edge cases, Channels basically will be handled 0 or 1 times, SharedFlow is more like it will be handled 1 or more times. But neither one is perfectly suitable for being handled exactly once
d
Personally, I would put in the work to handle it the way Manuel suggests. It sets the pattern in the codebase for the safest way to do it and gives you confidence nothing will go wrong. I just can't see any down side to doing it properly besides some extra elbow grease. It reminds me of the time I was working as a garbage man. I saw a sign that said: People always seem to have the time to do the job right the second time, but never have the time to do it right the first time.
p
Basically that.
c
FWIW I used to use a channel, but then had to implement my app for tablets. and then basically the whole "navigation events" arguments falls through and indeed I needed state to signify something and i would navigate on phones based on that, or show some other part of the screen on tablets.
p
And you signal/clear the ViewModel when you consume the "state/event" from the view? How do you do in the case of config changes? To not consume the "event-state" twice
c
I basically don't clear anything unless its a case that requires it (like a snackbar since it dismisses on its own)
p
I got you
k
If it's only about navigation event, then I would say that the best option is to not emit them at all, just inject navigator into your controller.
p
KamilH, you mean Controller or ViewModel? The challenge is any type of event. How to 100% guarantee one time delivery, knowing the view coroutine can cancel at any time
c
I asked about passing the navigator into the controller/VM a few weeks back and basically the feedback was that you're now prone to a memory leak.
k
@Pablichjenkov yes, by Controller I mean ViewModel or Presenter or Controller or anything else that holds UI logic. In my apps I usually have "NavigateTo", "ScrollTo", "ShowSnack" events. "NavigateTo" I replaced by passing Navigator to Controller and the rest of them are very easy to be modeled with the State. Especially in the Compose world. @Colton Idle you can make your Navigator a Singleton, it will outlive your Controller, but also Activity, but it shouldn't be a problem. Especially in the single Activity app
c
At least if you're using compose nav, then nav is tied to the activity I believe and so making that a singleton would be a mem leak? I'm not 100% sure, but @Ian Lake gave me that advice a few weeks back.
k
If you need to have a reference to the Activity, you could have a class that observes what Activity is currently on top (by using "Application.ActivityLifecycleCallbacks") and call appropriate method to dispatch navigation event to this Activity. This way your Navigator doesn't have a strong reference to any Activity
d
Personally I don't put anything related to navigation into ViewModels. I use ViewModels for talking to the business layer, holding state, and mapping state. Navigation logic is purely a UI concern and so it lives in fragments, activities, and composables. Typically if I have a
XScreen
Composable I will also set up a
XScreenController
Composable that handles navigation, ViewModels, and the
WindowSizeClass
.
From what I have seen Google rolls that stuff up into just the
XScreen
Composable.