Минутка рекламы

Собрал здесь лучшие статьи, библиотеки и проекты на Jetpack Compose:
Jetpack Compose Awesome

Ожидания по поводу разработки пользовательского интерфейса выросли. Сегодня мы не можем создать приложение и удовлетворить потребности пользователя, не имея отточенного пользовательского интерфейса, включая анимацию и движение UI-элементов. Этих требований не существовало при создании текущего UI Toolkit-а системы Android. Чтобы решить технические проблемы быстрого и эффективного создания безупречного пользовательского интерфейса, мы представили Jetpack Compose - современный набор инструментов для создания UI, который помогает разработчикам приложений добиться успеха на этом новом поприще.

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

Какие проблемы решает Compose?

Разделение ответственности (Separation of concerns) - это хорошо известный принцип разработки программного обеспечения. Это одна из фундаментальных вещей, которую мы, как разработчики приложений, узнаем. Несмотря на то, что этот принцип хорошо известен, часто трудно понять, соблюдается ли этот принцип на практике. Может быть полезно думать об этом принципе как о термине типа «сцепление» или «связанность».

Когда мы пишем код, мы создаем модули, которые состоят из нескольких сущностей (unit-тов). Связанность (Coupling) - это зависимость между сущностями в разных модулях, которая отражает то, ка части одного модуля влияют на части других модулей. Целостность (Cohesion)- это, наоборот, взаимосвязь между сущностями (юнитами) в модуле и показывает, насколько хорошо сгруппированы юниты в модуле.

При написании поддерживаемого программного обеспечения наша цель - минимизировать связанность и максимизировать целостность.

Когда у нас есть сильно связанные модули, внесение изменений в код в одном месте означает необходимость внесения множества изменений в другие модули. Что еще хуже, связь часто может быть неявной, так что вещи ломаются в неожиданных местах из-за изменения, которое кажется совершенно не связанным.

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

Давайте перейдем к практике, и рассмотрим современные подходы решения этого вопроса в мире Android-разработки. Возьмем для примера ViewModel и XML-лейаут.

ViewModel предоставляет данные лейауту. Оказывается, здесь может быть спрятано много зависимостей: большая взаимосвязь между ViewModel и лейаутом. Один из наиболее частых и х хорошо знакомых нам случаев сильной взаимосвязи - это использование API (от Android или сторонних библиотек - прим. переводчика), в которых требуется знание о внутренностях самого XML-макета, например метод findViewByID.

Использование таких API требует знания того, как устроен XML-макет, и создает взаимосвязь между ними. Поскольку наше приложение со временем растет, мы должны следить за тем, чтобы ни одна из этих зависимостей не устарела.

Большинство современных приложений отображают пользовательский интерфейс динамически и меняются в процессе выполнения. В результате необходимо не только проверить, что эти зависимости (т. е. View-элементы) предоставляются XML-макетом, но также и то, что они будут предоставляться во время работы программы. Если элемент покидает иерархию View во время выполнения, некоторые из этих зависимостей могут быть нарушены и могут привести к таким проблемам, как NulReferenceExceptions.

Примечание переводчика:

Под "предоставлением зависимостей" имеется в виду наличие вьюшки в самом лейауте и возможность найти её через findViewById.

Обычно ViewModel определяется языке программирования Kotlin, а макет - в XML. Из-за этой разницы в языке существует принудительное разделение, хотя ViewModel и XML-макет иногда могут быть тесно связаны. Другими словами, они очень тесно связаны.

Возникает вопрос: что, если бы мы начали определять лейаут, т. е. структуру нашего пользовательского интерфейса на одном языке? Что, если мы выберем Kotlin?

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

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

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

Устройство Composable-функции

Это пример Composable-функции.

@Composable
fun App(appData: AppData) {
  val derivedData = compute(appData)
  Header()
  if (appData.isOwner) {
    EditButton()
  }
  Body {
    for (item in derivedData.items) {
      Item(item)
    }
  }
}

Здесь функция получает данные как параметры из класса appData. В идеале это неизменяемые данные, которые Composable-функция не меняет: Composable-функция должна быть функцией преобразования для этих данных. Следовательно, мы можем использовать любой код на Kotlin, чтобы взять эти данные и использовать их для описания нашей иерархии, например вызвав функции Header() и Body().

