Don't use lambdas as listeners in Kotlin

Hello, Habr! I present to you the translation of the article Don't use lambdas as listeners in Kotlin by Alex Gherschon

From the translator : Kotlin is a very powerful language that allows you to write code more concisely and quickly. But, recently, there have been too many articles that describe the good sides of the language, talking about pitfalls, and they are, because the language brings new designs that are a black box for a beginner. This article, which is a translation, discusses the use of lambdas as listeners in Android. It will help not to step on the same rake that the author has stepped on, because, in the end, the specifics of the platform do not go away when changing the language.

I came across this problem in my first application that I write on Kotlin and it drove me crazy!

Introduction


I use AudioFocus in a podcast listening app. When a user wants to listen to an episode, it is necessary to request audio focus by passing the OnAudioFocusChangeListener implementation (because we can lose audio focus during playback if the user uses another application that also requires audio focus):

private fun requestAudioFocus(): Boolean {
    Log.d(TAG, "requestAudioFocus() called")
    val focusRequest: Int = audioManager.requestAudioFocus(onAudioFocusChange,
        AudioManager.STREAM_MUSIC,
        AudioManager.AUDIOFOCUS_GAIN)
    return focusRequest == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
}

In this listener, we want to handle various states:

when (focusChange) {
    AudioManager.AUDIOFOCUS_GAIN -> TODO("resume playing")
    AudioManager.AUDIOFOCUS_LOSS -> TODO("abandon focus and stop playing")
    AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> TODO("pause but keep focus")
}

When the episode is finished or the user stops it, you need to release the audio focus :

private fun abandonAudioFocus(): Boolean {
    Log.d(TAG, "abandonAudioFocus() called")
    val focusRequest: Int = audioManager.abandonAudioFocus(onAudioFocusChange)
    return focusRequest == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
}

Road to madness


With my passion for new things, I decided to implement a listener, onAudioFocusChange , using a lambda. I don’t remember whether this was suggested by IntelliJ IDEA or not, but in any case, it was declared as follows:

private lateinit var onAudioFocusChange: (focusChange: Int) -> Unit

In onCreate (), this variable is assigned a lambda:

onAudioFocusChange = { focusChange: Int ->
    Log.d(TAG, "In onAudioFocusChange focus changed to = $focusChange")
    when (focusChange) {
        AudioManager.AUDIOFOCUS_GAIN -> TODO("resume playing")
        AudioManager.AUDIOFOCUS_LOSS -> TODO("abandon focus and stop playing")
        AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> TODO("pause but keep focus")
    }
}

And it worked well, because Now we can request an audio focus, which will stop other applications (for example, Spotify) and play our episode.

Releasing the audio focus also seemed to work, as I received AUDIOFOCUS_REQUEST_GRANTED as a result of the method call abandonAudioFocus class the AudioManager :

11-04 16:08:14.610 D/MainActivity: requestAudioFocus() called
11-04 16:08:14.618 D/AudioManager: requestAudioFocus status : 1
11-04 16:08:14.619 D/MainActivity: granted = true
11-04 16:09:34.519 D/MainActivity: abandonAudioFocus() called
11-04 16:09:34.521 D/MainActivity: granted = true

But as soon as we want to request the audio focus again, we immediately lose it and get the AUDIOFOCUS_LOSS event :

11-04 16:17:38.307 D/MainActivity: requestAudioFocus() called
11-04 16:17:38.312 D/AudioManager: requestAudioFocus status : 1
11-04 16:17:38.312 D/MainActivity: granted = true
11-04 16:17:38.321 D/AudioManager: AudioManager dispatching onAudioFocusChange(-1)
   // for MainActivityKt$sam$OnAudioFocusChangeListener$4186f324$828aa1f
11-04 16:17:38.322 D/MainActivity: In onAudioFocusChange focus changed to = -1

Why do we lose it as soon as requested? What is going on?

Backstage


The best tool to understand the problem is the Kotlin Bytecode Bytecode Viewer :

image

image

Let's see what is assigned to our onAudioFocusChange variable :

this.onAudioFocusChange = (Function1)null.INSTANCE;

You may notice that lambdas are converted to classes of the type FunctionN, where N is the number of parameters. The specific implementation is hidden here, and you need another tool to view it, but that's a different story.

Let's see the implementation of OnAudioFocusChangeListener :

final class MainActivityKt$sam$OnAudioFocusChangeListener$4186f324 implements OnAudioFocusChangeListener {
   // $FF: synthetic field
   private final Function1 function;
   MainActivityKt$sam$OnAudioFocusChangeListener$4186f324(Function1 var1) {
      this.function = var1;
   }
   // $FF: synthetic method
   public final void onAudioFocusChange(int focusChange) {
      Intrinsics.checkExpressionValueIsNotNull(this.function.invoke(Integer.valueOf(focusChange)), "invoke(...)");
   }
}

Now let's check how it is used. RequestAudioFocus method :

private final boolean requestAudioFocus() {
    Log.d(Companion.getTAG(), "requestAudioFocus() called");
    (...)
    Object var10001 = this.onAudioFocusChange;
    if(this.onAudioFocusChange == null) {
       Intrinsics.throwUninitializedPropertyAccessException("onAudioFocusChange");
    }
    if(var10001 != null) {
        Object var2 = var10001;
        var10001 = new MainActivityKt$sam$OnAudioFocusChangeListener$4186f324((Function1)var2);
    }
    int focusRequest = var10000.requestAudioFocus((OnAudioFocusChangeListener)var10001, 3, 1);
    Log.d(Companion.getTAG(), "granted = " + (focusRequest == 1));
    return focusRequest == 1;
}

AbandonAudioFocus method :

private final boolean abandonAudioFocus() {
    Log.d(Companion.getTAG(), "abandonAudioFocus() called");
    (...)
    Object var10001 = this.onAudioFocusChange;
    if(this.onAudioFocusChange == null) {
       Intrinsics.throwUninitializedPropertyAccessException("onAudioFocusChange");
    }
    if(var10001 != null) {
        Object var2 = var10001;
        var10001 = new MainActivityKt$sam$OnAudioFocusChangeListener$4186f324((Function1)var2);
    }
    int focusRequest = var10000.abandonAudioFocus((OnAudioFocusChangeListener)var10001);
    Log.d(Companion.getTAG(), "granted = " + (focusRequest == 1));
    return focusRequest == 1;
}

You may have noticed a problem line in both places:

var10001 = new MainActivityKt$sam$OnAudioFocusChangeListener$4186f324((Function1)var2);

In fact, the following happens: our lambda / Function1 is initialized in onCreate (), but every time we pass it as a SAM to a function, it wraps itself in a new instance of the class that implements the listener interface, which means that two instances will be created the listener and AudioManager API cannot delete when calling abandonAudioFocus () the listener that was created earlier and used when calling requestAudioFocus () . Since the original listener is never deleted, we get the AUDIO_FOCUS_LOSS event in it .

The right approach


Listeners must remain anonymous inner classes, so here is the correct way to define it:

private lateinit var onAudioFocusChange: AudioManager.OnAudioFocusChangeListener
onAudioFocusChange = object : AudioManager.OnAudioFocusChangeListener {
    override fun onAudioFocusChange(focusChange: Int) {
        Log.d(TAG, "In onAudioFocusChange (${this.toString().substringAfterLast("@")}), focus changed to = $focusChange")
        when (focusChange) {
            AudioManager.AUDIOFOCUS_GAIN -> TODO("resume playing")
            AudioManager.AUDIOFOCUS_LOSS -> TODO("abandon focus and stop playing")
            AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> TODO("pause but keep focus")
         }
    }
}

Now the variable onAudioFocusChange refers to the same instance of the listener, which is correctly transmitted to the methods requestAudioFocus and abandonAudioFocus class the AudioManager . Excellent!

Code example


You can look at the generated bytecode and see the problem personally in this repository on GitHub .

Conclusion (but not quite)


With great power comes great responsibility. Do not use lambdas instead of anonymous inner classes for listeners. I learned an important lesson and I hope that you will benefit from it too.

P.S


As one of the readers pointed out in the comments (thanks, Pavlo!), We can declare a lambda as follows and everything will work correctly:

onAudioFocusChange = AudioManager.OnAudioFocusChangeListener { focusChange: Int ->
        Log.d(TAG, "In onAudioFocusChange focus changed to = $focusChange")
        // do stuff
}

Postscript explanation


Is lateinit to blame ?


Some readers have argued that the problem is with the listener declaration with the lateinit modifier . To check whether this is lateinit or not, let's try to implement a lambda with and without this modifier and look at the result.

To remind you what this is about, here is the code for these two lambdas:

// with lateinit
private lateinit var onAudioFocusChangeListener1: (focusChange: Int) -> Unit
// without lateinit
private val onAudioFocusChangeListener2: (focusChange: Int) -> Unit = { focusChange: Int ->
    Log.d(TAG, "In onAudioFocusChangeListener2 focus changed to = $focusChange")
    // do some stuff
}
// in onCreate()
onAudioFocusChangeListener1 = { focusChange: Int ->
    Log.d(TAG, "In onAudioFocusChangeListener1 focus changed to = $focusChange")
    // do some stuff
}

With lateinit (onAudioFocusChangeListener1)
// Declaration
private Function1 onAudioFocusChangeListener1;
// in onCreate()
this.onAudioFocusChangeListener1 = MainActivity$onCreate$1.INSTANCE;
// Class implementation
final class MainActivity$onCreate$1 extends Lambda implements Function1 {
    public static final MainActivity$onCreate$1 INSTANCE = new MainActivity$onCreate$1();
    MainActivity$onCreate$1() {
        super(1);
    }
    public final void invoke(int focusChange) {
        Log.d(MainActivity.Companion.getTAG(), "In onAudioFocusChangeListener1 focus changed to = " + focusChange);
    }
}
// In onCreate(), a button uses a SAM converted lambda to call the AudioManager API
Function1 listener = this.onAudioFocusChangeListener1;
((Button) findViewById(C0220R.id.obtain)).setOnClickListener(new MainActivity$onCreate$2(this, listener));
// Inside MainActivity$onCreate$2 the call to the AudioManager API
if (function1 != null) {
    mainActivityKt$sam$OnAudioFocusChangeListener$4186f324 = new MainActivityKt$sam$OnAudioFocusChangeListener$4186f324(function1);
} else {
    Object obj = function1;
}
Log.d(MainActivity.Companion.getTAG(), "granted = " + (access$getAudioManager$p.requestAudioFocus((OnAudioFocusChangeListener) mainActivityKt$sam$OnAudioFocusChangeListener$4186f324, 3, 1) == 1));


Our lambda is wrapped inside a class that implements the interface (SAM Conversion), but we don’t own a reference to the converted class, which is the problem.

Without lateinit (onAudioFocusChangeListener2)
// Declaration of the lambda
private final Function1 onAudioFocusChangeListener2 = MainActivity$onAudioFocusChangeListener2$1.INSTANCE;
// Class implementation
final class MainActivity$onAudioFocusChangeListener2$1 extends Lambda implements Function1 {
    public static final MainActivity$onAudioFocusChangeListener2$1 INSTANCE = new MainActivity$onAudioFocusChangeListener2$1();
    MainActivity$onAudioFocusChangeListener2$1() {
        super(1);
    }
    public final void invoke(int focusChange) {
        Log.d(MainActivity.Companion.getTAG(), "In onAudioFocusChangeListener1 focus changed to = " + focusChange);
    }
}
// In onCreate(), a button uses a SAM converted lambda to call the AudioManager API
Function1 listener = this.onAudioFocusChangeListener2;
((Button) findViewById(C0220R.id.obtain)).setOnClickListener(new MainActivity$onCreate$2(this, listener));
// Inside MainActivity$onCreate$2 the call to the AudioManager API
if (function1 != null) {
    mainActivityKt$sam$OnAudioFocusChangeListener$4186f324 = new MainActivityKt$sam$OnAudioFocusChangeListener$4186f324(function1);
} else {
    Object obj = function1;
}
Log.d(MainActivity.Companion.getTAG(), "granted = " + (access$getAudioManager$p.requestAudioFocus((OnAudioFocusChangeListener) mainActivityKt$sam$OnAudioFocusChangeListener$4186f324, 3, 1) == 1));


