Skip to content

Instantly share code, notes, and snippets.

@JoseAlcerreca
Created April 26, 2018 12:14
Show Gist options
  • Save JoseAlcerreca/e0bba240d9b3cffa258777f12e5c0ae9 to your computer and use it in GitHub Desktop.
Save JoseAlcerreca/e0bba240d9b3cffa258777f12e5c0ae9 to your computer and use it in GitHub Desktop.
An Observer for Events, simplifying the pattern of checking if the Event's content has already been handled.
/**
* An [Observer] for [Event]s, simplifying the pattern of checking if the [Event]'s content has
* already been handled.
*
* [onEventUnhandledContent] is *only* called if the [Event]'s contents has not been handled.
*/
class EventObserver<T>(private val onEventUnhandledContent: (T) -> Unit) : Observer<Event<T>> {
override fun onChanged(event: Event<T>?) {
event?.getContentIfNotHandled()?.let { value ->
onEventUnhandledContent(value)
}
}
}
@Zhuinden
Copy link

I think if you're using a Flow that is not a StateFlow, you don't need Event wrappers at all, as your events are not replayed on subscription anyway.

@adam-hurwitz
Copy link

adam-hurwitz commented Jul 17, 2020

@Zhuinden, In the use case in Coinverse a MutableStateFlow is used in the Fragment in order to emit intents in the Model-View-Intent pattern. In the ViewModel, the intent is observed as a Flow which is non-mutable so that the event is unidirectional.

I observed when triggering the selectContent intent that opens content in the feed, and navigating to a different screen, that upon returning, the intent is emitting a second time reopening the past content selected. After refactoring with the Event pattern this has resolved the issue.

This is good to know for Flow.

as your events are not replayed on subscription (with Flow)

When implementing MutableStateFlow to update intents, the Event pattern has utility as Roman Elizarov also summarizes.

If you use StateFlow to deliver events to your views (as opposed to the delivering a state), you must use an event wrapper class just as suggested by the article you've linked to: https://medium.com/androiddevelopers/livedata-with-snackbar-navigation-and-other-events-the-singleliveevent-case-ac2622673150
With the event wrapper you can ensure that every event is handled at most once.

The full code is under the refactor/mvi branch which will be merged to root.

FeedView.kt

@ExperimentalCoroutinesApi
interface FeedView {
    /**
     * Intent to select content from the feed
     *
     * @return A flow that emits the content to select from the feed
     */
    fun selectContent(): Flow<Event<SelectContent?>>
}

FeedFragment.kt

// MutableStateFlow passed into Adapter
val selectContent: MutableStateFlow<Event<SelectContent?>> = MutableStateFlow(Event(null)),

FeedAdapter.kt

private fun createOnClickListener(content: Content, position: Int) = OnClickListener { view ->
        when (view.id) {
            preview, contentTypeLogo -> intent.selectContent.value =
                    Event(SelectContent(content, position))
        }
    }

FeedViewModel.kt

fun bindIntents(view: FeedView) {
    view.selectContent().onEachEvent { selectContent ->
        selectContent(selectContent)
    }.launchIn(coroutineScope)
}

private fun selectContent(selectContent: SelectContent) {
    when (selectContent.content.contentType) {
        ARTICLE -> repository.getAudiocast(selectContent).onEach { resource ->
            when (resource.status) {
                LOADING -> state.value = OpenContent(...)
                SUCCESS -> state.value = OpenContent(...)
                Status.ERROR -> state.value = OpenContent(...)
            }
        }.launchIn(coroutineScope)
        YOUTUBE -> state.value = OpenContent(...)
        NONE -> throw IllegalArgumentException("contentType expected, contentType is 'NONE'")
    }
}

@Zhuinden
Copy link

Because one-off events shouldn't even be in a MutableStateFlow.

I hear that a LinkedListChannel exposed as a Flow would have the same effect, but without the magic.

Kotlin/kotlinx.coroutines@345458b

@adam-hurwitz
Copy link

adam-hurwitz commented Jul 17, 2020

Thank you for the recommendation! I implemented a LinkedListChannel to test it out and it works as expected to emit the one-time events.

The tradeoff is using the Channel introduces the need to launch the coroutine to utilize the Channel send and receive functions which creates a layer of nesting with either lifecycleScope.launch and viewModelScope.launch whereas with the Event, the value can be emitted with .value and returned with the extension function onEachEvent that tracks whether the event has been handled or not. This strategy also requires passing an additional argument into the Adapter, coroutineScope.

