При описании работы UI я использую концепцию «события», которое передаётся в логику UI слоя для дальнейшей обработки. Это позволяет мне не делать множество методов и с помощью Kotlin sealed классов описать все возможные события, а также форсировать их обработку через код

// Описываются события который могут происходить в UI
// чтобы потом отправить в код логики обработки
sealed interface UiEvent {

    /** Выбран элемента из списка */
    data class UserProfileClicked(val userId: String) : UiEvent

    /** Нажатие на кнопку подтверждения действия */
    data object ConfirmClicked : UiEvent
}

class MyViewModel: ViewModel() {

    fun onUiEvent(event: UiEvent): Unit = when(event) {
        is UserProfileClicked -> // Обработка события из UI
        is ConfirmClicked -> // Обработка события из UI
    }
}

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

Но этот код имеет одну неточность, которую мне рекомендует IDEA/Android Studio, а многие программисты её принимают на веру как априоре правильную - заменить класс без параметров на data object

Рекомендация в IDEA для изменения подкласса sealed класса/интерфейса
Рекомендация в IDEA для изменения подкласса sealed класса/интерфейса

Неосознанное использование синглтонов в Kotlin

object представляет собой singletone т.е. существует только один экземпляр этого класса в рамках одного процесса и другого быть не может (опускаем случае создание через рефлексию и другие возможности платформ). data object, который появился в Kotlin 1.9, сгенерирует читаемый toString() а также hashcode() и equals() методы.

В моём коде я описываю логику отправки события, которое каждое является уникальным и точно не подходит под описание "один экземпляр на всё приложение". Будь у моего класса хотя бы один параметр в primary конструкторе, то такого предупреждения от IDE с "полезным" советом я не получил. Вам как разработчику надо всё также помнить что вы переносите объект в код. Если нет четкой причины сделать класс data/value/object, то не делайте этого.

Мой же код описания события теперь примет следующий вид:

sealed interface UiEvent {

    data class UserProfileClicked(val userId: String) : UiEvent

    class ConfirmClicked() : UiEvent
}

Также аргументом в отказ от object я привожу, что зачастую классы события со временем могут меняться и дополняться параметрами. При изменении object на класс у вас будет теряться совместимость вызова, так как уже потребуется вызывать конструктор. Выходы есть и из такой ситуации, но всё таки лучше сразу стараться описать всё гибко сразу.

Использование data классов

Добавление data для классов-моделей стало уже неотъемлимой частью написания такого кода.

data class приведет к генерации:

  • Перегрузки equals() и hashcode()

  • Перегрузки toString()

  • Созданию функции copy()

  • Созданию функций-операторов componentN() для использование spread оператора

Зачастую нам нужны именно только перегрузки стандартных методов, по аналогии с теми что появится в результате добавления data к классу, а вот всё остальное даже не пригодится, может и вовсе вы не хотите таких возможностей класса.

R8 или ProGuard помогут вам оптимизировать код и удалять неиспользуемые API классов, так что переживать за лишний код точно не стоит. Обязательно используете оптимизаторы кода на продакшен сборках!

В случае UI событий вполне нормальным может являться. что любые экземпляры класса, представляющего событие, equals() всегда должен возвращать false т.к. они созданы в результате действия пользователя. Тогда может стоит оставить стандартную реализацию equals() и hashcode()? Да, вполне рабочий подход

sealed interface UiEvent {

    class UserProfileClicked(val userId: String) : UiEvent

    class ConfirmClicked() : UiEvent
}

Проблема его что сломаются все проверки в тестах, что ваши пришедшее событие совпадает с ожидаемым при использование assertEquals(expectedEvent, incomingEvent).

Нет тестов - нет проблем ?

Вариантом может быть добавление в свойства уникального идентификатора события: момент времени создания, уникальный ID и др. Это позволит нам добиться желаемой уникальности в событиях

sealed interface UiEvent {

    /**
     * Уникальный идентификатор события
     */
    val id: Uuid

    data class UserProfileClicked(
        val userId: UserId,
        override val id: Uuid = Uuid.random(),
    ) : UiEvent

    data class ConfirmClicked(
        override val id: Uuid = Uuid.random(),
    ) : UiEvent
}

Kotlin предоставил много возможностей и позволил убрать написание однотипного кода, но это стало тем что разработчики стали забывать, что не весь синтаксический сахар позволит реализовать задуманное. В Java же сложность написания чего-то заставляла нас делать выбор осознанным. Не используйте бездумно всё, что вам предлагает язык программирования и IDE, реализуйте логику работы объектов вашей программы соотвественно их поведению!

Что-то из моего примера вам покажется оверинженирингом, ведь с проблемой вы не стоклнулись. Буду рад в комментариях услышать ваше мнение касательно моих предложений и другого синтаксического сахара Kotlin, который может идти во вред.

Комментарии (5)


  1. xlzpm
    21.01.2025 18:57

    Прочел статью. Понял, что все и в правду вошло в привычку, не представляю, как будут смотреть ребята pull request, когда я вместо object для перехода на другой экран, пропишу class, посмотрят как на чокнутого, но опять data object и предполагает под себя единый класс, который передает какой то state в effect