Нюансы перехода на Kotlin, или Руководство для Android-разработчика по предательству Java

Android-инженер Константин Михайловский рассказал на dou.ua о своем опыте перехода с Java на язык программирования Kotlin в Android-проекте

Итак, на дворе 2018-й год. Если вы —  Android-инженер и уже успели полностью или даже отчасти «пересесть» на язык программирования Kotlin, если полагаться на актуальные рекомендации Google и не беспочвенные восторги многих разработчиков от опыта использования языка, которые я и сам разделяю, вы на правильном пути. С большой вероятностью вы успели попасться на большинство «ошибок новичка», описанных в этой статье (и, что немаловажно, докопаться до причины их возникновения).

Однако, если вы ещё лишь задумываетесь о переходе на Kotlin — будь это в рамках разработки новых фич вашего проекта на Java и написания первых тестов к нему, либо разработка нового проекта с чистого листа — сейчас всё ещё прекрасное время для этого. Советы из статьи будут вам полезны на определённых этапах такого переходного периода.

Вероятно, самый закономерный вопрос, который первым делом придет вам в голову в процессе предательства Java в пользу Kotlin, — «с чего же начать?».

Открываем двери для Kotlin

В то время как на просторах интернета, в том числе в официальной документации языка и на платформе для разработчиков, можно найти доступные руководства по созданию Kotlin-проекта и/или настройки Kotlin в Android Studio и Gradle, в этом разделе я сфокусируюсь лишь на первых потенциальных ловушках на вашем пути предательства Джавы.

Если в своём существующем проекте вы используете annotation processing или библиотеки, которые на нём базируются (в большинстве случаев это DI-фреймворки вроде Dagger 2 и других, Data Binding, Butterknife), без предварительных правок в build.gradle файлах на уровне ваших модулей, они просто перестанут собираться без подключённого плагина kapt (собственный annotation processor у Kotlin):

apply plugin: 'kotlin-kapt'

Также чрезвычайно важный шаг — замена всех вхождений annotationProcessor конфигурации в вашем build.gradle на kapt.

Генерация стабов в данный момент поддерживается из-под коробки.

Подготовка сознания к Null Safety

Важное отличие Kotlin от Java — поддержка первым nullable-типов null-safety «из-под коробки». Поэтому не поленитесь начать знакомство с языком с раздела официальной документации, который довольно исчерпывающе раскрывает основные аспекты этого мощного механизма и работы с ним (в том числе при двухстороннем взаимодействии с Java-кодом).

Первое свидание с Kotlin: data classes

До того как Google объявил, что поддерживает Kotlin на официальном уровне, довольно часто можно было встретить среди рекомендаций — начать процесс перехода с написания unit-тестов на этом языке. И пусть этот совет имеет огромный смысл, ведь unit-тесты — действительно наименее агрессивный путь экспансии Kotlin на кодовую базу и при этом свободный от рисков наткнуться на странные ошибки, связанные с interop двух языков. Однако вряд ли вы испытаете катарсис, который способен принести «сладкий» и лаконичный синтаксис языка, подталкивающий к тем же приемам функционального программирования (unit-тесты в большинстве случаев — исключительно императивный стиль). Попробуйте параллельно начать делать вкрапления языка с написания POJO с помощью data-классов, о которых вы наверняка, даже не будучи знакомым с языком на практике, могли слышать раньше.

Предположим, вам нужно написать класс сущности для фильма Movie. Одной строчки кода будет достаточно!

data class Movie(val id: Long, val title: String, val director: String, val releaseDate: Date)

Помимо лаконичности здесь вы получаете:

— Переопределённые методы equals(), hashCode() и toString() под капотом;

— Immutable класс, неявно наследующийся от Any (в отличие от Object в Java) с immutable-полями (но это не точно) и неявными публичными геттерами и сеттерами для каждого. Создание экземпляра такого класса будет выглядеть так (обратите внимание на отсутствие ключевого слова new):

Movie(42L, "Isle of Dogs", "Wes Anderson", Date())

— Метод copy(), который позволяет клонировать экземпляр данного класса и может быть полезен в том случае, если вы, например, пожелаете создать новый неизменяемый объект на основе существующего, но с отличающимися значениями одного или нескольких полей (при условии, что они не private). Такой подход будет для вас первым шагом навстречу функциональному стилю:

