Наконец, настал момент, когда не нужно собирать самостоятельно Android Studio, чтобы попробовать новый декларативный UI framework для Android. Jetpack Compose стал доступен в виде первого Dev Preview в Maven-репозитории Google. С такой новости началось моё утро понедельника. И сразу же возникло желание посмотреть, что из себя представляет набор инструментов, который так ждали.



Своё знакомство я решил начать сразу с попытки внедрения в pet-project, опубликованный в Google Play. Тем более, в нем давно хотелось сделать страницу “О приложении”. В этой статье я расскажу об основных компонентах и этапах подключения Compose:


  1. Подключение зависимостей
  2. Темы и стили. Интеграция с существующими в проекте.
  3. Accessibility и UI-тесты.
  4. Основные компоненты и аналоги наследников View.
  5. Работа со State.

Подключение зависимостей


Для начала я обновил студию c 3.5 до 3.5.1 (зря), добавил базовые зависимости. Полный список можно увидеть в статье Кирилла.


//корневой build.gradle
ext.compose_version= '0.1.0-dev01’

//build.gradle модуля
dependencies{
    ...
    implementation "androidx.compose:compose-runtime:$compose_version"
    kapt "androidx.compose:compose-compiler:$compose_version"

    implementation "androidx.ui:ui-layout:$compose_version"
    implementation "androidx.ui:ui-android-text:$compose_version"
    implementation "androidx.ui:ui-text:$compose_version"
    implementation "androidx.ui:ui-material:$compose_version"
}

И затем пытался всё это собрать из-за разъехавшихся версий Firebase. После чего столкнулся уже с препятствиями Compose:


app/src/main/AndroidManifest.xml Error: uses-sdk:minSdkVersion 16 cannot be smaller than version 21 declared in library [androidx.ui:ui-layout:0.1.0-dev01] .../ui-layout-0.1.0-dev01/AndroidManifest.xml as the library might be using APIs not available in 16 Suggestion: use a compatible library with a minSdk of at most 16, or increase this project's minSdk version to at least 21, or use tools:overrideLibrary="androidx.ui.layout" to force usage (may lead to runtime failures)

Да, Compose оказался доступен только с minSdk 21 (Lolipop). Возможно, это временная мера, но от него ожидали поддержки более ранних версий операционки.


Но и это не всё. Compose работает на Reflection, вместо Kotlin Compiler Plugin, как это было заявлено ранее, например, тут. Поэтому, чтобы всё завелось, нужно добавить в зависимости ещё и Kotlin Reflect:


implementation "org.jetbrains.kotlin:kotlin-reflect"

Ну и на сладкое. В Compose dp реализован как extension функции для Int, Long, Float, которые помечены ключевым словом inline. Это может вызвать новую ошибку компиляции:


Cannot inline bytecode built with JVM target 1.8 into bytecode that is being built with JVM target 1.6. Please specify proper '-jvm-target' option
* https://stackoverflow.com/questions/48988778/cannot-inline-bytecode-built-with-jvm-target-1-8-into-bytecode-that-is-being-bui

Для решения нужно явно прописать версию JVM для Kotlin:


android {
    …
    kotlinOptions {
        jvmTarget = "1.8"
    }
}

Вот, кажется, и всё. Намного легче, чем собирать свою студию)


Попробуем запустить Hello World (тоже из статьи Кирилла, но, в отличие от него, добавим Compose внутрь Fragment). Layout для фрагмента представляет собой пустой FrameLayout.


override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
    val fragmentView = inflater.inflate(R.layout.fragment_about, container, false)

    (fragmentView as ViewGroup).setContent {
        Hello("Jetpack Compose")
    }
    return fragmentView
}

@Composable
fun Hello(name: String) = MaterialTheme {
    FlexColumn {
        inflexible {
            // Item height will be equal content height
            TopAppBar<MenuItem>( // App Bar with title
                    title = { Text("Jetpack Compose Sample") }
            )
        }
        expanded(1F) {
            // occupy whole empty space in the Column
            Center {
                // Center content
                Text("Hello $name!") // Text label
            }
        }
    }
}

Запускаем, получается следующий экран:


image

Из-за того, что Composable использует Material-тему по умолчанию, мы получили фиолетовый AppBar. Ну и, как и ожидалось, она совсем не согласуется с темной темой приложения:


image

Попробуем это решить.


