Не используйте лямбды в качестве слушателей в Kotlin

в 13:34, , рубрики: android, kotlin, перевод, Разработка под android

Привет! Представляю вашему вниманию перевод статьи Don't use lambdas as listeners in Kotlin автора Alex Gherschon

От переводчика: Kotlin — очень мощный язык, который позволяет писать код лаконичней и быстрей. Но, в последнее время, появилось слишком много статей, которые описывают хорошие стороны языка, приумалчивая о подводных камнях, а они есть, ведь язык привносит новые конструкции, которые являются черным ящиком для новичка. В этой статье, которая является переводом, рассматривается использование лямбд в качестве слушателей в Android. Она поможет не наступить на те же грабли, на которые наступил и автор, ведь, в конечном итоге, специфика платформы никуда не девается при смене языка.

Я наткнулся на эту проблему в моём первом приложении, которое я пишу на Kotlin, и она свела меня с ума!

Вступление

Я использую AudioFocus в приложении по прослушиванию подкастов. Когда пользователь хочет прослушать эпизод, необходимо запросить аудио-фокус, передав реализацию OnAudioFocusChangeListener (потому что мы можем потерять аудио-фокус при проигрывании, если пользователь использует другое приложение, которое тоже требует аудио-фокус):

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
}

В этом слушателе мы хотим обрабатывать различные состояния:

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")
}

Когда эпизод закончен или пользователь его останавливает, необходимо освободить аудио-фокус:

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

Дорога к безумию

С моей страстью к новым вещам, я решил реализовать слушателя, onAudioFocusChange, с помощью лямбды. Я не помню, было ли это предложено IntelliJ IDEA или нет, но, в любом случае, он был объявлен следующим образом:

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

В onCreate() этой переменной присваивается лямбда:

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")
    }
}

И всё заработало хорошо, т.к. теперь мы можем запросить аудио-фокус, что остановит другие приложения (например, Spotify) и проиграет наш эпизод.

Освобождение аудио-фокуса тоже, вроде бы, работало, т.к. я получал AUDIOFOCUS_REQUEST_GRANTED в качестве результата при вызове метода abandonAudioFocus класса 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

Но как только мы хотим запросить аудио-фокус снова, сразу же его теряем и получаем событие AUDIOFOCUS_LOSS:

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

Почему мы его теряем, как только запросили? Что вообще происходит?

Закулисье

Самый лучший инструмент, чтобы понять проблему — просмотрщик байт-кода Kotlin Bytecode:

image

image

Давайте посмотрим, что присвоено нашей переменной onAudioFocusChange:

this.onAudioFocusChange = (Function1)null.INSTANCE;

Можно заметить, что лямбды преобразуются в классы вида FunctionN, где N — количество параметров. Конкретная реализация здесь скрыта, и понадобится другой инструмент для её просмотра, но это другая история.

Посмотрим реализацию 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(...)");
   }
}

А теперь проверим, как он используется. Метод requestAudioFocus:

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:

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;
}

Вы, возможно, заметили проблемную строку в обоих местах:

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

На самом деле происходит следующее: наша лямбда/Function1 инициализируется в onCreate(), но каждый раз, когда мы передаем её в качестве SAM в функцию, она обертывается в новый экземпляр класса, реализующий интерфейс слушателя, а это значит, что будет создано два экземпляра слушателя и AudioManager API не может удалить при вызове abandonAudioFocus() слушателя, который был создан ранее и использован при вызове requestAudioFocus(). Так как исходный слушатель никогда не удаляется, мы в нём получаем событие AUDIO_FOCUS_LOSS.

Правильный подход

Слушатели должны оставаться анонимными внутренними классами, так что вот правильный способ его определения:

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")
         }
    }
}

Теперь переменная onAudioFocusChange ссылается на один и тот же экземпляр слушателя, который корректно передаётся в методы requestAudioFocus и abandonAudioFocus класса AudioManager. Отлично!

Пример кода

Вы можете посмотреть сгенерированный байткод и увидеть проблему лично в данном репозитории на GitHub.

Заключение (но не совсем)

С большой мощью приходит большая ответственность. Не используйте лямбды вместо анонимных внутренних классов для слушателей. Я получил важный урок и надеюсь, что вам он тоже пошел на пользу.

Постскриптум

Как указал один из читателей в комментариях (спасибо, Pavlo!) мы можем объявить лямбду следующим образом и всё будет работать правильно:

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

Объяснение постскриптума

А виноват ли lateinit?

Некоторые читатели утверждали, что проблема в объявлении слушателя с модификатором lateinit. Чтобы проверить, вина ли это lateinit или нет, давайте попробуем реализовать лямбду с этим модификатором и без него и посмотрим на результат.

Чтобы напомнить о чём речь, вот код этих двух лямбд:

// 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
}

С lateinit (onAudioFocusChangeListener1)

// Declaration
private Function1<? super Integer, Unit> onAudioFocusChangeListener1;

// in onCreate()
this.onAudioFocusChangeListener1 = MainActivity$onCreate$1.INSTANCE;

// Class implementation
final class MainActivity$onCreate$1 extends Lambda implements Function1<Integer, Unit> {
    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));

Наша лямбда обернута внутри класса, который реализует интерфейс (Преобразование SAM), но мы не владеем ссылкой на преобразованный класс, в чем и заключается проблема.

Без lateinit (onAudioFocusChangeListener2)

// Declaration of the lambda
private final Function1<Integer, Unit> onAudioFocusChangeListener2 = MainActivity$onAudioFocusChangeListener2$1.INSTANCE;

// Class implementation
final class MainActivity$onAudioFocusChangeListener2$1 extends Lambda implements Function1<Integer, Unit> {
    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));

Видно, что такая же проблема и без lateinit, поэтому мы не можем обвинять этот модификатор.

Рекомендуемый способ

Чтобы исправить проблему, я рекомендую использовать анонимный внутренний класс:

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
    }
}

Который преобразуется в следующее на 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);

Анонимный класс реализует нужный интерфейс и мы обладаем единственным экземпляром (компилятору не нужно делать преобразование SAM, т.к. здесь нет лямбд). Отлично!

Наилучший способ

Наиболее краткий способ заключает в том, чтобы всё же объявить лямбду и использовать то, что документация называет методом преобразования:

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

Это указывает компилятору, что это тип, который необходимо использовать при преобразовании SAM. Результирующий код на Java:

// 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);

Заключение (вот теперь совсем)

Как замечательно заметил Roman Dawydkin в Slack:

Вы можете использовать лямбду в качестве слушателя только если используете её единожды

Не проблема, если лямбда используется в функциональном стиле или в качестве функции обратного вызова. Проблема проявляется только тогда, когда она используется как слушатель в API, написанном на Java, которое ожидает один и тот же экземпляр в паттерне Наблюдатель. Если API написано на Kotlin, то нет преобразования SAM, соответственно нет и проблемы. Когда нибудь всё API будет таким!

Я надеюсь, что эта тема теперь предельно ясна для каждого.

Я хотел бы поблагодарить Rhaquel Gherschon за вычитку и Christophe Beyls за комментарии по этой статье!

Ура!

От переводчика: Это лишь один из подводных камней. Другой пример — неправильные скобки в связке RxJava + SAM + Kotlin

Автор: nikis

Источник

Поделиться

* - обязательные к заполнению поля