val clonedMovie = existingMovie.copy(id = 43L)

Предупреждение № 1 При создании экземпляра такого класса на Java с помощью copy() вам придётся определить значения для каждого из полей.

Предупреждение № 2 На всякий случай предупрежу, что этот метод недоступен для экземпляров обычных (не data) классов.

— Поддержка значений по умолчанию, которой можно заменить использование Builder-паттерна.

data class Movie(val id: Long = 0L, val title: String = "", val director: String = "", val releaseDate: Date, val description: String? = null)
...
val movie = Movie(releaseDate = Date(), title = "The Darjeeling Limited")

Имейте в виду, что data-классы пока что не могут наследоваться друг от друга. Однако вы можете счесть полезными sealed-классы, неявно абстрактные. Например, в тех случаях, когда вам необходимо определить различные состояния загрузки данных или экрана. Или в любых других ситуациях, где ограниченные иерархии классов будут уместными.

Data-классы + Parcelable

Начиная с версии 1.1.4, больше нет необходимости писать boilerplate-реализации методов parcelable для поддержки де/сериализации ваших объектов, так как за вас это сделает аннотация @Parcelize.

@Parcelize
data class Movie(val id: Long, val title: String, val director: String, val releaseDate: Date)

Только не забудьте применить Android Extensions plugin:

apply plugin: 'kotlin-android-extensions'

И определить значение experimental-флага как true.

android {
   ...

   androidExtensions {
       experimental = true
   }
}

Экспериментальный статус расширения (к моменту написания этого материала) указывает на тот факт, что перед вами всё ещё не окончательный вариант этого API. Есть вероятность глубоко зарытых багов в его работе (пока что я с таковыми не сталкивался, но возможно всё). И обновление API в будущем потенциально способно «поломать» ваш код, и вам стоит использовать эту аннотацию в продакшн-коде на свой страх и риск.

Data-классы в сочетании с часто используемыми библиотеками

Если вы используете замечательную Room Persistence Library, вы всё так же при подключённом kapt можете писать data-классы для сущностей вашей базы данных, которые прекрасно работают с Room-аннотациями.

@Entity(tableName = "movies")
data class Movie(
        @PrimaryKey @ColumnInfo(name = "id") val id: Long,
        @ColumnInfo(name = "title") val title: String,
        @ColumnInfo(name = "director") val director: String,
        @ColumnInfo(name = "date") val releaseDate: Long
)

С Nullable-свойствами также нет никаких проблем. Но на тот случай, если вам вдруг станет любопытно так же, как нашей команде в своё время, поддерживает ли Room значения по умолчанию Kotlin, вас ждёт разочарование.

А что насчёт библиотек для сериализации данных?

В случае с одним из самых популярных в этой области GSON, необходимости в дополнительных конвертерах и плагинах нет. Библиотека будет сериализовать ваши POJO с таким же успехом, как и их Java-версии:

data class Movie(
        @SerializedName("id") val id: Long,
        @SerializedName("title") val title: String,
        @SerializedName("director") val director: String,
        @SerializedName("releaseDate") val releaseDate: Date
)

Однако так же, как и в случае с Room, значения по умолчанию не поддерживаются.

Для совместимости Jackson c Kotlin вам необходимо добавить зависимость специального модуля.

Аналогичным образом дела обстоят и с Moshi, для совместимости с которым потребуется добавить зависимость:

implementation 'com.squareup.moshi:moshi-kotlin:1.x.y'

Сводим Kotlin с Java: Interoperability

Предположим, теперь вы готовы зайти дальше уровня data-классов и начать писать на Kotlin более сложные классы для ваших активити, фрагментов, presenter-, view model-, interactor-, repository- и других классов в зависимости от вашей архитектуры.

Разумеется, с некоторой вероятностью в перспективе вы планируете избавиться от Джавы в вашем проекте чуть более, чем полностью. Но перед этим вам придётся пройти длинный путь устранения конфликтов между языками, которые могут разрушить вашу жизнь (по крайней мере на пару часов).

Так как Kotlin был изначально спроектирован как JVM-язык, полностью совместимый с Java и наоборот, вы без труда можете наследоваться от существующих Java-классов, обращаться к ним и применять Java-аннотации к вашим Kotlin-классам и методам.

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