Темы и стили. Интеграция с существующими в проекте.


Для того, чтобы использовать существующие стили внутри Composable, передадим их внутрь конструктора MaterialTheme:


@Composable
fun Hello(name: String) = MaterialTheme(colors = MaterialColors(
        primary = resolveColor(context, R.attr.colorPrimary, MaterialColors().primary),
        secondary = resolveColor(context, R.attr.colorSecondary, MaterialColors().secondary),
        onBackground = resolveColor(context, R.attr.textColor, MaterialColors().onBackground)
)){...}

Сама MaterialTheme состоит из двух частей: MaterialColors и MaterialTypography.
Для разрешения цветов я использовал обертку над стилями:


private fun resolveColor(context: Context?, @AttrRes attrRes: Int, colorDefault: Color) = context?.let { Color(resolveThemeAttr(it, attrRes).data.toLong()) }
        ?: colorDefault

private fun resolveThemeAttr(context: Context, @AttrRes attrRes: Int): TypedValue {
    val theme = context.theme
    val typedValue = TypedValue()
    theme.resolveAttribute(attrRes, typedValue, true)
    return typedValue
}

На данном этапе AppBar перекрасится в зеленый цвет. Но для перекраски текста нужно сделать еще одно действие:


Text("Hello $name!", style = TextStyle(color = +themeColor { onBackground }))

Тема к виджету применяется использованием операции унарного плюса. Мы еще увидим её при работе со State.


Теперь новый экран выглядит однородно с остальным приложением в обоих вариантах темы:


image

В источниках Compose нашел также файл DarkTheme.kt, функции из которого можно использовать для определения различных триггеров включения темной темы на Android P и 10.


Accessibility и UI-тесты.


Пока экран не начал разрастаться новыми элементами, давайте посмотрим, как он выглядит в Layout Inspector и со включенным отображением границ элементов в Dev Mode:



image

Здесь мы увидим FrameLayout, внутри которого только AndroidComposeView. Существующие инструменты для Accebility и UI-тестирования теперь больше не применимы? Возможно, вместо них теперь будет новая библиотека: androidx.ui:ui-test.


Основные компоненты и аналоги наследников View.


Теперь попробуем сделать экран чуть более информативным. Для начала поменяем текст, добавим кнопку, ведущую на страницу приложения в Google Play, и картинку с логотипом. Сразу покажу код и что получилось:


@Composable
fun AboutScreen() = MaterialTheme(...) {

    FlexColumn {
        inflexible {
            TopAppBar<MenuItem>(title = { Text(getString(R.string.about)) })
        }
        expanded(1F) {
            VerticalScroller {
                Column {
                    Image()
                    Title()
                    MyButton()
                }
            }
        }
    }
}

private fun Image() {
    Center {
        Padding(16.dp) {
            Container(
                constraints = DpConstraints(
                        minWidth = 96.dp,
                        minHeight = 96.dp
                )
            ) {
                imageFromResource(resources, R.drawable.ic_launcher)
            }
        }
    }
}

private fun Title() {
    Center {
        Padding(16.dp) {
            Text(getString(R.string.app_name) + " " + BuildConfig.VERSION_NAME,
                    style = TextStyle(color = +themeColor { onBackground }))
        }
    }
}

private fun MyButton() {
    Center {
        Padding(16.dp) {
            Button(getString(R.string.about_button), onClick = {
                openAppInPlayStore()
            })
        }
    }
}

image

Основные принципы композиции виджетов не изменились с момента первого появления исходников Compose.


Из интересного:


  • Функции для отображения отдельных элементов не обязательно помечать аннотацией @Composable.
  • Почти все свойства для виджетов превратились в отдельные виджеты (Center вместо android:gravity, Padding вместо android:margin, …)
  • Отобразить картинку из drawables мне так и не удалось.
  • У кнопки параметр onClick сделан не последним, из-за чего нельзя передать его как лямбду без явного указания названия, что казалось бы логичнее:
    Button(“Text"){
    openAppInPlayStore()
    }

Пройдемся теперь по основным существующим ViewGroup и попробуем найти аналоги в Compose.


Вместо FrameLayout можно использовать Stack. Тут всё просто: дочерние виджеты накладываются друг на друга и позиционируются в зависимости от используемой для вложения функции: aligned, positioned или expanded.


