Не лучше ли начать раньше, чем позже?

Приблизительно три года назад на Google I/O 2019 была анонсирована новая перспективная библиотека для разработки Android-приложений под названием compose. С тех пор он рос все больше и больше и становился все лучше и лучше. Многие люди даже говорят, что он знаменует собой конец использования XML в разработке Android-приложений. С тех пор, как Google выпустил первую стабильную версию, была написана куча учебных пособий, выпущено множество курсов и разработано большое количество библиотек для поддержки экосистемы.

Jetpack Compose в действии
Jetpack Compose в действии

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

Оглядываясь назад, я могу вспомнить, что было время, когда я начинал разбираться с compose, но его превью работало не совсем так, как я привык при использовании XML. Когда вы разрабатываете приложение для Android с использованием XML-макета, превью будет автоматически обновляться при каждом изменении, которое вы вносите в файл. С другой стороны, используя compose, чтобы посмотреть превью, вам нужно заново билдить свой модуль и рендерить превью каждый раз, когда вы вносите изменения. Я практически сразу бросил затею освоить compose, потому что склонен делать очень много изменений, а превью — это то, что помогает мне не потерять рассудок во время разработки приложения.

В этом году я принял решение снова начать осваивать compose. И я могу смело заявить, что я подсел. Даже проблема с превью, о которой я говорил ранее, похоже, ушла для меня на второй план. Когда ваши изменения небольшие, такие как обновление отступов или размера ширины, превью отработает их автоматически, но добавление новых компонентов… тут уж увы.

Я начал изучать compose здесь, и давайте посмотрим, чему мы можем научиться у такого разработчика, как я, который немного припозднился на эту вечеринку.

Это не туториал по использованию compose, а скорее несколько безапелляционных заявлений на основе выводов, которые я сделал, изучая его.

Compose-мышление

Императивный пользовательский интерфейс против декларативного 

В данный момент я наблюдаю тотальный переход к декларативному пользовательскому интерфейсу в разработке мобильных приложений.

В недалеком прошлом для создания дизайна макета своего приложения Android-разработчики использовали XML. Но XML-файл может содержать только информацию о том, какой компонент находится внутри макета, и его конфигурации, например, цвет, фон или содержимое. Если позже вам потребуется динамически изменить содержимое, например, отображая полученные данные из API/веб-сервиса, вам нужно делать это в файле Kotlin.

Но поскольку весь ваш дизайн находится в XML-файле, а ваш код — в Kotlin, вам нужен какой-то способ сослаться на ваш XML-компонент внутри вашего Kotlin-кода. Есть много способов сделать это, например:

  • findViewById, вручную сослаться на его ID.

  • ButterKnife, создать переменную, используя аннотацию с ID в качестве параметра (устарело).

  • synthetic Kotlin, сгенерировать дополнительный код для доступа к вашему представлению, как если бы его компоненты были свойствами с именами, соответствующими ID ресурсов (устарело).

  • ViewBinding / DataBinding, сгенерировать биндинг-класс для каждого XML-макета в вашем модуле с прямой ссылкой на ваш компонент.

Такой подход называется императивным пользовательским интерфейсом, потому что вы сосредоточены в первую очередь на том, КАК данные будут отображаться. И это может повлечь за собой некоторые сложности, например, когда ваше приложение становится все сложнее, и вам, возможно, потребуется обновлять или конфигурировать приложение в нескольких местах.

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

Сдвиг в мышлении

Как мне кажется, потому, что этот код находится в Kotlin, вам не нужен ID ресурса, как при использовании XML, вам не нужно ссылаться на него после того, как вы его создали. Но тогда вы вправе задаться вопросом:

“Так как мы будем обновлять их позже?”

Вместо изменения или обновления пользовательского интерфейса при изменении состояния приложения мы описываем, каким должен быть пользовательский интерфейс в определенных условиях состояния. Взгляните на этот пример:

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
   Column(modifier = modifier.padding(16.dp)) {
       var count by remember { mutableStateOf(0) }

       if (count > 0) {
           // This text is present if the button has been clicked
           // at least once; absent otherwise
           Text("You've had $count glasses.")
       }
       Button(onClick = { count++ }, Modifier.padding(top = 8.dp)) {
           Text("Add one")
       }
   }
}

Пример состояния в compose

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

Мы видим поле Text, которое отображает, сколько стаканов воды мы выпили каждый раз, когда мы нажимаем на кнопку. Таким образом, вместо того, чтобы обновлять содержимое Text каждый раз, когда нажимается кнопка, мы создаем переменную, которая содержит количество стаканов воды в качестве состояния и обновляем переменную каждый раз, когда происходит клик по кнопке (Button).

 

Цикл обновления пользовательского интерфейса
Цикл обновления пользовательского интерфейса

Опираясь на изображение, приведенное выше, проанализируем наш пример:

  • Событие (Event) возникает, когда происходит клик по кнопке (Button).

  • Обновление состояния (Update State) — обновляется текущее значение счетчика (count+1).

  • Отображение состояния (Display State) — изменяется содержимое поля Text.