It can be seen that the same problem is without lateinit , so we can not blame this modifier.

Recommended Method


To fix the problem, I recommend using an anonymous inner class:

private val onAudioFocusChangeListener3: AudioManager.OnAudioFocusChangeListener = object : AudioManager.OnAudioFocusChangeListener {
    override fun onAudioFocusChange(focusChange: Int) {
        Log.d(TAG, "In onAudioFocusChangeListener2 focus changed to = $focusChange")
        // do some stuff
    }
}

Which translates to the following in Java:

// declaration
private final OnAudioFocusChangeListener onAudioFocusChangeListener3 = new MainActivity$onAudioFocusChangeListener3$1();
// class definition
public final class MainActivity$onAudioFocusChangeListener3$1 implements OnAudioFocusChangeListener {
    MainActivity$onAudioFocusChangeListener3$1() {
    }
    public void onAudioFocusChange(int focusChange) {
        Log.d(MainActivity.Companion.getTAG(), "In onAudioFocusChangeListener2 focus changed to = " + focusChange);
    }
}
// In onCreate(), a button uses a SAM converted lambda to call the AudioManager API
OnAudioFocusChangeListener listener = this.onAudioFocusChangeListener3;
((Button) findViewById(C0220R.id.obtain)).setOnClickListener(new MainActivity$onCreate$2(this, listener));
// Inside MainActivity$onCreate$2 the call to the AudioManager API
Log.d(MainActivity.Companion.getTAG(), "Calling AudioManager.requestAudioFocus()");
int focusRequest = MainActivity.access$getAudioManager$p(this.this$0).requestAudioFocus(this.$listener, 3, 1);

An anonymous class implements the desired interface and we have a single instance (the compiler does not need to do the SAM conversion , because there is no lambdas here). Excellent!

Best way


The most concise way is to still declare a lambda and use what the documentation calls a conversion method :

private val onAudioFocusChangeListener4 = AudioManager.OnAudioFocusChangeListener { focusChange: Int ->
    Log.d(TAG, "In onAudioFocusChangeListener3 focus changed to = $focusChange")
    // do some stuff
}

This tells the compiler that this is the type to use when converting SAM . The resulting Java code:

// declaration
private final OnAudioFocusChangeListener onAudioFocusChangeListener4 = MainActivity$onAudioFocusChangeListener4$1.INSTANCE;
// Class definition
final class MainActivity$onAudioFocusChangeListener4$1 implements OnAudioFocusChangeListener {
    public static final MainActivity$onAudioFocusChangeListener4$1 INSTANCE = new MainActivity$onAudioFocusChangeListener4$1();
    MainActivity$onAudioFocusChangeListener4$1() {
    }
    public final void onAudioFocusChange(int focusChange) {
        Log.d(MainActivity.Companion.getTAG(), "In onAudioFocusChangeListener3 focus changed to = " + focusChange);
    }
}
// In onCreate(), a button uses a SAM converted lambda to call the AudioManager API
OnAudioFocusChangeListener listener = this.onAudioFocusChangeListener4;
((Button) findViewById(C0220R.id.obtain)).setOnClickListener(new MainActivity$onCreate$2(this, listener));
// Inside MainActivity$onCreate$2 the call to the AudioManager API
Log.d(MainActivity.Companion.getTAG(), "Calling AudioManager.requestAudioFocus()");
int focusRequest = MainActivity.access$getAudioManager$p(this.this$0).requestAudioFocus(this.$listener, 3, 1);

Conclusion (now completely)


As Roman Dawydkin remarkably remarked on Slack :

You can use lambda as a listener only if you use it once

Not a problem if lambda is used in a functional style or as a callback function. A problem only appears when it is used as a listener in an API written in Java that expects the same instance in the Observer pattern. If the API is written in Kotlin, then there is no SAM conversion , and accordingly there is no problem. Someday, the whole API will be like that!

I hope this topic is now extremely clear to everyone.

I would like to thank Rhaquel Gherschon for the proofreading and Christophe Beyls for the comments on this article!

Hurrah!

From the translator : This is just one of the pitfalls. Another example is incorrect brackets in a bunch of RxJava + SAM + Kotlin

Also popular now: