Skip to content

Instantly share code, notes, and snippets.

@gmk57
Last active February 13, 2024 15:17
Show Gist options
  • Star 29 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save gmk57/330a7d214f5d710811c6b5ce27ceedaa to your computer and use it in GitHub Desktop.
Save gmk57/330a7d214f5d710811c6b5ce27ceedaa to your computer and use it in GitHub Desktop.
Sending events to UI with Channel/Flow + custom collector (see my first comment for reasons behind it)
/**
* Starts collecting a flow when the lifecycle is started, and **cancels** the collection on stop.
* This is different from `lifecycleScope.launchWhenStarted { flow.collect{...} }`, in which case
* the coroutine is just suspended on stop.
*/
inline fun <reified T> Flow<T>.collectWhileStarted(
lifecycleOwner: LifecycleOwner,
noinline action: suspend (T) -> Unit
) {
object : DefaultLifecycleObserver {
private var job: Job? = null
init {
lifecycleOwner.lifecycle.addObserver(this)
}
override fun onStart(owner: LifecycleOwner) {
job = owner.lifecycleScope.launch {
collect { action(it) }
}
}
override fun onStop(owner: LifecycleOwner) {
job?.cancel()
job = null
}
}
}
class MyViewModel : ViewModel() {
// You can specify exact buffer size and onBufferOverflow strategy here, or go full blast with Channel.UNLIMITED
private val eventChannel = Channel<String>(capacity = 10, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val eventFlow = eventChannel.receiveAsFlow()
fun sendEvent(element: String) = eventChannel.trySend(element) // `trySend` replaces `offer` since Coroutines 1.5
}
class MyFragment : Fragment(R.layout.fragment_my) {
private val viewModel: MyViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// You can collect flow in `onCreate` using `this` as lifecycleOwner
// This is a bit more efficient: `LifecycleObserver` is registered only once
viewModel.eventFlow.collectWhileStarted(this) { Log.i(TAG, "event: $it") }
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Or you can collect flow in `onViewCreated` using `viewLifecycleOwner`
// This more closely resembles a typical LiveData observer
viewModel.eventFlow.collectWhileStarted(viewLifecycleOwner) { Log.i(TAG, "event: $it") }
}
}
@gmk57
Copy link
Author

gmk57 commented Oct 11, 2021

Thanks for the suggestion!

As far as I could test, mapNotNull works correctly. Personally, I find consuming inside a "shared" flow (technically it is not a SharedFlow after map) a bit counter-intuitive.

I'm not sure if consume()?.let { ... } is a boilerplate, I see it as a clear indication that we're dealing with events here, not some state. My main concern is that for Event<Unit> one can easily forget to add consume(), I've hit this a couple of times.

Having multiple primary (consuming) observers for a single flow is tricky, because it's hard to predict which one will process the event. Of course, if it doesn't matter, good for you. ;)

Nowadays, I prefer to expose a single StateFlow<SomeViewState> to UI, including all possible events. Event wrapper plays nice with it.

But all this is a matter of taste. If mapNotNull { it.consume() } looks and works well for you, great! :)

@erikhuizinga
Copy link

The fact that mapNotNull returns a Flow and not a SharedFlow makes sense, because after the map the property of a shared flow may no longer hold:

A hot Flow that shares emitted values among all its collectors in a broadcast fashion, so that all collectors get all emitted values.

(source)

In fact, a Flow might emit to zero or more collectors. That's not too generic, that's just versatile.

You can always create extensions for reuse, e.g.

fun <T : Any, V> SharedFlow<Event<T>>.mapNotNullEvents(transform: suspend (T) -> V) /* : Flow<V> */ =
    mapNotNull { it.unconsumedValueOrNull() }.map(transform)

fun <T : Any, V> SharedFlow<Event<T>>.mapEvents(transform: suspend (T) -> V) /* : Flow<V?> */ =
    map { it.unconsumedValueOrNull()?.let { value -> transform(value) } }

fun <T : Any> SharedFlow<Event<T>>.onEachEvent(action: suspend (T) -> Unit) /* : Flow<Event<T>> */ =
    onEach { it.unconsumedValueOrNull()?.let { value -> action(value) } }

It would've been nicer if onEach returned a SharedFlow instead of a Flow, as it preserves the broadcast property in the type signature.

@fergusonm
Copy link

So, for what it's worth Google's updated their own guidance on this problem yet again. https://developer.android.com/jetpack/guide/ui-layer/events (I'm not quite sure when this updated guide was posted.)

From their guide:

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.

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.

The TL;DR of it is for the view to call back to the view model when an event has been processed. This is in-line with their recommendations for unidirectional data flow and feels very Compose-y. It ignores a lot of issues, in my opinion, but it is what it is.

@gmk57
Copy link
Author

gmk57 commented Dec 23, 2021

@fergusonm Thanks for the update, I haven't seen this article before.

It's good to see that Google is aware of the issue. I like UDF & try to use it as much as possible. From my point of view, the main problem is an "impedance mismatch" between state-down-events-up principle and event-driven classic Android UI.

Event wrappers and userMessageShown() are both workarounds for this mismatch, with the latter being much more verbose. Wrapper just encapsulates basically the same logic to avoid repetition.

Compose mostly alleviates this concern, e.g. AlertDialog is now just a composable and can be clearly modeled as part of state.

On the other side, AFAIK, Navigation Compose is still event-based. Is this code guaranteed to be called only once?

LaunchedEffect(viewModel.uiState)  {
    if (viewModel.uiState.isUserLoggedIn) {
        currentOnUserLogIn()
    }
}

Another point that bothers me is the suggestion to handle "UI behavior logic" directly in UI, bypassing ViewModel. This goes against the "single source of truth" principle. It also complicates handling configuration changes, which becomes a larger issue on Android 12.

BTW, one phrase caught my eye: "business logic remains the same for the same app on different mobile platforms". Is Google starting to push us towards KMP? ;)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment