После того, как на Google IO 2019 я увидел доклад про Android Jetpack Compose, захотелось сразу же его попробовать. Тем более, что подход, реализованный в нём, очень напомнил Flutter, которым я интересовался ранее.



Сама библиотека Compose находится в пре-альфа стадии, поэтому доступно не так много документации и статей про нее. Далее я буду полагаться на несколько ресурсов, которые мне удалось найти, плюс открытые исходники библиотеки.


Вот эти ресурсы:



Что такое Android Jetpack Compose?


Раньше весь UI в Android был основан на классе View. Так повелось с первых дней Android. И в связи с этим накопилось множество легаси и архитектурных недостатков, которые могли бы быть улучшены. Но сделать это достаточно сложно, не сломав весь код, написанный на их основе.


За последние годы появилось множество новых концептов в мире клиентских приложений (включая веяния Frontend-а), поэтому команда Google пошла радикальным путём и переписала весь UI-уровень в Android с нуля. Так и появилась библиотека Android Jetpack Compose, включающая в себя концептуальные приёмы из React, Litho, Vue, Flutter и многих других.


Давайте пройдемся по некоторым особенностям существующего UI и сравним его с Compose.


1. Независимость от релизов Android


Существующий UI тесно связан с платформой. Когда появились первые компоненты Material Design, они работали только с Android 5 (API21) и выше. Для работы на старых версиях системы необходимо использовать Support Library.


Compose же входит в состав Jetpack, что делает его независимым от версий системы и возможным для использования даже в старых версиях Android (как минимум с API21).


2. Весь API на Kotlin


Раньше приходилось иметь дело с разными файлами, чтобы сделать UI. Мы описывали разметку в xml, а затем использовали Java/Kotlin код, чтобы заставить ее работать. Затем мы снова возвращались в другие xml-файлы для того чтобы задать темы, анимацию, навигацию,… И даже пытались писать код в xml (Data Binding).


Использование Kotlin позволяет писать UI в декларативном стиле прямо в коде вместо xml.


3. Composable = Композитный: использование композиции вместо наследования


Создание кастомных элементов UI может быть довольно громоздким. Нам необходимо унаследоваться от View или его потомка и позаботиться о многих важных свойствах перед тем, как он правильно заведется. Например, класс TextView содержит около 30 тысяч строк Java-кода. Это связано с тем, что он содержит множество лишней логики внутри себя, которую наследуют элементы-потомки.


Compose подошел с другой стороны, заменяя наследование композицией.


Padding как нельзя лучше подойдет для иллюстрации того, о чем речь:


В существующем UI для того, чтобы отрисовать TextView c отступами в 30dp:


image

нам нужно написать следующий код:


<TextView android:id="@+id/simpleTextView"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:background="@color/cyan"
    android:padding="30dp" <------------------------ NOTE THIS
    android:text="Drag or tap on the seek bar" />

Это означает, что где-то внутри TextView.java или его суперклассов содержится логика, которая знает, как посчитать и отрисовать отступы.


Давайте посмотрим, как можно сделать то же самое в Compose:


// note: the cyan background color is omitted for now to keep it simple
Padding(30.dp) {
    Text("Drag or tap on the seek bar")
}

Изменения
TextView стал просто Text(). Свойство android:padding превратилось в Padding, который оборачивает Text.


Преимущества
Таким образом, Text отвечает только за отрисовку непосредственно текста. Он не знает про то, как считать отступы. С другой стороны, Padding отвечает только за отступы и ничего больше. Он может быть использован вокруг любого другого элемента.


4. Однонаправленный поток данных


Однонаправленный поток данных является важным концептом, если мы говорим, например, про управление состоянием CheckBox в существующей системе UI. Когда пользователь тапает на CheckBox, его состояние становится checked = true: класс обновляет состояние View и вызывает callback из кода, который следит за изменением состояния.


Затем в самом коде, например, во ViewModel, вам нужно обновить соответствующую переменную state. Теперь у вас есть две копии нажатого состояния, которые могут создать проблемы. Например, изменение значения переменной state в��утри ViewModel вызовет обновление CheckBox, что может закончиться бесконечным циклом. Чтобы избежать этого, нам придется придумывать какой-то костыль.


Использование Compose поможет решить эти проблемы, так как в его основе заложен принцип однонаправленности. Изменение состояния будет обрабатываться внутри фреймворка: мы просто отдаем модель данных внутрь. Кроме того, компонент в Compose теперь не меняет свое состояние самостоятельно. Вместо этого он только вызывает callback, и теперь это задача приложения изменить UI.


5. Улучшение отладки


Так как теперь весь UI написан на Kotlin, теперь можно дебажить UI. Я не попробовал это сам, но в подкасте говорили, что в Compose работают дебаггер и точки остановки.


Хватит слов, покажите код


Я знаю, хочется поскорее увидеть, как выглядит UI в коде (спойлер: очень похоже на Flutter, если вы пробовали писать на нем).


Мы начнем с создания нескольких простых View, затем сравним как они выглядят в существующем UI и в Compose.


1. FrameLayout vs Wrap + Padding + Background


Переиспользуем наш пример выше и попробуем сделать этот TextView с отступами в 30dp и бирюзовым фоном:


`TextView` с отступами в `30dp` и бирюзовым фоном

Существующий UI:


<TextView android:id="@+id/simpleTextView"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:background="@color/cyan" <-------------- NOTE THIS
    android:padding="30dp" <------------------------ AND THIS
    android:text="Drag or tap on the seek bar" />

Теперь посмотрим на код, который делает то же самое в Compose:


@Composable
fun MyText() {
    Wrap {
        Padding(30.dp) {
            DrawRectangle(color = Color.Cyan)
            Text("Drag or tap on the seek bar")
        }
    }
}

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


  • DrawRectangle отрисовывает фон
  • Padding отрисовывает отступы
  • Wrap — функция, которая накладывает параметры друг на друга, как FrameLayout.

