Привет, Хабр! Представляю вашему вниманию перевод статьи Don't use lambdas as listeners in Kotlin автора Alex Gherschon
От переводчика: Kotlin — очень мощный язык, который позволяет писать код лаконичней и быстрей. Но, в последнее время, появилось слишком много статей, которые описывают хорошие стороны языка, приумалчивая о подводных камнях, а они есть, ведь язык привносит новые конструкции, которые являются черным ящиком для новичка. В этой статье, которая является переводом, рассматривается использование лямбд в качестве слушателей в Android. Она поможет не наступить на те же грабли, на которые наступил и автор, ведь, в конечном итоге, специфика платформы никуда не девается при смене языка.
Я наткнулся на эту проблему в моём первом приложении, которое я пишу на Kotlin, и она свела меня с ума!
Я использую AudioFocus в приложении по прослушиванию подкастов. Когда пользователь хочет прослушать эпизод, необходимо запросить аудио-фокус, передав реализацию OnAudioFocusChangeListener (потому что мы можем потерять аудио-фокус при проигрывании, если пользователь использует другое приложение, которое тоже требует аудио-фокус):
В этом слушателе мы хотим обрабатывать различные состояния:
Когда эпизод закончен или пользователь его останавливает, необходимо освободить аудио-фокус:
С моей страстью к новым вещам, я решил реализовать слушателя, onAudioFocusChange, с помощью лямбды. Я не помню, было ли это предложено IntelliJ IDEA или нет, но, в любом случае, он был объявлен следующим образом:
В onCreate() этой переменной присваивается лямбда:
И всё заработало хорошо, т.к. теперь мы можем запросить аудио-фокус, что остановит другие приложения (например, Spotify) и проиграет наш эпизод.
Освобождение аудио-фокуса тоже, вроде бы, работало, т.к. я получал AUDIOFOCUS_REQUEST_GRANTED в качестве результата при вызове метода abandonAudioFocus класса AudioManager:
Но как только мы хотим запросить аудио-фокус снова, сразу же его теряем и получаем событие AUDIOFOCUS_LOSS:
Почему мы его теряем, как только запросили? Что вообще происходит?
Самый лучший инструмент, чтобы понять проблему — просмотрщик байт-кода Kotlin Bytecode:


Давайте посмотрим, что присвоено нашей переменной onAudioFocusChange:
Можно заметить, что лямбды преобразуются в классы вида FunctionN, где N — количество параметров. Конкретная реализация здесь скрыта, и понадобится другой инструмент для её просмотра, но это другая история.
Посмотрим реализацию OnAudioFocusChangeListener:
А теперь проверим, как он используется. Метод requestAudioFocus:
Метод abandonAudioFocus:
Вы, возможно, заметили проблемную строку в обоих местах:
На самом деле происходит следующее: наша лямбда/Function1 инициализируется в onCreate(), но каждый раз, когда мы передаем её в качестве SAM в функцию, она обертывается в новый экземпляр класса, реализующий интерфейс слушателя, а это значит, что будет создано два экземпляра слушателя и AudioManager API не может удалить при вызове abandonAudioFocus() слушателя, который был создан ранее и использован при вызове requestAudioFocus(). Так как исходный слушатель никогда не удаляется, мы в нём получаем событие AUDIO_FOCUS_LOSS.
Слушатели должны оставаться анонимными внутренними классами, так что вот правильный способ его определения:
Теперь переменная onAudioFocusChange ссылается на один и тот же экземпляр слушателя, который корректно передаётся в методы requestAudioFocus и abandonAudioFocus класса AudioManager. Отлично!
Вы можете посмотреть сгенерированный байткод и увидеть проблему лично в данном репозитории на GitHub.
С большой мощью приходит большая ответственность. Не используйте лямбды вместо анонимных внутренних классов для слушателей. Я получил важный урок и надеюсь, что вам он тоже пошел на пользу.
Как указал один из читателей в комментариях (спасибо, Pavlo!) мы можем объявить лямбду следующим образом и всё будет работать правильно:
Некоторые читатели утверждали, что проблема в объявлении слушателя с модификатором lateinit. Чтобы проверить, вина ли это lateinit или нет, давайте попробуем реализовать лямбду с этим модификатором и без него и посмотрим на результат.
Чтобы напомнить о чём речь, вот код этих двух лямбд:
Наша лямбда обернута внутри класса, который реализует интерфейс (Преобразование SAM), но мы не владеем ссылкой на преобразованный класс, в чем и заключается проблема.
Видно, что такая же проблема и без lateinit, поэтому мы не можем обвинять этот модификатор.
Чтобы исправить проблему, я рекомендую использовать анонимный внутренний класс:
Который преобразуется в следующее на Java:
Анонимный класс реализует нужный интерфейс и мы обладаем единственным экземпляром (компилятору не нужно делать преобразование SAM, т.к. здесь нет лямбд). Отлично!
Наиболее краткий способ заключает в том, чтобы всё же объявить лямбду и использовать то, что документация называет методом преобразования:
Это указывает компилятору, что это тип, который необходимо использовать при преобразовании SAM. Результирующий код на Java:
Как замечательно заметил Roman Dawydkin в Slack:
Не проблема, если лямбда используется в функциональном стиле или в качестве функции обратного вызова. Проблема проявляется только тогда, когда она используется как слушатель в API, написанном на Java, которое ожидает один и тот же экземпляр в паттерне Наблюдатель. Если API написано на Kotlin, то нет преобразования SAM, соответственно нет и проблемы. Когда нибудь всё API будет таким!
Я надеюсь, что эта тема теперь предельно ясна для каждого.
Я хотел бы поблагодарить Rhaquel Gherschon за вычитку и Christophe Beyls за комментарии по этой статье!
Ура!
От переводчика: Это лишь один из подводных камней. Другой пример — неправильные скобки в связке RxJava + SAM + Kotlin
От переводчика: 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:


Давайте посмотрим, что присвоено нашей переменной 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
