Привет, Хабр! Представляю вашему вниманию перевод статьи 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:
![image](https://habrastorage.org/r/w1560/getpro/habr/post_images/222/6bf/66c/2226bf66cbd2f1ed686a6459fe51ca9f.png)
![image](https://habrastorage.org/r/w1560/getpro/habr/post_images/e3f/d44/6ab/e3fd446abf39afac9c2a2d9ba6b2de25.png)
Давайте посмотрим, что присвоено нашей переменной 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:
![image](https://habrastorage.org/getpro/habr/post_images/222/6bf/66c/2226bf66cbd2f1ed686a6459fe51ca9f.png)
![image](https://habrastorage.org/getpro/habr/post_images/e3f/d44/6ab/e3fd446abf39afac9c2a2d9ba6b2de25.png)
Давайте посмотрим, что присвоено нашей переменной 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