Легко. Но немного отличается от существующей UI-системы, к который мы все привыкли.


2. Вертикальный LinearLayout vs Column


Теперь попробуем сделать что-то эквивалентное нашему старому доброму LinearLayout.
Чтобы поместить два элемента один под другим, как на картинке ниже, мы можем использовать Column:


Два элемента один под другим

Код будет выглядеть так:


@Composable
fun FormDemo() {
   Column(crossAxisAlignment = CrossAxisAlignment.Start) {
      Text("Click the button below: ")
      Button(text = "Next")
   }
}

Вложенные в Column элемент будут расположены вертикально друг под другом.


2a. Отступы


Вероятно, вы заметили, что текст и кнопка расположены слишком близко к краю. Поэтому добавим Padding.


@Composable
fun FormDemo() {
    Padding(10.dp) { // Новый отступ
        Column(crossAxisAlignment = CrossAxisAlignment.Start) {
            Text("Click the button below: ")
            Button(text = "Next")
        }
    }
}

Выглядит лучше:


Два элемента один под другим с отступом

2b. Интервалы


Мы можем также добавить немного отступов между Text и Button:


@Composable
fun FormDemo() {
    Padding(10.dp) {
        Column(crossAxisAlignment = CrossAxisAlignment.Start) {
            Text("Click the button below: ")
            HeightSpacer(10.dp) // Новый вертикальный интервал
            Button(text = "Next")
        }
    }
}

Как выглядит наш экран теперь:


Два элемента один под другим с отступом и интервалом

2c. Горизонтальный LinearLayout vs Row


Поместим вторую кнопку рядом с первой:


Добавили вторую кнопку

Код для этого:


@Composable
fun FormDemo() {
    Padding(10.dp) {
        Column(crossAxisAlignment = CrossAxisAlignment.Start) {
            Text("Click the button below: ")
            HeightSpacer(10.dp)
            Row { // Новый ряд
                Button(text = "Back") // Новая кнопка
                WidthSpacer(10.dp) // Новый горизонтальный интервал
                Button(text = "Next")
            }
        }
    }
}

Внутри Row две кнопки будут расположены горизонтально. WidthSpacer добавляет расстояние между ними.


2d. Gravity vs Alignment


Выровняем наши элементы по центру, как это делает gravity в текущем UI. Чтобы показать diff, я закомментирую старые строки и заменю их новыми:


@Composable
fun FormDemo() {
    Padding(10.dp) {
//      Column(crossAxisAlignment = CrossAxisAlignment.Start) {
        Column(crossAxisAlignment = CrossAxisAlignment.Center) { // центрирование
            Text("Click the button below: ")
            HeightSpacer(10.dp)
//          Row {
            Row(mainAxisSize = FlexSize.Min) { // ограничиваем размер элемента
                Button(text = "Back")
                WidthSpacer(10.dp)
                Button(text = "Next")
            }
        }
    }
}

У нас получится:


Центральное выравнивание

С crossAxisAlignment = CrossAxisAlignment.Center вложенные элементы будут выравнены по горизонтали по центру. Мы должны также выставить Row параметр mainAxisSize = FlexSize.Min, похожий по поведению на layout_width = wrap_content, чтобы он не растягивался по всему экрану из-за дефолтного mainAxisSize = FlexSize.Max, который ведет себя как layout_width = match_parent.


2d. Замечание


Из того, что мы видели в примерах выше, можно заметить, что все элементы строятся композитно из отдельных функций: padding — отдельная функция, spacer — отдельная функция, вместо того, чтобы быть свойствами внутри Text, Button или Column.


Более сложные элементы, такие как RecyclerView или ConstraintLayout находятся в разработке: поэтому я не смог найти пример с ними в демонстрационных исходниках.


3.Стили и темы


Вы, вероятно, заметили, что кнопки выше по умолчанию фиолетовые. Это происходит потому, что они используют стили по умолчанию. Рассмотрим, как работают стили в Compose.


В примерах выше FormDemo помечена аннотацией @Composable. Теперь я покажу, как этот элемент используется в Activity:


override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
        CraneWrapper{
            MaterialTheme {
                FormDemo()
            }
        }
    }
}

Вместо функции setContentView() мы используем setContent() — функция-расширение из библиотеки Compose.kt.


CraneWrapper содержит дерево Compose и предоставляет доступ к Context, Density, FocusManager и TextInputService.


MaterialTheme позволяет кастомизировать тему для элементов.


Например, я могу изменить основной цвет темы (primary color) на каштановый следующим образом:


override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
        CraneWrapper{
//          MaterialTheme {
            MaterialTheme(colors = MaterialColors(primary = Color.Maroon)) {
                FormDemo()
            }
        }
    }
}

Теперь наш экран будет выглядеть так:


Maroon as Primary Color

Другие цвета и шрифты, которы можно поменять: MaterialTheme.kt#57


Rally Activity содержит хороший пример, как можно кастомизировать тему: source code to RallyTheme.kt


Что посмотреть/почитать


Если вы хотите большего, вы можете собрать проект-образец по инструкции тут.


Как пишут пользователи Windows, сейчас не существует официального способа запустить Compose, но есть неофициальный гайд из kotlinlang Slack.


Вопросы про Compose можно задать разработчикам в канале #compose kotlinlang Slack.


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


Выводы


Разработка этой библиотеки идет полным ходом, поэтому любые интерфейсы, показанные здесь могут быть изменены. Остается еще множество вещей, про которые можно узнать в исходном коде, как например @Model и Unidirectional data flow (однонаправленный поток данных). Возможно, это тема для будущих статей.