LinkedListChannel

FeedView.kt

@ExperimentalCoroutinesApi
interface FeedView {
    /**
     * Intent to select content from the feed
     *
     * @return A flow that emits the content to select from the feed
     */
     fun selectContent(): Channel<SelectContent?>
}

FeedFragment.kt

// LinkedListChannel passed into Adapter
val selectContent: Channel<SelectContent?> = Channel(UNLIMITED)

FeedAdapter.kt

private fun createOnClickListener(content: Content, position: Int) = OnClickListener { view ->
        when (view.id) {
            preview, contentTypeLogo ->
                coroutineScope.launch {
                    intent.selectContent.send(SelectContent(content, position))
                }
        }
    }

FeedViewModel.kt

fun bindIntents(view: FeedView) {
    viewModelScope.launch {
            view.selectContent().receive()?.let {
                selectContent(it)
            }
     }
}

@edujtm
Copy link

edujtm commented Aug 29, 2020

Can't you just emit the event using offer() though? That way you don't need to create a CoroutineScope.

The SendChannel<T>.offer() documentation says this:

Immediately adds the specified [element] to this channel, if this doesn't violate its capacity restrictions,
and returns true. Otherwise, just returns false. This is a synchronous variant of [send] which backs off
in situations when send suspends.

and the LinkedListChannel, say this:

Channel with linked-list buffer of a unlimited capacity (limited only by available memory).
Sender to this channel never suspends and [offer] always returns true.

I've been using the event-emitter library from Zhuinden, but since I'm already using coroutines. I've been trying to implement the same behavior using only coroutines.

@mathroule
Copy link

mathroule commented Oct 28, 2020

Thanks @JoseAlcerreca.

It's also possible to create an extension to MutableLiveData to directly post an Event:

fun <T> MutableLiveData<Event<T>>.postEvent(content: T) {
    postValue(Event(content))
}

And to replace usage with:

mutableLiveData.postValue(Event(data)) => mutableLiveData.postEvent(data)

@Artman12
Copy link

Artman12 commented Feb 2, 2021

Am I wrong or can't EventObserver handle null events? i.e. new Event<>(null). I'm not in Kotlin but when I got it right it?.getContentIfNotHandled()?.let(onEventUnhandledContent)

onEventUnhandledContent

is only called if event != null and the return value != null so with new Event<>(null)

onEventUnhandledContent

is never called?!

I've tweaked some code a bit to allow nulling the content. I'm new to kotlin so please correct me if there are any issues

open class Event<out T>(private val content: T) {
    var hasBeenHandled = false
        private set // Allow external read but not write

    fun handleContent() : T {
        hasBeenHandled = true
        return content
    }

    /**
     * Returns the content, even if it's already been handled.
     */
    fun peekContent(): T = content
}

class EventObserver<T>(private val onEventUnhandledContent: (T) -> Unit) : Observer<Event<T>> {
    override fun onChanged(event: Event<T>?) {
        event?.let {
            if (it.hasBeenHandled) return
            onEventUnhandledContent(it.handleContent())
        }
    }
}

@Zhuinden
Copy link

Zhuinden commented Feb 3, 2021

@gmk57
Copy link

gmk57 commented Feb 21, 2021

I hear that a LinkedListChannel exposed as a Flow would have the same effect, but without the magic.

As you are probably aware it also needs a custom collector to work reliably.

I use https://github.com/Zhuinden/live-event now

This is fine solution too, supporting multiple observers and multiple queued events, but with a small catch: only one observer receives these queued events.

@Zhuinden
Copy link

@gmk57 this is true. Theoretically it'd be possible to either expose setPaused from command-queue and/or provide a "minimumSubscriberCount" (working name) that would make it so that under 3 subscribers the EventEmitter is paused

@Dzendo
Copy link

Dzendo commented Jun 19, 2021

inline fun <T> LiveData<Event<T>>.observeEvent(owner: LifecycleOwner, crossinline onEventUnhandledContent: (T) -> Unit) {
    observe(owner, Observer { it?.getContentIfNotHandled()?.let(onEventUnhandledContent) })
}

I finished it like this. It seems to work correctly. What's your opinion?

inline fun LiveData<Event>.observeEvent(owner: LifecycleOwner, crossinline onEventUnhandledContent: (T) -> Unit) {
observe(owner) { it?.getContentIfNotHandled()?.let(onEventUnhandledContent) }
}

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