Kotlin Delegation и наш пример его использования

Madi Dulatov
Kolesa Group
Published in
7 min readFeb 9, 2024

Я Мади Дулатов, Android-разработчик в Kolesa Group. Занимаюсь поддержкой и добавлением новых фич в команде Kolesa.kz. Сегодня расскажу, как мы сэкономили время сборки проекта и облегчили работу QA с помощью Kotlin Delegation.

Содержание статьи:

  • Проблема долгих сборок и её решение;
  • От каких ошибок защищает Kotlin Delegation;
  • Итоги.
Мади Дулатов

Сократить время сборки

Мы в Kolesa Group всегда ищем варианты оптимизации инструментов разработки для достижения лучшего опыта работы. Что на выходе дает качественный продукт нашим пользователям. Поэтому команда в лице QA-инженеров и разработчиков решила сократить время сборок в нашей системе CI/CD. Достичь этого хотели за счет сокращения количество собираемых апк при билде проекта.

У нас были отдельные билд-варианты — с тестовым окружением и боевым. Они назывались developDebug и developDebugRelease. Чтобы проверить задачи в тестовом окружении, тестировщики вначале скачивали сборку developDebug. Если нужно проверить функционал с боевыми ссылками, то скачивали уже сборку developDebugRelease. Это не очень удобно. К тому же developDebugRelease увеличивало время сборки в CI/CD.

Для решения задачи мы придумали внести пару изменений в dev options. Это наше меню возможностей для удобства тестирования, где можно менять ссылки, добавлять хэдеры в запросы и многое другое. Мой коллега Аманжол Тулепбаев подробно рассказал про эту реализацию на Kolesa Android Meetup.

Идея была простой:

  • сделать переключение между окружениями внутри меню нашего dev options;
  • оставить возможность менять тестовые ссылки из dev options;
  • удалить ненужный билд-вариант developDebugRelease;

Ещё мы учли, что ссылки тестового окружения будут лишними в боевой сборке. Чтобы они не мешали тестировщикам и случайно не ушли к пользователям, добавили требование:

  • убрать ссылки тестового окружения из боевой сборки.

Реализовать решили через слияние source set-ов. Создали два класса с одинаковым названием: один из них содержит ссылки тестового окружения, а другой — боевые. Причём только в тестовой версии должна быть возможность менять ссылки в рантайме.

Мы назвали классы BuildConfigController и положили их в папки debug и release внутри корневой директории. Так при сборке билд-варианта developDebug берётся класс из пути debug пространства, а при сборке боевого приложения используется класс из release. Важный нюанс: классы должны быть идентичны по названию и переменным, но можно менять их содержимое.

Дальше прикрутили переключатель между двумя окружениями внутри dev options. И вуаля — всё работает, тестировщики рады, время сборки уменьшено.

Но у этого подхода есть большой минус. Предположим, мы добавляем новую ссылку внутрь BuildConfigController, лежащего в debug-папке. Тогда нужно добавить боевую ссылку с таким же названием и в класс, который лежит в release-папке. Это правило действует также для случая, когда первым меняем класс, лежащий в директории для боевой сборки. Разработчики в основном пишут код и тестируют в билд-варианте developDebug. Если мы забудем добавить ссылку в релизный класс, то Android Studio нам об этом, к сожалению, не подскажет.

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

Ниже показана эта реализация:

// путь до класса - src/**debug**/java/kz/kolesa/core

object BuildConfigController :
ConfigController,
EndpointConfig {
override var webviewEndpoint: String
get() = sharedPrefences.getString(WEB_VIEW_URL, BuildConfig.WEBVIEW_ENDPOINT)
set(value) = sharedPrefences.putString(WEB_VIEW_URL, value)

override var websiteEndoint: String
get() = sharedPrefences.getString(KEY_WEBSITE_URL, BuildConfig.WEBSITE_URL)
set(value) = sharedPrefences.putString(KEY_WEBSITE_URL, value)
}

// путь до класса - src/**release**/java/kz/kolesa/core

object BuildConfigController :
ConfigController,
EndpointConfig {

override var webviewEndpoint: String = BuildConfig.WEBVIEW_ENDPOINT
set(value) = Unit

override var websiteEndoint: String = BuildConfig.WEBSITE_URL
set(value) = Unit
}