Состояние (State) определяет, что отображается в пользовательском интерфейсе в каждом конкретном промежутке времени. В качестве состояния можно использовать практически все, например базовые типы (String, Boolean, Int) и даже ресурсы (String, Color, Drawable и Dimension). И когда ваше значение состояния будет обновлено, любой компонент, который его использовал, будет соответствующим образом обновлен. Это вся суть декларативного пользовательского интерфейса в Jetpack Compose.

Чтобы привыкнуть к использованию состояния в своем коде, вам не потребуется много времени. Впоследствии весь код вашего Activity может находиться внутри compose-функции.

Внедрение в свой проект

Вам, наверное, интересно, сколько изменений потребуется совершить для внедрения/миграции текущего проекта на Jetpack Compose. Если в вашем проекте уже реализована чистая архитектура (clean code architecture), то особых изменений не потребуется.

Архитектура чистого кода
Архитектура чистого кода

Поскольку Jetpack Compose — это всего лишь библиотека, которая описывает, как работает и отображается ваш пользовательский интерфейс, должен будет измениться только уровень вашего представления (presentation layer). Остальная часть вашего кода, такая как получение данных из API, хранение и обработка данных в локальной базе данных, будет также работать с Jetpack Compose.

Вы даже можете переносить свой код на Compose постепенно, потому что Compose и Android View хорошо работают вместе. Подробнее об этом здесь.

Что из себя представляет проектирование в compose

Отображение списка данных 

Чтобы понять, насколько просто использовать compose, давайте возьмем пример, когда нам нужно отображать список данных. В эпоху XML мы, вероятно, стали бы использовать RecyclerView. И наши шаги могли бы быть следующими:

  • Создать Adapter и ViewHolder с массивом/списком для хранения данных внутри RecyclerView.

  • Добавить RecyclerView в XML-макет.

  • Настроить  RecyclerView с помощью Layout Manager и добавить к нему адаптер.

  • (опционально) если ваши данные еще не готовы, когда RecyclerView уже инициализирован, вам нужно будет добавить данные в список в адаптере, чтобы обновить его.

Необходимо выполнить столько шагов, чтобы всего лишь отобразить список данных. Но используя compose, вам не нужно будет делать столько всего, т.к. весь макет, конфигурация и состояние хранятся внутри composable-функции. Давайте рассмотрим этот пример:

data class Message(val sender : String, val content : String)

@Composable
fun MessageList() {
  val viewModel : SampleViewModel = viewModel()
  val messages = viewModel.messages.observeAsState()
  
  LazyColumn { items(items = messages, key = { it }) { message -> MessageLayout(message) } }
}

@Composable
fun MessageLayout(data : Chat) {
  Row(Modifier.padding(top = 10.dp)
      .clickable {  
        Toast.makeText(LocalContext.current,"${data.sender} clicked",Toast.LENGTH_SHORT).show() 
      }) {
    Text(data.sender)
    Text(data.content)
  }
}

Compose-функция для отображения списка объектов

Использование compose позволяет вам делать следующее:

  • Нет необходимости в Layout Manager, вы можете определить, как список будет отображаться с помощью встроенного макета. Например, для отображения по вертикали используйте LazyColumn, а для отображения по горизонтали — LazyRow.

  • Создайте новую compose-функцию и добавьте объект данных в качестве параметра — внутри нее можно настроить любое действие, например клик.

И все, адаптер или viewHolder не требуются. Как видите, использование compose поможет вам писать меньше кода и работать с меньшим количеством файлов. Одна только эта причина мотивирует многих разработчиков изучать compose.

ConstraintLayout

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

Сначала я подумал, что будет сложно реализовать ConstraintLayout с помощью compose, потому что мы не используем ID ресурса для добавления ограничения. Существует реализация ConstraintLayout, которую можно использовать, добавив следующее в gradle:

dependencies {
  implementation("androidx.constraintlayout:constraintlayout-compose:1.0.0-beta02")
}

Зависимость для ConstraintLayout

Во-первых, вместо использования ID ресурса, как при проектировании в XML, ConstraintLayout в compose использует ссылку, которую вы можете создать с помощью createRefs() или createRefFor(). Давайте рассмотрим следующий пример:

