Keep listening or do your job and finish

altavir

Alexander Nozik

Posted on June 30, 2024

Keep listening or do your job and finish

People talk a lot about so-called design patterns (usually referencing those made in the famous book for C++). Though those patterns themselves are not universal, they change from language to language and their usage strongly depends on the specific task at hand. However, there are several among them that are more frequently used. One of those is the listener/observer pattern, which implies that an object is allowed to attach a listener object to it to observe its changes or other events.

In Kotlin syntax a general API for using a listener looks like this:

interface Observable {

    fun addListener(owner: Any?, listener: (Event) -> Unit)

    fun removeListener(owner: Any?)
}
Enter fullscreen mode Exit fullscreen mode

The first method of this interface is quite obvious and familiar. listener is the callback which is used when something interesting happens in the Observable. The Event could be different and even could have several arguments instead of one. The thing people tend to forget about is the second argument: owner. It becomes important when we remember that listeners need not only to be added but removed as well. When you remove a listener, you need to specify which listener to remove. So, you either need to return a removal handler from an addListener method or provide an identity to the removed object. Sometimes people try to use the listener itself to provide listener identity like this: fun removeListener(listener: (Event) -> Unit). But doing this is most probably a mistake because lambda functions identity is a tricky thing and at some point, it could produce unexpected results. Furthermore, one needs to store this function somewhere in case it needs to be removed. It is much better to use the owner of the listener (an object, that has an identity) as a handle. As an additional benefit, one could remove all listeners from the same handle or even use a null handle to remove all listeners.

Even with the handle identity problem solved, there is a problem of "hanging" listeners. In a dynamic structure, it is quite easy to forget to "detach" the listener that points to removed objects. It leads to a memory leak because the referenced object still exists in the listener so it could not be collected by GC.

The way to solve this problem is inspired by Kotlin SharedFlow. It attaches a listener on each subscription and removes said listener automatically when the subscription flow is canceled or collected. This way, you do not need to think about removing the listener manually. You just stop listening.

Let us see, how we can do that in a "flowless" environment:

interface AutoDetachObservable {
    fun listen(
        scope: CoroutineScope, 
        listener: (Event) -> Unit
    ): Job
}
Enter fullscreen mode Exit fullscreen mode

The scope provides a CoroutineScope in which the listener runs. When it is canceled, the listener (and all other listeners in the scope) is automatically detached. And the resulting Job could be canceled manually thus canceling only this subscription. This way the construct has not one, but two levels of control for subscription cancelation.

The implementation of this mechanism could look like this:

fun listen(
    scope: CoroutineScope, 
    listener: (Event) -> Unit
): Job{
    val id = generateUniqueId()
    val job = scope.launch{ 
        listenerRegistry[id] = listener 
        while(isActive){
            delay(1000) //cancelation will break delay anyway
            // just keep job active 
        }
    }
    job.invokeOnCompletion{ listenerRegistry.remove(id) }
    return job
}
Enter fullscreen mode Exit fullscreen mode

There was a mistake in the initial example Thanks @nicopicodev for noticing it. In order to make job running indefinitely one needs to add something for it to do. To avoid it one could use only the external scope, without additional job.

This way the removal of the listener will be triggered both on scope cancelation and on Job cancelation. The id is a simple internal implementation detail that ensures that internal listeners can be removed. It is attached to a specific job, so it needs not to be exposed to the external user.

An additional benefit of said approach is that there is a CoroutineScope provided, so with some additional changes, it is possible to make listener a suspended function and run it in the context of a listener, not in the target object. Making listeners suspended is quite useful in concurrent systems to avoid data races for events.


After I wrote the post, I found out that I've already written a related one two years ago: https://dev.to/altavir/to-flow-or-not-to-flow-message-subscription-in-kotlin-57ea.


The cover image is taken from "Большой Ух" cartoon.

💖 💪 🙅 🚩
altavir
Alexander Nozik

Posted on June 30, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related