Здесь наш синглтон BuildConfigController наследует два интерфейса — ConfigController, EndpointConfig. Эти интерфейсы внутри себя описывают ссылки и другие конфиги. Для простоты я упоминаю в этой статье только две переменные. На самом деле переменных, как и кода, больше.

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

На помощь приходит Kotlin Delegation

Паттерн делегирования в Kotlin

Kotlin Delegation — одна из крутых особенностей языка Kotlin. Она помогает нам уменьшить бойлерплейтный код, реализуя нативную поддержку паттерна делегирования.

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

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

Давайте рассмотрим быстрый пример. Семья ждёт гостей. Мама делегирует задачи по уборке сыну, а по готовке — дочке.

interface Cleaning {
fun doCleaning()
}

interface Cooking {
fun doCooking()
}

class Son : Cleaning {
override fun doCleaning() {
println("Son did the cleaning")
}
}

class Daughter : Cooking {
override fun doCooking() {
println("Daughter did the cooking")
}
}

class Mother(
son: Son,
daughter: Daughter
) : Cleaning by son, Cooking by daughter {

fun setTable() {
println("Mom set the table")
}
}

fun main() {
val mother = Mother(Son(), Daughter())
// waiting guests
mother.doCleaning()
mother.doCooking()
mother.setTable()
}

В коде выше написаны два интерфейса — Cleaning и Cooking. Их наследуют классы Son и Daughter. Если мы посмотрим класс Mother, то увидим, что уборку и готовку мама делегирует сыну и дочке через ключевое слово by. Можно делегировать больше одного интерфейса. А вот абстрактные классы и открытые для наследования классы делегировать не получится.

Запускаем код из нашего примера. Мы вызвали у матери функции doCleaning() и doCooking(), и соответствующие функции запустились у сына и дочки.

Давайте посмотрим, как это всё работает под капотом. Нам нужна декомпиляция в эквивалент java. Для этого переходим в Tools → Kotlin → Show Kotlin Bytecode → Decompile в Android Studio.

public final class Mother implements Cleaning, Cooking {
// $FF: synthetic field
private final Son $$delegate_0;
// $FF: synthetic field
private final Daughter $$delegate_1;

public final void setTable() {
String var1 = "Mom set the table";
System.out.println(var1);
}

public Mother(@NotNull Son son, @NotNull Daughter daughter) {
Intrinsics.checkNotNullParameter(son, "son");
Intrinsics.checkNotNullParameter(daughter, "daughter");
super();
this.$$delegate_0 = son;
this.$$delegate_1 = daughter;
}

public void doCleaning() {
this.$$delegate_0.doCleaning();
}

public void doCooking() {
this.$$delegate_1.doCooking();
}
}

В коде видно, что класс Mother держит ссылки на объекты классов Son, Daughter под переменными $$delegate_0 и $$delegate_1. Компилятор в классе Mother реализовал функции обертки doCleaning(), doCooking(), внутри которых идёт вызов одноименных функций у сохранённых объектов делегатов. Здесь компилятор Kotlin использует другой паттерн под названием декоратор для поддержки делегирования в сгенерированном коде.

Теперь давайте посмотрим, как нам пригодился шаблон делегирования.

Наша реализация

Итак, мы удалили developDebugRelase и сделали переключатель в debug-меню. Это привело к появлению типичной ошибки.

Чтобы ошибки не повторялись, решили положить классы c релиз-конфигурациями в папку main и переиспользовать их из debug и release пространств. Для удобства сделали это через Kotlin Delegation.

Для начала изменили интерфейс ConfigController и добавили EditableConfigController внутри папки main.

ConfigController теперь содержит ссылки и другие конфигурации. По сути предыдущие два интерфейса объединили в один. В EditableConfigController описываем ссылки и конфиги, которые будут изменяться из dev options.

// путь до файла - src/**main**/java/kz/kolesa/core

interface ConfigController {
val websiteUrl: String
val webviewMobileEndpoint: String
}

interface EditableConfigController {
fun setWebsiteUrl(url: String)
fun setWebviewMobileEndpoint(url: String)
fun reset()
}

Дальше создали реализацию ProdConfigController внутри папки main. Это синглтон, и он содержит боевые ссылки внутри себя. Еще нам нужно создать класс-пустышку EmptyEditableConfigController для боевой сборки, потому что в боевой сборке мы не хотим уметь менять ссылки.