Going Static

Например, в Kotlin нет ключевого слова static, просто смиритесь с этим.

Но не стоит отчаиваться, ведь в вашем распоряжении есть companion object. Это механизм объявления объекта внутри класса, способного содержать внутри константы и методы, синтаксически доступные со стороны Java в таком же виде, как и статические поля или методы Java. Но для того, чтобы при компиляции сгенерировались и статический метод класса, в котором находится этот объект, и метод этого объекта сам по себе, пометьте его как @JvmStatic (по умолчанию без этой аннотации метод companion-объекта будет вам доступен с помощью ссылки на Companion инстанс). Эта страница документации подробно раскрывает тему companion objects, создания синглтонов и object expressions в Kotlin.

Но прежде чем вы заключите все ваши константы в companion object-ы по всему проекту, возьмите во внимание тот факт, что такие объекты не так дешевы, как кажутся. Очень рекомендую всем, кто переходит на Kotlin, ознакомиться с циклом статей «Kotlin’s hidden costs» (part 1, part 2, part 3), открывающих обратную сторону (с точки зрения байткода) синтаксического сахара языка. Рекомендую обратить особое внимание на советы об использовании inline-функций для оптимизации производительности ваших лямбда-выражений, а также на советы по избежанию избыточной автоупаковки / автораспаковки «под капотом».

Коллекции

Когда речь идёт о коллекциях, Kotlin полностью полагается на классы стандартной библиотеки Java, расширяя их возможности с помощью дополнительных функций для их объявления (listOf(), mapOf(), etc) и их модификаций и преобразований. Они довольно часто оказываются полезными и удобными в использовании, и с коллекциями как таковыми в Kotlin в целом всё довольно прозрачно. Ну… почти 😉 Обратите внимание на то, что List, Map и Set — это алиасы для неизменяемых JDK-реализаций коллекций, и попытки изменения их содержимого выльются в UnsupportedOperationException, что логично.

Generics

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

Возвращаемся к unit-тестам

Если вы работаете по TDD/BDD или хотя бы пишете unit-тесты к уже готовой бизнес-логике вашего приложения (ремарка: всегда, при малейшей возможности, покрывайте ваш код тестами), высока вероятность, что вы будете использовать для этих нужд Mockito.

Однако первым камнем преткновения при написании unit-тестов к Kotlin-классам может оказаться отсутствие поддержки со стороны Mockito из-под коробки создания моков к final-классам. В Kotlin классы всегда final по умолчанию, до тех пор, пока вы явно не обозначите их как open. Согласно «Effective Java», 3rd Edition, Item 19: Design and document for inheritance or else prohibit it, это вполне резонное решение. В данной ситуации вы можете поступить так:

  • либо открыть тестируемый класс для наследования с помощью упомянутого модификатора open;
  • либо применить небольшой хак к Mockito, создав файл с названием org.mockito.plugins.MockMaker, обязательно в папке test/resources/mockito-extensions. Внутри он должен содержать строку: mock-maker-inline.

Она подключит плагин, который будет генерировать моки для final-классов, что позволит вам писать к ним жизнеспособные тесты.

Также от себя хочу порекомендовать библиотеку Ника Хаармана mockito-kotlin, которая предоставляет множество полезных вспомогательных функций. Они способны с помощью возможностей Котлина подсластить инициализацию моков, верификацию обращений к ним, etc, с помощью Mockito.

А например, вспомогательные inline-методы, возможностями которых воспользовался автор библиотеки, позволяют определять поведение мока сразу же при его инициализации. Например, можем сразу же задать возвращаемое значение для метода получения фильмов мок-объекта MoviesRepository:

val moviesRepo = mock { on { getMovies() } doReturn emptyList() }

Kotlin Android Extensions

Написание любого класса для Activity мы чаще всего начинаем с определения layout-а внутри onCreate метода. Если вы используете базирующиеся на annotation-processing альтернативы для получения вьюх из xml-файла классическому findViewById-подходу или используете его же, посмотрите в сторону плагина Kotlin Android Extensions (не путайте с Android KTX 🙂 ), в который вы можете с некоторой вероятностью влюбиться с первых же строк кода.

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

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    setContentView(R.layout.activity_home)

    tvResult.text = "Random greetings text"
    btnFinish.setOnClickListener {
        Toast.makeText(this@BreathingActivity, "Voila!", Toast.LENGTH_LONG).show()
    }
}

