Автор: Сергей Ешин, Strong Junior Android Developer, DataArt
Уже более полутора лет прошло с тех пор, как Google объявил об официальной поддержке Kotlin в Android, а самые матерые разработчики начали экспериментировать с ним в своих боевых и не очень проектах больше трех лет назад.
Новый язык тепло приняли в Android-сообществе, и подавляющая часть новых проектов на Android стартует с Kotlin на борту. Важно и то, что Kotlin компилируется в JVM-байткод, следовательно, полностью совместим с Java. Значит, в существующих Android-проектах, написанных на Java, тоже есть возможность (более того — потребность) задействовать все фичи Kotlin, благодаря которым он и приобрел столько поклонников.
В статье я расскажу об опыте миграции Android-приложения с Java на Kotlin, трудностях, которые пришлось преодолеть в процессе, и объясню, почему все это было не зря. Статья в большей степени рассчитана на Android-разработчиков, только начинающих изучение Kotlin, и кроме личного опыта, опирается на материалы других членов сообщества.
Why Kotlin?
Кратко опишу фичи Kotlin, из-за которых я перешел на него в проекте, покинув «уютный и до боли знакомый» мир Java:
- Полная совместимость с Java
- Null safety
- Выведение типов
- Extension methods
- Функции как объекты первого класса и лямбды
- Generics
- Coroutines
- Отсутствие checked exception
Приложение DISCO
Это небольшое по объему приложение для обмена скидочными картами, состоящее из 10 экранов. На его примере мы и рассмотрим миграцию.
Коротко об архитектуре
Приложение использует MVVM-архитектуру с Google Architecture Components под капотом: ViewModel, LiveData, Room.
Также, согласно принципам Clean Architecture от Uncle Bob, я выделил в приложении 3 слоя: data, domain и presentation.
С чего начать? Итак, мы представляем себе основные фичи Kotlin и имеем минимальное представление о проекте, который нужно смигрировать. Возникает естественный вопрос «с чего начать?».
На странице официальной документации Android «Начало работы с Kotlin» написано, что, если вы хотите перенести существующее приложение на Kotlin, просто должны начать писать модульные тесты. Когда вы приобретете небольшой опыт работы с этим языком, пишете новый код на Kotlin, существующий Java-код вы должны будете просто конвертировать.
Но есть одно «но». Действительно, простая конвертация обычно (хотя далеко не всегда) позволяет получить рабочий код на Kotlin, однако его идиоматичность оставляет желать лучшего. Дальше я расскажу, как устранить этот пробел за счет упомянутых (и не только) фич языка Kotlin.
Миграция по слоям
Поскольку приложение уже разбито на слои, выполнять миграцию имеет смысл по слоям, начиная с верхнего.
Очередность слоев в ходе миграции показана на следующей картинке:
Мы неслучайно начали миграцию именно с верхнего слоя. Мы тем самым избавляем себя от использования Kotlin-кода в Java-коде. Наоборот, мы делаем так, чтобы Kotlin-код верхнего слоя использовал Java-классы нижнего слоя. Дело в том, что Kotlin изначально проектировался с учетом необходимости взаимодействия с Java. Существующий код Java может быть вызван из Kotlin естественным способом. Мы без труда можем наследоваться от существующих Java-классов, обращаться к ним и применять Java-аннотации к Kotlin-классам и методам. Код на Kotlin также может быть использован в Java без особых проблем, но на это часто требуются дополнительные усилия, например, добавление JVM-аннотации. А зачем делать лишние преобразования в Java-коде, если в итоге он все равно будет переписан на Kotlin?
Для примера посмотрим на генерацию перегрузок.
Обычно, если вы пишете функцию Kotlin со значениями параметров по умолчанию, она будет видна в Java только как полная сигнатура со всеми параметрами. Если вы хотите предоставить многократные перегрузки вызовам Java, можно использовать аннотацию @JvmOverloads:
class Foo @JvmOverloads constructor(x: Int, y: Double = 0.0) {
@JvmOverloads fun f(a: String, b: Int = 0, c: String = "abc") { ... }
}
Для каждого параметра со значением по умолчанию это создаст одну дополнительную перегрузку, которая имеет этот параметр и все параметры справа от него, в удаленном списке параметров. В этом примере будет создано следующее:
// Constructors:
Foo(int x, double y)
Foo(int x)
// Methods
void f(String a, int b, String c) { }
void f(String a, int b) { }
void f(String a) { }
Примеров использования JVM-аннотаций для корректной работы Kotlin можно привести множество. На этой странице документации подробно раскрывается тема вызова Kotlin из Java.
Теперь опишем процесс миграции слой за слоем.
Слой Presentation
Это слой пользовательского интерфейса, содержит экраны с вьюшками и ViewModel, в свою очередь, содержащую свойства в виде LiveData c данными из модели. Далее мы рассмотрим приемы и инструменты, которые оказались полезны при миграции этого слоя приложения.
1. Kapt annotation processor
Как и во всяком MVVM, View привязывается к свойствам ViewModel за счет databinding. В случае Android мы имеем дело с Android Databind Library, которая использует annotation processing. Так вот у Kotlin есть свой annotation processor, и если не внести правки в соответствующий build.gradle файл, проект перестанет собираться. Поэтому мы эти правки внесем:
apply plugin: 'kotlin-kapt'
android {
dataBinding {
enabled = true
}
}
dependencies {
api fileTree(dir: 'libs', include: ['*.jar'])
///…
kapt "com.android.databinding:compiler:$android_plugin_version"
}
Важно помнить, что нужно полностью заменить все вхождения annotationProcessor конфигурации в вашем build.gradle на kapt.
Например, если вы в проекте используете библиотеки Dagger или Room, которые также под капотом задействуют annotation processor для кодогенерации, необходимо указать kapt в качестве annotation processor.
2. Inline functions
Помечая функцию как inline, мы просим компилятор поместить ее по месту использования. Тело функции становится встраиваемым, иными словами, оно подставляется вместо обычного использования функции. Благодаря этому мы можем обойти ограничение type erasure, т. е. стирания типа. При использовании inline-функций мы можем получить тип (класс) в runtime.
Эта особенность Kotlin была использована в моем коде для «извлечения» класса запускаемой Activity.
inline fun <reified T : Activity> Context?.startActivity(args: Bundle) {
this?.let {
val intent = Intent(this, T::class.java)
intent.putExtras(args)
it.startActivity(intent)
}
}
reified — обозначение овеществляемого типа.
В описанном выше примере мы также коснулись такой фичи языка Kotlin, как Extensions.
3. Extensions
Они же расширения. В extensions выносились утилитные методы, что позволило избежать раздутых и монструозных утилит классов.
Приведу пример расширений, задействованных в приложении:
fun Context.inflate(res: Int, parent: ViewGroup? = null): View {
return LayoutInflater.from(this).inflate(res, parent, false)
}
fun <T> Collection<T>?.isNotNullOrEmpty(): Boolean {
return this != null && isNotEmpty();
}
fun Fragment.hideKeyboard() {
view?.let { hideKeyboard(activity, it.windowToken) }
}
Разработчики Kotlin подумали о полезных расширениях для Android заранее, предложив свой плагин Kotlin Android Extensions. Среди возможностей, которые он предлагает, можно выделить View binding и поддержку Parcelable. Подробную информацию о возможностях этого плагина можно найти здесь.
4. Лямбда-функции и функции высшего порядка
С помощью лямбда-функций в Android-коде можно избавиться от неуклюжих ClickListener и сallback, которые в Java реализовывались посредством самописных интерфейсов.
Пример использования лямбда вместо onClickListener:
button.setOnClickListener({ doSomething() })
Также лямбда используются в функциях высшего порядка, например, для функций работы с коллекциями.
Возьмем для примера map:
fun <T, R> List<T>.map(transform: (T) -> R): List<R> {...}
В моем коде есть место, где нужно «намапить» id карточек для их последующего удаления.
При помощи лямбда-выражения, переданного в map, получаю искомый массив id:
val ids = cards.map { it.id }.toIntArray()
cardDao.deleteCardsByIds(ids)
Обратите внимание, что круглые скобки можно вообще не указывать при вызове функции, если лямбда — единственный аргумент, а ключевое слово it — неявное имя единственного параметра.
5. Платформенные типы
Вам неизбежно придется работать с SDK, написанными на Java (включая, собственно, Android SDK). Значит, нужно всегда оставаться настороже с такой особенностью Kotlin and Java Interop как платформенные типы.
Платформенный тип — это тип, для которого Kotlin не может найти информацию о допустимости null. Дело в том, что по умолчанию код на Java не содержит информацию о допустимости null, а NotNull и @Nullable-аннотации используются далеко не всегда. Когда соответствующая аннотация в Java отсутствует, тип становится платформенным. С ним можно работать и как с типом, допускающим null, и как с типом, null не допускающим.
Это означает, что точно как в Java, разработчик несет полную ответственность за операции с этим типом. Компилятор не добавляет рантайм проверки на null и разрешит вам делать все.
В следующем примере мы переопределяем onActivityResult в нашем Activity:
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent{
super.onActivityResult(requestCode, resultCode, data)
val randomString = data.getStringExtra("some_string")
}
В данном случае data — платформенный тип, который может содержать null. Однако, с точки зрения Kotlin-кода, data не может быть null ни при каких обстоятельствах, и вне зависимости от того, укажете ли вы тип Intent как nullable, вы не получите ни предупреждения, ни ошибки от компилятора, так как оба варианта сигнатуры допустимы. Но поскольку получение непустой data не гарантировано, т. к. в случаях с SDK вы не можете это проконтролировать, получение null в данном случае приведет к NPE.
Также в качестве примера можно перечислить следующие места возможного появления платформенных типов:
- Service.onStartCommand(), где Intent может быть null.
- BroadcastReceiver.onReceive().
- Activity.onCreate(), Fragment.onViewCreate() и прочие похожие методы.
Причем бывает так, что параметры метода аннотированы, но студия почему-то теряет Nullability при генерации override.
Слой Domain
Этот слой включает в себя всю бизнес-логику, он отвечает за взаимодействие между слоем данных и презентационным слоем. Ключевую роль здесь играет Repository. В Repository мы проводим необходимые манипуляции с данными, как с серверными так и локальными. Наверх, в слой Presentation, мы отдаем лишь метод интерфейса Repository, скрывающий всю сложность действий с данными.
Как было указано выше, для реализации была использована RxJava.
1. RxJava
Kotlin полностью совместим с RxJava и более лаконичен в связке с ней, нежели Java. Однако и здесь мне пришлось столкнуться с одной неприятной проблемой. Звучит она так: если передать лямбду в качестве параметра метода andThen, данная лямбда не выполнится!
Чтобы убедиться в этом, достаточно написать простой тест:
Completable
.fromCallable { cardRepository.uploadDataToServer() }
.andThen { cardRepository.markLocalDataAsSynced() }
.subscribe()
Содержимое andThen не выполнится. Это в случае с большинством операторов (вроде flatMap, defer, fromAction и множества других) в качестве аргументов ожидается действительно лямбда. А при такой записи с andThen ожидается Completable/Observable/SingleSource. Проблема решается использованием обыкновенных круглых скобок () вместо фигурных {}.
Подробно эта проблема описана в статье «Kotlin and Rx2. How I wasted 5 hours because of wrong brackets».
2. Деструктуризация
Также коснемся такого интересного синтаксиса Kotlin как деструктуризация или деструктуризирующее присваивание. Он позволяет присвоить объект сразу нескольким переменным, разбив его на части.
Представим, что у нас есть метод в API, возвращающий сразу несколько сущностей:
@GET("/foo/api/sync")
fun getBrandsAndCards(): Single<BrandAndCardResponse>
data class BrandAndCardResponse(@SerializedName("cards") val cards: List<Card>?,
@SerializedName("brands") val brands: List<Brand>?)
Компактный способ вернуть результат из данного метода — деструктуризация, как показано на следующем примере:
syncRepository.getBrandsAndCards()
.flatMapCompletable {it->
Completable.fromAction{
val (cards, brands) = it
syncCards(cards)
syncBrands(brands)
}
}
}
Стоит упомянуть, что мультидекларации опираются на конвенцию: классы, которые предполагается деструктурировать, должны содержать функции componentN(), где N — соответствующий номер компонента — члена класса. Т. е. Приведенный выше пример транслируется в следующий код:
val cards = it.component1()
val brands = it.component2()
В нашем примере используется data-класс, который автоматически объявляет componentN() функции. Поэтому мультидекларации работают с ним из коробки.
Более подробно о data-классе поговорим в следующей части, посвященной слою Data.
Слой Data
Этот слой включает в себя POJO для данных с сервера и базы, интерфейсы для работы и с локальными данными, и с данными, полученными с сервера.
Для работы с локальными данными была использована Room, предоставляющая нам удобную обертку для работы с базой данных SQLite.
Первая цель для миграции, которая напрашивается сама собой — POJO, которые в стандартном коде Java представляют собой объемные классы с множеством полей и соответствующих им get/set методов. Сделать POJO более лаконичными можно с помощью Data-классов. Одной строчки кода будет достаточно, чтобы описать описать сущность с несколькими полями:
data class Card(val id:String, val cardNumber:String,
val brandId:String,val barCode:String)
Помимо лаконичности мы получим:
- Переопределенные методы equals(), hashCode() и toString() под капотом. Генерация equals по всем свойствам data-класса крайне удобна при использовании DiffUtil в адаптере, который генерирует вьюшки для RecyclerView. Дело в том, что DiffUtil сравнивает два набора данных, два списка: старый и новый, выясняет, какие изменения произошли, и с помощью notify-методов оптимально обновляет адаптер. И как правило, элементы списка сравниваются с помощью equals.
Таким образом, после добавления нового поля в класс нам не нужно его дописывать еще и в equals c тем, чтобы DiffUtil учитывал новое поле. - Immutable класс
- Поддержка значений по умолчанию, которой можно заменить использование Builder-паттерна.
Пример:
data class Card(val id : Long = 0L, val cardNumber: String="99", val barcode: String = "", var brandId: String="1") val newCard = Card(id =1L,cardNumber = "123")
Еще одна хорошая новость: при настроеном kapt (что описано выше) Data-классы прекрасно работают с Room-аннотациями, что позволяет все сущности базы данных перевести в Data-классы. Также Room поддерживает nullable свойства. Правда, Room пока не поддерживает значения по умолчанию от Kotlin, но на это уже заведен соответствующий баг.
Выводы
Мы рассмотрели только немногие подводные камни, которые могут возникнуть в процессе миграции с Java на Kotlin. Важно, что, хотя проблемы и возникают, особенно при недостатке теоретических знаний или практического опыта, все они решаемы.
Однако удовольствие от написания краткого выразительного и безопасного кода на Kotlin с лихвой окупит все трудности, что возникнут на пути перехода. Могу сказать с уверенностью, что пример проекта DISCO это безусловно подтверждает.
Книги, полезные ссылки, ресурсы
- Теоретический фундамент знания языка позволит заложить книга Kotlin in Action от создателей языка Светланы Исаковой и Дмитрия Жемерова.
Лаконичность, информативность, широкий охват тем, ориентированность на Java-разработчиков и наличие версии на русском языке делают ее лучшим из возможных пособий на старте изучения языка. Я начинал именно с нее. - Источники по Kotlin с developer.android.
- Руководство по Kotlin на русском
- Отличная статья Константина Михайловского, Android-разработчика из компании Genesis, об опыте перехода на Kotlin.
rjhdby
Формально верно, но вводит в заблуждение. Тут лучше бы было полностью раскрыть тему про последнюю лямбду в списке параметров.