// путь до файла - src/**main**/java/kz/kolesa/core

object ProdConfigController : ConfigController {
override val websiteUrl: String = "<https://kolesa.kz>"
override val webviewMobileEndpoint: String = "<https://m.kolesa.kz>"
}

class EmptyEditableConfigController : EditableConfigController {
override fun setWebsiteUrl(url: String) = Unit
override fun setWebviewMobileEndpoint(url: String) = Unit
override fun reset() = Unit
}

Теперь нужно создать классы для debug-окружения.

// путь до файла - src/**debug**/java/kz/kolesa/core

object DebugConfigController : ConfigController {
override val websiteUrl: String = BuildConfig.WEBSITE_URL
override val webviewMobileEndpoint: String = BuildConfig.WEBVIEW_MOBILE_ENDPOINT
}

class DefaultConfigController : ConfigController {
private val debugEndpointConfig: ConfigController = DebugConfigController

override val websiteUrl: String
get() = sharedPrefences.getString(WEBSITE_ENDPOINT_PROPERTY, debugEndpointConfig.websiteUrl)

override val webviewMobileEndpoint: String
get() = sharedPrefences.getString(WEBVIEW_MOBILE_ENDPOINT_PROPERTY, debugEndpointConfig.webviewMobileEndpoint)
}

Здесь мы создали класс DebugConfigController. Это синглтон, содержащий ссылки тестового окружения. Реализовали класс DefaultConfigController, который наследуется от ConfigController. Он умеет возвращать сохранённые ссылки из хранилища преференсов.

Далее реализуем класс DefaultEditableConfigController. Это наследник интерфейса EditableConfigController. Он сохраняет ссылки в преференсы.

class DefaultEditableConfigController: EditableConfigController {

override fun setWebsiteUrl(url: String) {
sharedPreferences.putString(WEBSITE_ENDPOINT_PROPERTY, url)
}

override fun setWebviewMobileEndpoint(url: String) {
sharedPreferences.putString(WEBVIEW_MOBILE_ENDPOINT_PROPERTY, url)
}

override fun reset() {
sharedPreferences.remove(WEBSITE_ENDPOINT_PROPERTY)
sharedPreferences.remove(WEBVIEW_MOBILE_ENDPOINT_PROPERTY)
}
}

Осталось изменить наши классы BuildConfigController, лежащие в папках debug и release.

// версия для дебаг реализации, путь до файла - src/**debug**/java/kz/kolesa/core
object BuildConfigController :
ConfigController by DefaultConfigController(),
EditableConfigController by DefaultEditableConfigController()

В debug-версии наш класс теперь наследует интерфейсы ConfigController и EditableConfigController, но реализует их через делегирование работы классам DefaultConfigController, DefaultEditableConfigController.

// версия для релиз-реализации, путь до файла - src/**release**/java/kz/kolesa/core
object BuildConfigController :
ConfigController by ProdConfigController,
EditableConfigController by EmptyEditableConfigController()

Для release-версии вышеуказанные интерфейсы реализуются классами ProdConfigController и EmptyEditableConfigController.

А вот как выглядит структура папок и файлов после наших доработок.

Наш класс BuildConfigController стал более простым. Мы можем обращаться к нужным нам переменным и методам так же, как и раньше.

Давайте опишем схему вызовов метода, когда мы собрали сборку в билд-варианте developDebug:

Схема для release-сборок:

Итоги

Такие у нас получились выводы:

  1. Разработчики чаще всего пишут код, используя билд тип developDebug.
  2. Добавление или улучшение ссылок осуществляется через внесение изменений в интерфейсы ConfigController и EditableConfigController.
  3. Так как реализация для release-сборок лежит в main папке проекта, Android Studio нам подскажет об отсутствующих функциях в дебаг- и релиз-классах BuildConfigController.

Результаты:

  1. Мы сократили количество собираемых APK-файлов — убрали те, что не нужны при сборке.
  2. При этом подстраховались от ошибок с помощью Kotlin Delegation.

3. По оценкам QA-инженера, время сборки сократилось на 3 минуты. И конечно, это облегчило работу нашим тестировщикам.

--

--