Больше об этом плагине и его подключении можно прочитать, перейдя по этой ссылке.

Примечание И сразу остановлюсь на опасном моменте при обращении к синтетическим свойствам, определяющим идентификаторы вьюх, с которым столкнулась вся наша команда. Если вы используете include-блоки в ваших xml-layouts, убедитесь, что каждый из таких блоков НЕ переопределяет идентификатор корневого layout во включённом layout-файле.

Например, при такой конфигурации вас ждёт неминуемый креш:

<android.support.constraint.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/rootLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/white"
    android:keepScreenOn="true">

    <include
        android:id="@+id/randomTextViewLayout"
        layout="@layout/layout_random_text_view" />


</android.support.constraint.ConstraintLayout>
<TextView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/randomTextView"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content">
</TextView>
import kotlinx.android.synthetic.main.activity_home.*

class HomeActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        setContentView(R.layout.activity_home)
        randomTextView.text = "Hi!" // will lead to NPE
    }
}

В данном случае исходный id включенного TextView (randomTextView) переопределён и таким образом не может быть найден. Оптимальным решением будет вообще при такой возможности оставить саму include-область без идентификатора, чтобы избежать конфузов.

Kotlin и Data Binding

Если вы используете в проекте DataBinding и не имеете возможности быстро мигрировать на Kotlin Android Extensions, вы можете обнаружить множество ошибок компиляции после перехода на kapt.

Чтобы вернуть поддержку совместимости Data Binding с Котлином, необходимо добавить в список зависимости в build.gradle зависимость компилятора:

kapt 'com.android.databinding:compiler:x.y.z'

X.y.z. здесь определяют текущую версию Gradle: они должны совпадать.

Потенциальные ловушки при общении с SDK, написанными на Java

В то время, как вам неизбежно придётся работать с SDK, написанными на Java (включая, собственно, Android SDK), нужно всегда оставаться на стороже, когда речь идёт о nullable аргументах (по умолчанию в Java), открытых для переопределения методов.

Предположим, вам нужно переопределить onActivityResult в вашем Activity.

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent) {
        super.onActivityResult(requestCode, resultCode, data)
        val randomString = data.getStringExtra("some_string")
    }

Замечаете что-нибудь подозрительное в этом сниппете?

Всегда есть шанс случайно упустить оператор ? после типа аргумента метода — в данном случае Intent, — который допускал бы null-значения. Здесь же, с точки зрения Kotlin-кода, data не может быть null ни при каких обстоятельствах, и, вне зависимости от того, укажете ли вы тип Intent как nullable или нет, вы не получите ни предупреждения, ни ошибки от компилятора, так как оба варианта сигнатуры допустимы. Но поскольку получение не пустой data не гарантировано, так как в случаях с SDK вы не можете это проконтролировать, получение null в данном случае приведёт к NPE. В случае, если вы укажете Intent?-тип, любые попытки вызвать метод такого объекта приведут к ошибке на этапе компиляции без должной проверки на null посредством ?-оператора.

Работаем с лямбдами: Android SDK

Как вы могли заметить в одном из сниппетов кода выше, с функции высшего порядка избавляют нас от большого количества бойлерплейт-кода — в том числе, когда речь идёт о реализации собственных click listenerов. Начать постижение дзен в написании красивых и производительных лямбд (и, к примеру, понять, в каких случаях уместно пренебречь фигурными скобками {} для достижения максимальной лаконичности), можно с этой страницы документации.

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

Предположим, что мы уже используем Kotlin Android Extensions в проекте. Давайте реализуем простые адаптер в связке с ViewHolder для списка фильмов на основе RecyclerView, каждый элемент в котором кликабелен, и выбор каждого должен быть каким-то образом обработан извне.

