Урок 3: DSL, Императивный и Декларативный подход
Оглавление:
Введение
Почему в Jetpack Compose можно верстать функциями? В свое время Android разработка активно опиралась на Java, но постепенно отошла от него в пользу Kotlin. Сегодня Kotlin практически полностью вытеснил Java с этой арены.
И разработчики Kotlin, помимо прочего, заложили, казалось бы, одну небольшую, но довольно важную особенность языка. Это возможность, опускать последний параметр метода, если он является лямбдой, и записывать его в фигурных скобках.
Вот пара заготовленных примеров для быстрой демонстрации:
fun main() {
fun sayHello(name: String, message: () -> String) {
println("Hello, $name! ${message()}")
}
fun sayBye(name: String, message: () -> String) {
println("Bye, $name! ${message()}")
}
// Вызов без вынесения лямбды
sayHello("Alice", {"How are you today?"})
// Вызов с вынесением лямбды
sayBye("John") {
"See you later!"
}
}
В этом файле сначала объявлены два метода, где первый принимающий параметр строка, а второй лямбда. Ниже эти методы вызываются и в первом случае в скобках у нас два параметра, где первый строка, а второй лямбда. Однако, во втором методе второй параметр отсутствует – круглая скобка закрывается и открывается лямбда.
Это дало возможность разработчикам выстраивать более хитрые вложенные конструкции типа таких:
sayBye("John") {
sayHello("Alice") {
"How are you today?"
}
"See you later!"
}
Пример выше не имеет большого смысла, однако же такая небольшая особенность языка позволяет строить Domain-Specific Language на основе котлин.
DSL — это когда мы даем разработчику возможность описывать какую-то логику или конфигурацию максимально компактным и наглядным образом.
Примером могут служить файлы конфигурации сборки Gradle (те, которые с раcширением .kts, пришедшие на замену Groovy). Сейчас они пишутся на компактном Kotlin DSL, где все что мы здесь видим является вызовом функций. Мы даже можем в них провалиться и немножко подглядеть на сигнатуру.
Соответственно когда мы используем эти функции, они выполняют свою работу в «закулисье», и нам не нужно беспокоиться о том, как это реализовано на более низком уровне.
Свой Kotlin DSL
В следующем небольшом примере мы сделаем свой DSL из этих функций, чтобы хотя бы примерно понимать как под капотом происходит магия. И почему мы видим модифицированные функции в конфигурационных файлах.
class GreetingDSL(val mood: String) {
fun sayHello(name: String, message: String) {
println("[$mood] Hello, $name! $message")
}
fun sayBye(name: String, message: String) {
println("[$mood] Bye, $name! $message")
}
}
fun main() {
// Функция для создания DSL
fun greeting(
mood: String,
block: GreetingDSL.() -> Unit
) {
val dsl = GreetingDSL(mood) // Передаём настроение в DSL
dsl.block() // Выполняем переданный блок в контексте объекта
}
// !!! Объект класса GreetingDSL НЕ создается явно
greeting("Happy") {
sayHello("Alice", "How are you today?")
}
greeting("Serious") {
sayBye("Mark", "Let’s talk tomorrow.")
}
}
Здесь мы уже обернули два наших метода в класс GreetingDSL, а в классе добавили свойство mood. Больше ничего.
Но интересно дальше – в функции greeting(), которая принимает строку с настроением и лямбду. Лямбда содержит GreetingDSL с точкой перед круглыми скобками. Если видите такую конструкцию, значит, что всё, что в этой функции, будет выполняться так, будто мы уже находимся внутри объекта GreetingDSL.
Это значит, что можно вызывать методы и свойства GreetingDSL напрямую, без указания объекта. Не создавая его явно и не обращаясь к нему, как это делали по старинке. Круто, не правда ли? При помощи такого подхода можно скрывать несущественные для описания конфигураций детали реализации внутри класса GreetingDSL
DSL в Jetpack Compose
Таким образом Jetpack Compose — это набор классов и функций представляющих собой DSL для описания интерфейсов. То есть, элементы экрана описываются как код точно также с помощью специфичных для данной предметной области функций. При этом с максимально простым, я бы сказал, до абсурда синтаксисом:
- Хочешь вывести на экран текст? Вызови системную функцию, которая так и называется Text c параметром text. Да, как вы уже должно быть заметили, это все функции. И вопреки привычному синтаксису Kotlin – Composable функции пишутся с большой буквы и не в инфинитиве.
- Хочешь заверстать кнопку? Легко, напиши слово “Кнопка” (а именно вызови соответствующую функцию), в параметры отправь какой-то коллбэк (какой метод должен дергаться при нажатии). А в лямбду можно поместить другую функцию Text, которая разместится внутри Button и будет представлять текст на кнопке. Причем это параметр, который можно опустить и записать именно лямбдой помните почему?
Button(onClick = { }) {
Text(text = "Button")
}
Правильно, потому, что это последний параметр функции Button и он лямбда. Все остальные параметры кнопки легко узнаваемы, типа цветов, форм, обводки или отступов. Но сейчас исключительно обращаю внимание на последний параметр.