LinearLayout заменяется сразу двумя виджетами: Column и Row вместо использования параметра android:orientation. Они же, в свою очередь, содержат внутри себя FlexColumn и FlexRow с прослойкой функции inflexible над вложенным поддеревом. Ну а сами FlexColumn и FlexRow построены на Flex с параметром orientation = LayoutOrientation.Vertical или Horizontal.


Похожая иерархия у виджетов FlowColumn, FlowRow и Flow. Их основное отличие: если контент не помещается в один столбец или строку, рядом отрисуется следующий, и вложенные виджеты “перетекут” туда. Реальное предназначение для этих виджетов мне пока представить сложно.


Эффект ScrollView достигается помещением Column или Row внутрь VerticalScroller или HorizontalScroller. Оба они композируют внутри Scroller, передавая внутрь параметр isVertical = true или false.


В поисках аналога для ConstraintLayout или хотя бы RelativeLayout наткнулся на новый виджет Table. Попытался запустить пример кода у себя в приложении: DataTableSamples.kt. Но, как я не пытался упростить пример, сделать его работающим так и не получилось.


image

Работа со State


Одним из самых ожидаемых нововведений фреймворка является его готовность из коробки к использованию в однонаправленных архитектурах, построенных на основе единого состояния. И в этом предполагалось введение аннотации @Model для пометки классов, предоставляющих State для отрисовки UI.
Рассмотрим пример:


data class DialogVisibleModel(val visible: Boolean, val dismissPushed: Boolean = false)
...
@Composable
fun SideBySideAlertDialogSample() {
    val openDialog = +state { DialogVisibleModel(true) }

    Button(text = "Ok", onClick = { openDialog.value = DialogVisibleModel(true) })

    if (openDialog.value.visible) {
        AlertDialog(
            onCloseRequest = {
                // Because we are not setting openDialog.value to false here,
                // the user can close this dialog only via one of the buttons we provide.
            },
            title = {
                Text(text = "Title")
            },
            text = {
                Text("This area typically contains the supportive text" +
                        " which presents the details regarding the Dialog's purpose.")
            },
            confirmButton = {
                Button("Confirm", onClick = {
                    openDialog.value = DialogVisibleModel(false)
                })
            },
            dismissButton = {
                if (!openDialog.value.dismissPushed)
                    Button("Dismiss", onClick = {
                        openDialog.value = DialogVisibleModel(true, true)
                    })
                else {
                    //hidden
                }
            },
            buttonLayout = AlertDialogButtonLayout.SideBySide
        )
    }
}

Здесь создается дата-класс для модели стейта, при этом его не обязательно помечать аннотацией @Model.
Само исходное состояние создаётся внутри @Composable функции с использованием +state.
Видимость диалога определяется свойством visible из модели, полученной вызовом свойства value.
Этому свойству можно также задавать новый неизменяемый объект, как это происходит в onClick обеих кнопок. Первая скрывает саму себя, вторая — закрывает диалог. Диалог можно переоткрыть, нажав на кнопку Ok, определенную внутри той же @Composable функции.
При попытке вынести состояние вне этой функции возникает ошибка:
java.lang.IllegalStateException: Composition requires an active composition context.
Контекст можно получить, присвоив значение функции setContent{} в onCreateView, но как его использовать, например в Presenter или другом классе, отличном от Fragment или Activity, для изменения состояния – пока непонятно.


image

На этом завершим обзор новой библиотеки Jetpack Compose. Фреймворк архитектурно оправдывает своё название, заменяя всё наследование, которое так сильно доставляло неудобства в иерархии View, композицией. Пока остаётся слишком много вопросов о том, как будут реализованы аналоги более сложных ViewGroup, типа ConstraintLayout и RecyclerView; не хватает документации и превью.


Абсолютно понятно, что Compose не готов к применению даже в маленьких боевых приложениях.


Но это всего лишь первая версия Dev Preview. Будет интересно наблюдать за развитием концепции работы со State и библиотеками от комьюнити на основе Compose.


Если вы нашли более удачные примеры кода, или документации для кейсов, которые у меня не получилось завести – напишите пожалуйста в комментарии.