Это означает, что мы вызываем другие Composable-функции, и эти вызовы отражают структуру нашего UI. Мы можем использовать все примитивы предоставляемые Kotlin-ом. Мы можем включить операторы if и циклы for для управления структурой UI, чтобы справиться с более сложной логикой пользовательского интерфейса.

Composable-функции часто используют конечный лямбда-синтаксис Kotlin, поэтому Body() - это Сomposable-функция, которая принимает composable-лямбду в качестве параметра. Это подразумевает иерархию или структуру, поэтому Body() обертывает здесь набор элементов.

Декларативный UI

Декларативный - это модное, но важное слово. Когда мы говорим о декларативном программировании, мы говорим об его отличии от императивного программирования. Давайте рассмотрим пример.

Рассмотрим почтовое приложение со значком непрочитанных сообщений. Если сообщений нет, приложение отображает пустой конверт. Если есть какие-то сообщения, мы визуализируем бумагу в конверте, а если есть 100 сообщений, мы визуализируем значок, как будто он горит.

С императивным интерфейсом нам, возможно, придется написать такую ??функцию подсчета обновлений:

fun updateCount(count: Int) {
  if (count > 0 && !hasBadge()) {
    addBadge()
  } else if (count == 0 && hasBadge()) {
    removeBadge()
  }
  if (count > 99 && !hasFire()) {
    addFire()
    setBadgeText("99+")
  } else if (count <= 99 && hasFire()) {
    removeFire()
  }
  if (count > 0 && !hasPaper()) {
   addPaper()
  } else if (count == 0 && hasPaper()) {
   removePaper()
  }
  if (count <= 99) {
    setBadgeText("$count")
  }
}

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

Если мы перепишем эту логику в декларативном стиле, мы получим нечто подобное:

@Composable
fun BadgedEnvelope(count: Int) {
  Envelope(fire=count > 99, paper=count > 0) {
    if (count > 0) {
      Badge(text="$count")
    }
  }
}

Здесь мы говорим:

  • Если счет больше 99, покажи огонь ?.

  • Если счет больше 0, покажи бумагу

  • Если счетчик больше 0, отобрази значок счетчика

Это то, что подразумевается под декларативным API. Код, который мы пишем, описывает нужный нам пользовательский интерфейс, но не описывает, как перейти в это состояние. Важным здесь является то, что при написании декларативного кода, подобного этому, вам больше не нужно беспокоиться о том, в каком предыдущем состоянии был ваш пользовательский интерфейс, вам нужно только указать, каким должно быть ваше текущее состояние. Фреймворк контролирует, как перейти из одного состояния в другое, поэтому нам больше не нужно об этом думать.

Композиция vs Наследование

В разработке программного обеспечения композиция - это то, как несколько частей более простого кода могут объединяться в более сложный блок кода. В объектно-ориентированной модели программирования одной из наиболее распространенных форм композиции является наследование на основе классов. В мире Jetpack Compose, поскольку мы работаем только с функциями, а не с классами, метод композиции сильно отличается, но имеет много преимуществ перед наследованием. Давайте посмотрим на пример.

Допустим, у нас есть View и мы хотим создать поле ввода. В случае с наследованием наш код может выглядеть так:

class Input : View() { /* ... */ }
class ValidatedInput : Input() { /* ... */ }
class DateInput : ValidatedInput() { /* ... */ }
class DateRangeInput : ??? { /* ... */ }

View - это базовый класс. ValidatedInput является подклассом Input. Для проверки даты DateInput наследуется от ValidatedInput. Но тогда возникает проблема: мы хотим создать компонент с вводом диапазона дат, следовательно, нам нужно осуществлять проверку по двум датам - дате начала и дате окончания. Вы можете создать подкласс DateInput, но вам нужно сделать это дважды, а вы не можете этого сделать. Это ограничение наследования: у нас должен быть единственный родитель, от которого мы наследуем.

В Compose это не так сложно. Допустим, мы начинаем с базового composable-компонента Input:

@Composable
fun <T> Input(value: T, onChange: (T) -> Unit) { 
  /* ... */
}

Когда мы создаем наш ValidatedInput, мы просто вызываем Input в теле нашей функции. Затем мы можем дополнить его чем-нибудь для проверки.