Напоминает что-то, не так ли? Да, близко к этому код мы видели в примере буквально пару минут назад.
Разработчики Jetpack Compose представили для нас мощный DSL, который скрыл от нас все несущественное для верстки. Туда действительно не нужно глубоко лезть без надобности, все использование можно сравнить с конструкцией строительного набора, где у тебя есть готовые детальки. Ты собираешь свой лего конструктор и радуешься. Все что нужно знать – это язык Kotlin. Ну а если понадобилось, создать свой кубик лего и использовать наряду со стандартными.
И сейчас мы немного подробнее остановимся на последнем параметре, а именно на интерфейсе RowScope.().
Scope функции
Важной особенностью языка является то, что в Kotlin есть так называемые scope-функции, которые мы уже проходили в рамках KotlinSprint (иногда их ещё называют функции области видимости). Они позволяют временно “погружаться” в контекст объекта или лямбды. Напомню: к стандартным scope-функциям относят: let, run, with, apply и also.
Давайте я коротко напомню для чего мы можем их использовать, а потом вы начнете понимать почему они важны для работы с Jetpack Compose.
- Во-первых то, за что мы любим Kotlin. Это элегантная инициализация. Представьте, что вам нужно инициализировать объект и сразу же настроить его свойства. С помощью, например,
applyилиalsoможно не создавать промежуточных переменных. Мы просто берём объект и “входим” в его контекст, где можно напрямую обращаться к полям (черезthis) или самому объекту (черезit).
val person = Person().apply {
name = "Alice"
age = 20
}
- Во-вторых, некоторые из них позволяют избежать проверки на null. Например, через
letмы можем вызвать цепочку операций только если объект не равенnull.
val text: String? = "Hello World"
text?.let {
println(it.length) // Только если text не null
}
- И, пожалуй, самое интересное – локальный контекст.
with(object) { ... }позволяет не засорять пространство имён и группировать вызовы. Мы будто бы “заходим” в объект и там уже вызываем его методы и обращаемся к свойствам — всё в одной компактной блок-конструкции.
Однако, в дополнение к стандартным scope-функциям Kotlin, ничто не мешает вам создавать собственные. Это может дать дополнительные возможности для контроля контекста и области видимости.
Почему это важно для Jetpack Compose?
Jetpack Compose использует похожий подход “погружения” в контекст, как у scope-функций. Только вместо объектов вроде apply или with мы работаем с DSL-областями (scope), например, с RowScope или ColumnScope.
Что это значит? Представьте, что у вас есть комната с набором инструментов. Если вы зашли в эту комнату, то всё, что вы делаете, относится только к этим инструментам. Например, если в комнате есть только молоток и гвозди, то вы можете забивать гвозди, но не сможете, скажем, покрасить стены. Так вот, RowScope — это такая «комната», где доступны только функции и свойства, связанные с размещением элементов в строку.
В коде с кнопкой мы пишем Text и Button как будто они просто отдельные функции. Но на самом деле они работают внутри специальной области RowScope. То есть мы неявно уже находимся в контексте RowScope, и всё, что мы добавлям в Row, автоматически относится к нему. Не нужно вручную указывать, что Text и Button принадлежат Row. Это работает потому, что Row “открывает” свою область видимости, и внутри фигурных скобок вы как будто находитесь “внутри Row”.
Если представить это без Compose, то можно сказать так:
- У вас есть объект
RowScope, который умеет, например, добавлять элементы. - В лямбде вы пишете команды: “Добавь текст”, “Добавь кнопку”.
- Каждая команда понимается как команда для
RowScope.
val rowScope = RowScope()
rowScope.apply {
addText("Hello")
addButton {
addText("Click me!")
}
}
В Compose мы пишем то же самое, но более лаконично:
Row {
Text("Hello")
Button {
Text("Click me!")
}
}
Теперь давайте повторим первичное определение. Jetpack Compose – это декларативный UI фреймворк, где мы пишем интерфейс с помощью кода на языке программирования Kotlin.
Императивный и Декларативный подход в Android
Вроде как уже понятнее, однако, все еще может смущать слово “декларативный”. Само по себе слово “декларация” подразумевает значение “объявление” или “заявление”. Так вот, самое первое и ключевое, что вам необходимо запомнить, что при декларативном подходе мы сразу объявляем в коде то, что хотим получить в качестве результата на экране.
Еще раз. Раньше, при так называемом классическом подходе через XML, мы описывали в коде “как” мы должны получить тот или иной результат – такой подход назывался императивным. Слово происходит от латинского imperativus – повелительный. Грубо говоря термин означает описание определенного алгоритма или свода правил.
Императивное программирование фокусируется на пошаговом описании процесса достижения результата. Указываются какие действия и в каком порядке должны быть выполнены для получения желаемого состояния интерфейса.
Открою пустой проект c Empty Views для примера, где я добавил немного кода с одним TextView. То есть для того, чтобы вывести на экране текст нужно было:
- Получить экземпляр
TextViewили создать его программно. - Установить текст, который должен отображаться.
- Также можно было бы задать параметры отображения, например размер шрифта, положение текста на экране, выравнивание и так далее.
- И добавить этот элемент в контейнер (например,
LinearLayout,ConstraintLayoutили другой тип макета).
И если обратимся к коду на Compose, то на первый взгляд может показаться, что он тоже императивный. Мол мы же пишем какие-то инструкции. Однако, это не так. Это именно декларативный подход, потому что мы, по сути, декларируем, что:
- вот здесь будет вот такая функция приветствия
Greeting– это произвольное пользовательское название. - В ней уже системная
ComposableфункцияText, которая отвечает за отрисовку текста. Аналог TextView, но к классу View не имеет никакого отношения. - Что у функции есть вот такие параметры самого текста и какой-то модификатор в виде переданного паддинга.
- А выше мы это все дело просто размещаем на экране в другой функции
setContent. Условный аналогsetContentView.
Вот и все, не нужно создавать экземпляр View, обращаться к элементам и их свойствам. Сразу объявляем кастомные Composable функции с нужными параметрами, вызываем эти функции в setContent и система отрисовывает то, что нужно отрисовать. Вот что есть настоящая декларация.
Далее пойдем в хорошем темпе по ключевым темам технологии и наконец начнем писать код.
Бесплатные Telegram-боты для обучения
Практика с проверкой кода и помощью ИИ-ментора
AndroidSprint AI Mentor
Проверяет Pull Request'ы в GitHub, проводит тестовые собеседования с голосом и таймером, помогает разбираться с кодом 24/7
Попробовать ИИ-ментора →KotlinSprint Bot
22 урока Kotlin, 220 тестов, 120 практических задач с код-ревью
Начать обучение Kotlin →