После того, как на Google IO 2019 я увидел доклад про Android Jetpack Compose, захотелось сразу же его попробовать. Тем более, что подход, реализованный в нём, очень напомнил Flutter, которым я интересовался ранее.
Сама библиотека Compose находится в пре-альфа стадии, поэтому доступно не так много документации и статей про нее. Далее я буду полагаться на несколько ресурсов, которые мне удалось найти, плюс открытые исходники библиотеки.
Вот эти ресурсы:
- Android Developers Backstage: Episode 115: Jetpack Compose
- Compose From First Principles
- Diving into Jetpack 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
:
нам нужно написать следующий код:
<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
и бирюзовым фоном:
Существующий 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()
}
}
}
}
Теперь наш экран будет выглядеть так:
Другие цвета и шрифты, которы можно поменять: MaterialTheme.kt#57
Rally Activity содержит хороший пример, как можно кастомизировать тему: source code to RallyTheme.kt
Что посмотреть/почитать
Если вы хотите большего, вы можете собрать проект-образец по инструкции тут.
Как пишут пользователи Windows, сейчас не существует официального способа запустить Compose, но есть неофициальный гайд из kotlinlang Slack.
Вопросы про Compose можно задать разработчикам в канале #compose
kotlinlang Slack.
Оставляйте другие ссылки в комментариях — самые полезные добавлю сюда.
Выводы
Разработка этой библиотеки идет полным ходом, поэтому любые интерфейсы, показанные здесь могут быть изменены. Остается еще множество вещей, про которые можно узнать в исходном коде, как например @Model
и Unidirectional data flow (однонаправленный поток данных). Возможно, это тема для будущих статей.