@Composable
fun ValidatedInput(value: T, onChange: (T) -> Unit, isValid: Boolean) { 
  InputDecoration(color=if (isValid) blue else red) {
    Input(value, onChange)
  }
}

Затем для DataInput мы можем напрямую вызвать ValidatedInput.

@Composable
fun DateInput(value: DateTime, onChange: (DateTime) -> Unit) { 
  ValidatedInput(
    value,
    onChange = { ... onChange(...) },
    isValid = isValidDate(value)
  )
}

Теперь, когда мы сталкиваемся с вводом диапазона дат, у нас больше нет проблемы: это всего лишь два вызова вместо одного.

@Composable
fun DateRangeInput(value: DateRange, onChange: (DateRange) -> Unit) { 
  DateInput(value=value.start, ...)
  DateInput(value=value.end, ...)
}

При создании UI-компонентов при помощи Compose, у них нет единственного родителя, и это решает проблему, которая возникла с в случае с использованием наследования.

Другой тип проблемы композиции - это абстрагирование от типа декотратора. Для примера рассмотрим следующий пример наследования:

FancyBox - это View, которое украшает другие View, в данном случае Story и EditForm. Мы хотим создать FancyStory и FancyEditForm, но как? Мы наследуем от FancyBox или мы наследуем от Story? Это неясно, потому что, опять же, у нас может быть только один родитель в цепочке наследования.

Compose же справляется с этим очень хорошо.

@Composable
fun FancyBox(children: @Composable () -> Unit) {
  Box(fancy) { children() }
}
@Composable fun Story(…) { /* ... */ }
@Composable fun EditForm(...) { /* ... */ }
@Composable fun FancyStory(...) {
  FancyBox { Story(…) }
}
@Composable fun FancyEditForm(...) {
  FancyBox { EditForm(...) }
}

У Composamble-функции есть лямбда, в которой мы описываем дочерние View, т. е. мы определяем View, которая обертывает другие View. Итак, теперь, когда мы хотим создать FancyStory, мы вызываем Story внутри FancyBox и можем сделать то же самое с FancyEditForm. Это способ композиции в Compose.

Инкапсуляция

Еще одна вещь, которую хорошо выполняет Compose - это инкапсуляция. Это то, о чем вы должны думать, когда делаете общедоступные API-интерфейсы Composable-функций: публичный API-интерфейс Composable-функций - это набор параметров, которые она получает, поэтому он не может их контролировать. С другой стороны, Composable-компонент может управлять состоянием и создавать его, а затем передавать это состояние вместе с любыми данными, которые он получил, в другие Composable-компонентыв качестве параметров.

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

Перекомпоновка (перерисовка компонентов)

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

Например, вот функция bind, которую вы можете встретить сегодня в разработке для Android.

fun bind(liveMsgs: LiveData<MessageData>) {
  liveMsgs.observe(this) { msgs ->
    updateBody(msgs)
  }
}

У нас есть LiveData, на которую мы хотим подписаться для обновления View. Для этого мы вызываем метод observe в классе, имеющем жизненный цикл (LifecycleOwner - Activity или Fragment), а затем передаем лямбду. Лямбда вызывается каждый раз при обновлении LiveData, и когда это происходит, мы хотим обновлять и View.

С помощью Compose мы можем изменить этот способ взаимодействия с LiveData:

@Composable
fun Messages(liveMsgs: LiveData<MessageData>) {
  val msgs by liveMsgs.observeAsState()
  for (msg in msgs) {
    Message(msg)
  }
}

Это аналогичный Composable-компонент Messages , который получает LiveData и вызывает compose-метод observationAsState. Метод observeAsState преобразует LiveData<T> в State<T>. Это означает, что вы можете использовать полученное значение в теле функции. Экземпляр State подписан на экземпляр LiveData, что означает, что он будет обновляться при каждом обновлении LiveData. Это также означает, что где бы ни читался экземпляр State, окружающая compose-функция, в которой он читается, будет автоматически подписываться на эти изменения. Конечным результатом является то, что больше нет необходимости указывать LifecycleOwner или коллбэк для обновления, поскольку Composable может неявно выполнять функцию их обоих.

Заключительные мысли

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

В следующем посте я собираюсь сосредоточить внимание на некоторых деталях реализации Compose и его компилятора. Дополнительные ресурсы по Compose можно найти здесь.