class MoviesAdapter(
        var movies: List,
        private val itemClick: (Movie) -> Unit
) : RecyclerView.Adapter() {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MovieHolder {
        val itemView = LayoutInflater.from(parent.context).inflate(R.layout.item_movie,
                parent, false)
        return MoviesAdapter.MovieHolder(itemView)
    }

    override fun onBindViewHolder(holder: MovieHolder, position: Int) {
        val currentMovie = movies[position]

        holder.setup(currentMovie, itemClick)
    }

    override fun getItemCount(): Int = movies.size

    class MovieHolder(
            override val containerView: View
    ) : RecyclerView.ViewHolder(containerView), LayoutContainer {

        fun setup(movie: Movie, itemClick: (Movie) -> Unit) {
            with(containerView) {
                tvTitle = currentMovie.title
                tvDirector = currentMovie.director
                tvReleaseDate = currentMovie.releaseDate.asFormatted()
                setOnClickListener { itemClick.invoke(currentMovie) }
            }
        }
    }

}

Вы наверняка заметили (Movie) -> Unit в качестве последнего параметра конструктора. В данном случае Movie выступает в качестве типа получателя, а Unit — в качестве результата выполнения функции и, таким образом, заменяет гипотетический click listener интерфейс, который вам бы пришлось реализовывать, имей вы дело с Java.

Ещё один участок, который мог привлечь ваше внимание, — класс MovieHolder сам по себе. Как видите, он реализует интерфейс LayoutContainer, который преобразовывает ViewHolder класс элемента списка в контейнер и таким образом активирует кеширование для вьюхи элемента (стратегию кеширования вы вполне можете кастомизировать).

Важно Не забудьте переопределить свойство containerView: View в конструкторе вашего ViewHolder.

И, наконец, вы определённо могли заметить выражение с with, которое, на самом деле, представляет из себя вызов одноимённой встроенной функции, позволяющей вам вызывать серию из методов текущего объекта.

Ещё одна ремарка Обратите внимание, что return внутри лямбды возвращает нас не из функции, в которой лямбда вызывается, а из самого лямбда-выражения.

Kotlin vs RxJava(2): And then… Nothing!

Kotlin значительно устраняет многословность RxJava в связке с Java (при условии, что вы не использовали Retrolambda или Jack) и полностью совместим с ней. Однако вселенная — место не самое идеальное, и на ловушки можно наткнуться и в этом тандеме.

Спойлер: всё из-за скобочек {}.

В своё время я стал жертвой печально известной проблемы с andThen оператором, которая подробно описана в статье «Kotlin and Rx2. How I wasted 5 hours because of wrong brackets». Передача лямбды в качестве параметра метода приведёт к разочаровывающему экзистенциальному ничему. Достаточно написать простой тест для выражения вроде

Completable.fromCallable { someRepository.removeData() }
    .andThen { anotherRepository.removeAnotherData() }
    .subscribe()

чтобы убедиться в этом воочию: содержимое andThen не выполнится. Всё потому, что пока в случае с большинством операторов вроде flatMap, defer, fromAction и огромного количества других, в качестве их аргументов ожидается действительно лямбда, при такой записи с andThen ожидается Completable/Observable/SingleSource. Проблема решается использованием обыкновенных круглых скобок () вместо фигурных {}.

К моменту публикации статьи этот баг всё ещё живёт застывшим в состоянии «under discussion».

Выводы

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

Однако, на мой взгляд, он того стоит. Даже решение открыть себя для новый язык программирования, выйти из зоны комфорта Java послужит важной вехой в вашем развитии как Android-разработчика. Так что при любой возможности — плавно, итеративно попробуйте перестроить своё сознание, свой проект на Kotlin. Помните, что все эти маленькие вкрапления боли, описанные в статье, — ничто по сравнению с тем, насколько вам приятно будет писать код на этом языке.

Полезные ссылки и ресурсы

  • Посмотрите в сторону Android KTX. Это набор extension-функций Android SDK, которые значительно сэкономят ваше время на написание кода для заполнения Bundle, работы с Shared Preferences, bitmap-изображениями, анимациями, span-ами и многим другим. Помимо официальной документации, я бы порекомендовал ознакомиться с этой статьей-экскурсией по KTX — «Exploring KTX for Android».
  • Обращайтесь время от времени к Kotlin Styleguide for Android.
  • Книжка «Kotlin in Action» от создателей языка Светланы Исаковой и Дмитрия Жемерова. Вы точно не пожалеете о её прочтении!

Ещё интересное для вас:
— Биты, байты, Ада Лавлейс — тест на знание околоIT.
— Level Up — события и курсы, на которых можно поднять свой уровень.
— Работа мечты — лучшие IT-вакансии для вас.