@Composable
fun ConstraintSample(data : SampleDate) {
    ConstraintLayout {
        // Use this syntax to create multiple reference for your constraint
        // Later you can use it to set constraint it position like, ivIcon.top or ivIcon.bottom
        val (ivIcon, tvCategory, tvDescription) = createRefs()
        
        Image(
            painter = painterResource(id = R.drawable.sampleImage),
            contentDescription = null,
            modifier = Modifier
                .constrainAs(ivIcon) {
                    start.linkTo(parent.start)
                    top.linkTo(parent.top)
                    width = Dimension.value(25.dp)
                    height = Dimension.value(25.dp)
                }
        )
        Text(
            text = data.category,
            modifier = Modifier.constrainAs(tvCategory) {
                // Use method as below to bind your compoent with the reference
                
                // To link constraint, you can call linkTo function from top, bottom, end, and start
                top.linkTo(ivIcon.top)
                
                // Or if you want to constraint both start and end, or top and bottom you can directly call linkTo like this
                // Using it like this, you can set the bias for horizontal or vertical
                linkTo(
                    start = ivIcon.end,
                    end = tvAmount.start,
                    startMargin = 5.dp,
                    endMargin = 5.dp,
                    bias = 0f
                )
                bottom.linkTo(ivIcon.bottom)
                
                // width and height are defined using Dimension, there are multiple Dimension you can use, for example:
                // Dimension.fillToConstaints , same as setting 0dp in XML
                // Dimension.prefferedWrapContent , same as settings wrap_content in XML
                // Dimension.value(theValue.dp) , same as setting value in dp
                width = Dimension.fillToConstraints
                height = Dimension.preferredWrapContent
            }
        )
        Text(
            text = data.amount,
            modifier = Modifier
                .constrainAs(tvAmount) {
                    end.linkTo(parent.end)
                    top.linkTo(tvCategory.top)
                    bottom.linkTo(tvCategory.bottom)
                    width = Dimension.preferredWrapContent
                    height = Dimension.preferredWrapContent
                }
        )
    }
}

Пример ConstraintLayout в Compose

Результатом будет следующий пользовательский интерфейс:

Пример ConstaintLayout
Пример ConstaintLayout

Навигация по приложению

При создании нового Android-проекта возникают вопросы, на которые нужно отвечать сразу, например, какую архитектуру мы будем использовать для его разработки.

  • Актуально ли использование нескольких Activity?

  • Используем ли мы стек Fragment вручную?

  • Или мы реализуем единую Activity используя навигацию Jetpack?

Сегодня люди все чаще выбирают компонент навигации (Navigation Component) и используют одну Activity в качестве контейнера, а список фрагментов — в качестве страницы приложения.

Существуют проблемы, с которыми я сталкиваюсь при использовании компонента навигации, например синхронизация жизненных циклов Activity и Fragment, поскольку у них есть собственные отдельные жизненные циклы. Прежде чем задействовать наблюдатель за жизненным циклом (lifecycle observer), при использовании Fragment довольно сложно определить, какая функция запускается в каком жизненном цикле.

Позаимствовано у Süleyman Başaranoğlu
Позаимствовано у Süleyman Başaranoğlu

Использование навигации (Navigation) для compose отличается от использования компонента навигации. Разница, которую я заметил, заключается в следующем:

  • В навигации для compose единое Activity не достигается с помощью списка фрагментов — вам нужно использовать compose-функции в качестве destination

  • Граф навигации строится не с помощью XML, а в Kotlin внутри compose-функции

Посмотрите на этот пример:

@Composable
fun SampleApp() {
  // define a NavController
  val navController = rememberNavController()
  
  NavHost(navController,startDestination = "splash") {
    // Simple destination with popUpToInclusive
    composable("splash") {
      SplashScreen {
        navController.navigate("home") {
          launchSingleTop = true
          popUpTo("splash") { inclusive = true }
        }
      }
    }

    composable("home") { HomeScreen() }
    
    // Destination with argument and deep link defined
    composable(
      route = "$account/{name}",
      arguments = listOf(
        navArgument("name") {
          type = NavType.StringType
        }
      ),
      deepLinks = listOf(
        navDeepLink { uriPattern = "app://acoount/{name}" }
      )
    ) { entry ->
      val name = entry.arguments?.getString("name")
      AccountScreen(name)
    }
  }
}

@Composable
fun SplashScreen(){
  // Compose functon here
}

@Composable
fun HomeScreen(){
  // Compose functon here
}

@Composable
fun AccountScreen(name : String){
  // Compose functon here
}

Навигация в Compose

Заключение

Twitter разработает новые фичи с помощью Jetpack Compose
Twitter разработает новые фичи с помощью Jetpack Compose

Twitter объявляет о разработке дополнительных фич с помощью Jetpack Compose. А что насчет нас? Должны ли мы сделать также?

Мой опыт работы с Compose на данный момент можно оценить как положительный, и я действительно считаю, что это революционная технология. Его использование изменит ваш образ мышления при разработке приложения, а также сократит ваш код, что не может не радовать.

Разработка большего количества кода на одном языке — это тоже хорошо, я говорил, что вы можете создать NavGraph, используя навигацию для Compose, но на самом деле вы уже можете сделать это с помощью компонента навигации. Положительным моментом использования Compose является то, что вы можете не только проектировать свое приложение с помощью Kotlin, но и ваши ресурсы, такие как стили, цвета, типографика и даже возможность рисования (в Compose называется ImageVector), также могут быть написаны на Kotlin.


Приглашаем всех желающих на открытый урок «UI Profiling. Обзор возможностей тестирования производительности приложений и инструментов оптимизации». На нем мы разберёмся, что такое «тормозящее приложение», рассмотрим основные причины такого поведения, и инструменты, призванные найти и исправить эту проблему. Но мало понять, какова производительность приложения на вашем устройстве. Поэтому мы также рассмотрим несколько сервисов, позволяющих измерить производительность в бою — на телефонах ваших пользователей. Регистрация доступна по ссылке.

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