Комментарии (9)


  1. sergeyfitis
    16.10.2019 11:15

    Спасибо за разбор, но вы немного поспешили, официальный анонс артефактов и codelabs только через неделю на DevSummit. Для работы плагина нужна будет новая студия, новый AGP и ещё не релизнутый Kotlin Compiler с API для плагинов. Как пишут разработчики Compose в slack канале что оно сейчас работает, это скорее случайность :) Сейчас не все фичи будут работать без нового компилятора.


    Compose работает на Reflection, вместо Kotlin Compiler Plugin, как это было заявлено ранее

    Он не будет работать на рефлексии. Ему нужен компиляторный плагин. В официальном slack канале Compose, Leland Richardson уже ответил на вопрос касательно kotlin.reflect:


    i think compose used some reflection very early on and the dependency just never got removed;
    update: reflect is going away. kapt is not a dependency and never was.


    1. AndreySBer Автор
      16.10.2019 11:36

      Спасибо за дополнение. Хотелось показать, что работает, и что не работает на сегодняшний день.
      Будем ждать DevSummit. После него попробую применить к моему экрану то, что там покажут, и посмотреть, что улучшится.


  1. ice_android
    16.10.2019 13:43

    Спасибо за статью и обзор текущего доступного API.
    Как уже заметили выше в комментариях, ни рефлексии, ни kapt не будет — это зависимости, которые остались в проекте со времени, когда работа над Compose только стартовала. Также, дополняя комментарий выше, какой-то более-менее сложный UI в текущей версии студии скорее будет падать с ошибкой, а те примеры, что работают сейчас, условно говоря, «под капотом» работают неправильно.
    Ну и я бы посоветовал добавить в статью, что весь публичный API, который сейчас доступен, нужно рассматривать исключительно с точки зрения dev-preview, а это значит, что он (наверняка) может значительно измениться и в продакшен Compose можно тянуть только под собственную ответственность :)

    Напоследок посоветую неплохой доклад с недавнего минского GDG митапа про то, что в Compose вряд ли изменится, а именно — основные принципы его работы: www.youtube.com/watch?v=oK8CFrZmVrg


  1. andkulikov
    16.10.2019 14:03

    Привет! Я работаю над Jetpack Compose и хотелось прокоментировать несколько замечаний из статьи.
    Во первых, спасибо! Очень подробный и интересный разбор. Приятно что в проекте вам оказалось достаточно легко сориентироваться даже при отсутствии официальной документации. И рад что наши сэмплы помогают.
    1) Могу подтвердить то, что ответил sergeyfitis. Версия dev01 это не официальный релиз, а откатка процесса релиза для такого большого проекта. Jetpack Compose все еще требует специальную версию студии, AGP и котлина.
    2) Минимальная поддерживаемая версия такой и останется — 21. Мы завязываемся на некоторые апи которые появились в этой версии. Плюс проект еще довольно далек от стейбл версии, надеюсь к тому моменту все больше приложений перейдут на данный minSdkVersion.
    3) Автоматическое подхватывание темы из андроид стилей еще не существует. Мы еще рассматриваем как это лучше всего решить. В том числе планируется поддержка dark mode из коробки (тоже еще не до конца реализовано)
    4) Чтобы вручную не проставлять +themeColor { onBackground } на каждый Text можно обернуть весь «экран» в Surface {… }, тогда весь текст автоматически перекрасится в onSurface из темы. или же можно использовать Surface(color = +themeColor { background }) {… }, тогда текст перекрасится в onBackground. Думаю логика понятна. Еще до конца не решили будем ли автоматически закрашивать фон в background с применением текста onBackground
    5) Тестирование на основе View и их айди перестанет работать, да. Работаем над новым апи для тестирования Compose Ui. Можно посмотреть примеры здесь: android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/ui/ui-material/src/androidTest/java/androidx/ui/material/CheckboxUiTest.kt
    6) Все функции вызывающие другие @Composable функции будет необходимо тоже помечать @Composable. Аналогично с тем как работают suspend функции. В дальнейшем такой код не помеченный аннотацией будет ошибкой компиляции.
    7) «свойства для виджетов превратились в отдельные виджеты (Center вместо android:gravity, Padding вместо android:margin, …)» — это еще не финальное решение. На данный момент мы изучаем возможность вместо них использовать идею layout modifier. Тут есть примеры: android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/ui/ui-layout/integration-tests/samples/src/main/java/androidx/ui/layout/samples/PaddingSample.kt
    8) «У кнопки параметр onClick сделан не последним». На данный момент это сделано умышлено чтобы можно было отличить несколько вариантов Button. У этой функции есть несколько перегрузок в разными наборами параметров. Например вместо
    Button(“Text", { openAppInPlayStore() })
    Можно написать
    Button(onClick ={ openAppInPlayStore() }) {
    Text(text = «Text», style = ...)
    }
    Что дает более гибкую настройку текста или даже вызов любого другого Composable вместо текста как контент кнопки
    9) Пример про DataTable на самом деле рабочий, как вы видите мы его же используем в нашем семпл приложении: android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/ui/ui-material/integration-tests/material-demos/src/main/java/androidx/ui/material/demos/DataTableActivity.kt
    Как я понимаю разница в том, что у вас получился белый текст на белом фоне. В нашем примере мы еще оборачиваем пример в Surface {}. Как это меняет цвет текста я уже рассказывал выше
    10) Аннотация @Model требуется только для классов с мутабельными филдами. Таким образом компайлер сгенерирует дополнительный код в геттеры и сеттеры и каждое изменение поля будет автоматически отслеживаться и вызывать рекомпозицию участка кода, в котором это поле было использовано. Именно таким образом работает +state { DialogVisibleModel(true) }. DialogVisibleModel оборачивается в специальный дата класс с одним var полем и каждый раз когда вы вызываете openDialog.value = DialogVisibleModel(false) все, кто раньше читали значение из openDialog.value автоматически перевызваны чтобы применить изменение
    11) «Контекст можно получить, присвоив значение функции setContent{} в onCreateView, но как его использовать, например в Presenter или другом классе, отличном от Fragment или Activity, для изменения состояния – пока непонятно.». Официальная рекомендация на этот вопрос еще не выработана, обязательно скажем как мы советуем это решать после. Например, ваш композабл сможет принимать LiveData или Observable и будет автоматически перерисовываться каждый раз когда новое значение будет доступно из потока


    1. DEADMC
      16.10.2019 15:39

      7) Судя по исходникам Modifier кажется довольно объемным решением, можете хоть Padding виджет не удалять, потому что как видно из слака того же — все разделились на 2 лагеря, кому-то удобно через модифаер, кому-то через виджет. Кажется, правильно оставить и поддержать оба варианта.
      10) Сейчас если в поле @Model класса есть ArrayList например, то изменение этого листа не влечет за собой изменение UI, нужно присваивать новый ArrayList, что неудобно. Планируется ли как-то улучшить этот процесс, особенно в свете того, что для Compose как раз будут писать свой RecyclerView аналог?
      11) А зачем, похоже, что самый удобный способ — это все тот же инстанс @Model внутри ViewModel/Presenter через который и будут производиться изменения. Да и насколько я понимаю с текущей реализацией стейта в Compose LiveData становится ненужной практически.


      1. andkulikov
        16.10.2019 15:56

        7) Modifier достаточно объемное решение. мы внутри команды тоже все еще обсуждаем все и взвешиваем. в итоге точно не останется оба варианта, только один будет рекомендованный.
        10) Для этой задачи у нас есть специальный вид листа. Он создается через метод modelListOf(). все изменения в нем точно так же вызывают обновление
        11) Есть сотни архитектур и подходов. В идеале Compose UI это просто юай слой и он должен работать с любым подходом. На данный момент мы сконцентрированы на этой части. Интеграция с конкретными архитектурами может решаться по разному. Например, как вы заметили, можно прямо модель презентера пометить @Model. Но не все захотят добавлять аннотации к моделям слоя данных и хранить их мутабельными. В этом случае нужны будут какие то потоки данных на которых Compose будет подписываться


        1. DEADMC
          16.10.2019 16:17

          Про Modifier жалко, сейчас кажется не очень удобным, учитывая общие концепции типа Ripple {} и тд.
          BTW — спасибо за ответы)


    1. DEADMC
      16.10.2019 15:47

      Планируется ли нормальная работа с картинками? Хотя бы на уровне того, что есть во Flutter — просто возможность прокинуть Url обычной строкой и все?


      1. andkulikov
        16.10.2019 15:58

        Точно так же как в ImageView нельзя передать url точно так же это не задача Image компонента в Compose. Но библиотеки типа условного Glide смогут точно так же очень легко создать условный GlideImage(url: String), который сначала отобразит плейсхолдер, потом пойдет загружать картинку.