Эволюция декларативных UI-фреймворков: от динозавров к Jetpack Compose
Проект Jetpack Compose привлёк много внимания в Android-мире, ещё когда был в альфа-версии. А недавно добрался до беты — так что теперь всем Android-разработчикам пора понимать, что он собой представляет.
Поэтому мы сделали для Хабра текстовую версию доклада Матвея Малькова с нашей конференции Mobius. Матвей работает в Google UK и лично причастен к Compose, так что из доклада можно узнать кое-что о «внутренностях» проекта. Но рассказ не ограничивается этим: внимание уделено не только Jetpack Compose, а всему декларативному подходу в целом.
Кстати, недавно появился ещё и проект Compose for Desktop от JetBrains. И скоро на Mobius о нём тоже будет рассказ из первых рук: 14 апреля об этом расскажет руководитель проекта Николай Иготти.
Далее повествование будет от лица спикера.
Я хотел бы рассказать не только про Jetpack Compose, но и поговорить в целом про декларативные UI-фреймворки: окунуться в их прошлое, настоящее и будущее.
Прошлое: о декларативном и императивном
В прошлом уже были декларативные и императивные фреймворки. Люди часто говорят: «Декларативное лучше, чем императивное». В моём понимании, декларативное не лучше императивного, а любит его. Это две части любого фреймворка, любого языка. Чтобы разобраться, почему это так, рассмотрим, что такое декларативное.
Декларативное программирование — это парадигма, которая позволяет нам задавать программы, не описывая control flow. Иными словами, мы описываем не «как», а «что» мы хотим видеть на экране. Это самое большое отличие от императивного программирования.
Вот пока пример не про UI. Часть «How» — это императивная история. Мы берем var и складываем значения у него в цикле. И у нас есть «What» — декларативное программирование, где мы говорим, что хотим сделать: заредьюсить список с помощью какой-то операции.
Возьмём пример попроще:
val a = 4
val b = -2
val result = a + b
Эти три строчки являются интерактивными или декларативными? Ответ зависит от того, с какой стороны посмотреть. Раз мы говорим о прошлом, вспомним, как развивались языки программирования.
Поколения языков программирования
Кажется, выглядит довольно императивно: мы конкретно описываем, как нам достичь какого-то результата, объясняем машине на ее языке, байт за байтом. На самом деле, тут ошибка: это операция вычитания, а нам нужно сложение. Третий символ во второй строке должен быть нулём. В этом случае очень сложно понять, что происходит, очень низкий уровень абстракций. Поэтому ребята придумали второе поколение языков.
Ассемблерный язык является крайне декларативным по сравнению с предыдущим, потому что у него есть операция ADD. Ты не описываешь, как это объяснить машине, ты говоришь, что сделать — сложить. Но, как выяснилось, этого тоже недостаточно.
Ребята придумали языки высокого уровня: Java, Kotlin, C, C++. Они тоже очень декларативные по сравнению с предыдущими поколениями, потому что ты не объясняешь машине, что положить, а просто говоришь: a = 4, b = –2 и result = a + b. Но все это прошлое.
А сегодня мы уже говорим о языках повыше: это вещи вроде RxJava или декларативные UI-фреймворки. Новые декларативные фреймворки ничего нового не приносят с точки зрения именно общей концепции. Они, как и все остальные поколения, абстрагируют предыдущее поколение, то есть четвертое абстрагирует третье, третье — второе, а второе — первое.
Очевидно, что любое новое поколение по сравнению с предыдущим более простое в использовании для более сложных задач. Наши проблемы развиваются, и нам нужно решать их проще. Когда возникают новые проблемы, мы придумываем новые абстракции, новые поколения, которые решают их более эффективно. В числе примеров четвертого поколения — 1C, R, SQL, RegEx. Ещё в четвёртом поколении есть domain specific languages, то есть небольшие фреймворки, которые решают конкретные проблемы: React, RxJava, JetpackCompose.
Вывод: декларативность практически всегда основана на императивности. Кто-то должен объяснить машине, предыдущему поколению или фреймворку, как сделать, чтобы мы уже просто говорили что. Еще одним подтверждением этого факта является то, что в любом декларативном фреймворке почти всегда есть возможность убежать в императив.
Например, в C++ есть intrinsics или inline ASM. В React мы можем получать reference прямо на DOM-элементы или контролировать жизненный цикл. Отличный пример в RxJava — PublishSubject. В Jetpack Compose есть imperative drawing, imperative touch-handling, onCommit. Это даёт больший контроль, а также возможность строить поверх императивного свои декларативные абстракции.
UI-фреймворки: прошлое и настоящее
Говорить о фреймворках мы будем в разрезе Android и веба, потому что проблемы в UI, с которыми сталкиваются Android-разработчики, есть и в вебе, и в других UI-областях.
Давайте рассмотрим HTML. Мы говорим, что у нас должен быть список, должно быть два параграфа с таким-то текстом. Потом из этого HTML строится дерево.
HTML — это очень декларативная штука, она просто размечает и ничего больше не делает. И с этой стороны можно посмотреть на HTML как на функцию, которая генерирует нам UI, не беря никаких данных. Она просто берет файл и генерирует UI.
Если я заменю HTML на main_layout.xml, который есть у нас у всех в Android-приложении, ничего не изменится. Это тот же самый язык разметки, сконвертированный с HTML для Android. Это тот же подход к UI: вызываешь функцию inflate, и получаешь дерево UI.
Проблема в том, что мы не живем в 1995 году и не пишем статические сайты, статические приложения, где на каждую ссылку у нас загружается новая страница. Мы пишем сложные приложения, которые содержат много state и анимации. Это называется динамический UI, к которому мы применяем изменения на лету.
Давайте разберемся на примере. Мы делаем мессенджер и хотим сделать в нем плашку с непрочитанными сообщениями.
Когда мы нажимаем на кнопку Read All, у нас 0 непрочитанных. Чтобы разобраться, рассмотрим дерево нашего UI (будь то хоть Android с вещами вроде TextView, хоть хоть HTML-элементы вроде div). И перед нами такой человек, его будут звать Капитан Динамичность.
У нас есть 100 непрочитанных сообщений, и пользователь нажимает Read All. Капитан Динамичность берет UI, понимает, что нужно поменять, находит это, убирает лишние элементы, соединяет нового родителя с child. Молодец!
Потом пришло новое сообщение. Нужно снова создать подветку UI (надеюсь, не по частям, а одну часть), можно это делать через View.GONE или View.INVISIBLE. И мы снова получаем непрочитанные сообщения. Проблема в том, что если добавляется кнопка undo или какие-то ещё новые требования, становится сложно следить за всеми состояниями. И переходов между ними уже совсем не два.
Давайте посмотрим на реальный пример.
val unreadRoot = document.getElementsByClassName("unreadRoot");
val count = document.createElement('div');
count.textContent = 'You have $count unread messages'; unreadRoot.appendChild(count);
В JavaScript без фреймворков люди пишут примерно так: они берут элементы, императивно создают их, ставят им текст, аппендят childs — получается Капитан Динамичность.
То же самое происходит у нас на андроиде:
val unreadIcon = findViewById(R.id.unread)
if (unreadCount == 0) {
unreadIcon.setVisibility(GONE)
} else if (unreadCount > 0) {
unreadIcon.setVisibility(VISIBLE)
unreadIcon.setCount(unreadCount)
var hasImportant = unreadMessages.exists(it.isImportant)
if (hasImportant) {
unreadCount.setTextColor(Red)
} else {
unreadCount.setTextColor(Blue)
}
}
Я не проверил unreadIcon на null, хотя стоило. Где-то я обновил TextColor в зависимости от чего-то. Не забыть бы поменять обратно при каких-то изменениях.
Можно сделать вывод: чтобы быть Капитаном Динамичностью, нужно держать в голове очень много вещей, потому что состояний очень много, а переходов между состояниями еще больше. Часто, если мы не делаем анимацию, нам не важны переходы, нам важны только состояния. Мы хотим, чтобы пользователь увидел одно, потом увидел второе, и между ними, возможно, был какой-то переход.
Но от переходов можно избавиться. Попробуем построить фреймворк, который позволит нам сделать это.
UI-фреймворки: настоящее и будущее. Главные принципы
Новый UI-фреймворк:
- Убирает переходы между состояниями (для разработчика)
- UI — это функция от состояния
- Объединяет markup и динамическое обновление
Скорее всего, придется модифицировать наш HTML или XML или объединить это с динамическим обновлением, чтобы позволить создавать UI, как функцию от какого-то state.
Я имею в виду функцию или объект, которому мы скармливаем данные нашего приложения: например, есть ли у нас непрочитанные, будет это функция выглядеть как f (false) или f (true). Строим в этих случаях два разных дерева.
Чтобы это сделать, нам придется соединить Markup и Dynamic, которые у нас есть на третьем уровне и построить фреймворк четвертого уровня, который позволяет задавать динамический Markup. И есть понимание, что это уже сделано. Например, React объединил HTML и JavaScript в React Components. И вместе с ним идет JSX, который позволяет создавать внутри JavaScript как будто бы HTML.
В случае с Jetpack Compose аналогично: Composable-функции делают то же самое, соединяя разметку и динамику.
Разберёмся в том, что такое Compose, как он работает, чтобы понять контекст.
В его случае любой UI-элемент — это функция с аннотацией Composable. Только Composable-функции могут вызвать Composable-функции.
@Composable
fun UnreadSection(count: Int) {
Text(
text = "You have $count unread messages",
color = if (count > 10) Color.Red else Color.Green
)
if (count > 0) {
ReadAllButton()
}
}
Функция принимает состояние — в данном случае функция UnreadSection принимает количество непрочитанных сообщений. И дальше на основании состояния я могу делать все, что угодно. Могу прямо здесь сказать, что у меня есть текст с определенным цветом. Могу вставить конструкции Kotlin: if или for. Могу добавить кнопку или убрать её. Для этого не нужно больше вручную настраивать её visibility, обновлять текст, искать его откуда-то из XML и так далее.
Вернёмся к примеру с функцией от false и true. Если мы поставим UnreadSection (count = 100), у нас получится одно дерево, а если UnreadSection (count = 0), то получится другое.
И в этом случае мы можем сказать, где чему равен count. Jetpack Compose так и работает.
Но возникает интересный вопрос: как перейти из одного дерева в другое? Простым ответом может быть:, а давайте менять новое дерево на экране, у нас было одно, а бахнем другое. Это так не работает. У нас уже есть какие-то анимации, другие виджеты или другие части экрана. Если мы будем перестраивать дерево, это будет очень долго. То же самое в вебе: не получится просто так перезагрузить страницу, это занимает какое-то время. Поэтому нам нужно сделать это динамически.
Заменить экран с одного на другой — дорогая история.
И ребята придумали: используем виртуальную, дешевую версию реального UI. При изменении состояния строим новую виртуальную версию. Считаем diff между виртуальными версиями. И научим наш фреймворк выражать diff как императивные операции над реальными UI.
Разберемся, как воплотить идею.
У нас есть UI — это функция от состояния. С этим мы разобрались, это можно сделать в нашем фреймворке. Итак, у нас есть реальный UI — это то, что видит пользователь. И у нас есть виртуальная репрезентация, которую мы можем построить в фреймворке. У нас функция принимает true, пропадает количество unread count или, наоборот, появляется при false. Мы строим новую виртуальную репрезентацию. После этого мы можем посчитать diff этих виртуальных репрезентаций, на его основе построим список императивных операций. Например, это могут быть псевдокод и операции, которые построили над реальным UI. Все будет делаться внутри фреймворка.
Интересный факт здесь — я не говорил, что в виртуальной репрезентации должно быть дерево, это может быть что угодно.
Наш псевдокод называется «Reconciliation», он делает за нас то, что мы делали. Если Reconciliation кажется вам сложным словом, то запомните это как Авто Капитан Динамичность.
И кажется, у нас складывается фреймворк, у нас есть виртуальная репрезентация, мы их строим, строим diff, строим последовательность операций Reconciliation и обновляем наше реальное дерево. Но как построить список этих императивных операций? Давайте разберемся, что такое Reconciliation, какая в этом задача и проблема.
Reconciliation
Базовая задача звучит так: построить минимальный набор команд для трансформации дерева А в дерево Б.
Проблема: общий алгоритм построения набора операций, необходимых для трансформации одного дерева в другое, имеет скорость O (n3).
Кубическая сложность во времени значит, что если у нас есть дерево с тысячей элементов (ну, среднестатический такой UI), то нам надо будет сделать один миллиард операций, чтобы обновить их. Так не пойдёт.
Опытные ребята знают: если надо сделать побыстрее, ставь кэш! Ну, в общем, надо сделать какие-то упрощения, понять, где можем сохранить время, чтобы сделать алгоритм быстрее.
Упрощение 1 (очень общее упрощение, которое вместе со вторым упрощением в каком-то виде применяются в React, Compose): если мы понимаем, что обновляемый элемент/поддерево те же самые, то можем обновить дерево без пересчитывания полностью.
Упрощение 2: если входные данные этого компонента не меняются, то не меняется и поддерево, которое ему соответствует.
Это позволяет в среднем за линейное время преобразовывать дерево А в дерево Б.
В общем, если мы позволим задавать UI как функцию от состояния, то это позволит фреймворку убирать для разработчиков переходы между состояниями. Но под капотом нам всё ещё необходимо осуществлять эти переходы и желательно быстро, иначе этот никто не будет использовать фреймворк. Как мы выяснили, это называется Reconciliation.
Давайте посмотрим, как мы это делаем внутри Jetpack Compose.
Jetpack Compose внутри
Важная оговорка: мы рассмотрим внутренности, а они имеют тенденцию меняться. Если вы знакомитесь с этим докладом в записи, что-то уже могло измениться. Но останется неизменные главное — core-принципы (а изменятся параметры, которые мы генерируем, их последовательность или что-то ещё).
Мы не храним виртуальную репрезентацию как дерево, а используем Gap buffer. Это классическая, но малоизвестная структура данных, используется в текстовых редакторах вроде vim и Emacs. У неё есть, грубо говоря, список, и в этом списке есть gap. Gap один, он может быть большой или маленький, его можно увеличивать. И мы можем совершать определённые операции в этом Gap buffer. Первая логическая операция — insert. Это операция добавления, и она константная.
После добавления элементов можно инвалидировать Gap buffer: вернуться на конкретную позицию, посмотреть и обновить элемент. Также можем переместить Gap к определённому элементу, пустив последующие элементы уже после Gap:
Это единственная операция, которая занимает линейное время, у остальных время константное. Мы можем добавлять элементы, изменять структуру, расширять Gap buffer и убрать то, что было за ним. Мы можем добавлять, обновлять и удалять элементы за константное время.
Мы не можем вклиниться в середину быстро и добавлять туда элементы. Это tradeoff, который мы делаем в Compose с расчетом на то, что структура конкретно нашего UI меняется не так часто, реже, чем происходят апдейты. Например, есть текстовый View, и в нем чаще меняется строчка текста, чем он превращается в иконку.
Итак, рассмотрим на реальном примере, что же происходит в Gap buffer. Мы будем делать что-то примерно такое: у нас есть Mobius, мы кликаем — обновляется счётчик.
Как это сделать по Jetpack Compose? Мы можем сделать Composable-функцию:
@Composable
fun Counter(title: String) {
var count by state { 0f }
Button(
text = "$title: $count" ,
onClick = { count++ }
)
}
Мы можем задать state (позже разберёмся, как это работает — главное, что есть обёртка, которую я могу обновлять по клику). И у нас есть Button, в котором $title (в данном случае Mobius) и count (сначала 0, увеличивается при клике).
Сейчас произойдет бум-момент, потому что мы посмотрим, что же делает аннотация @Composable. Она превращает код примерно в такой:
fun Counter($composer:Composer,$key: Int,title: String) {
$composer.start($key)
var count by state($composer,key=123) { 0f }
Button($composer, key=456
text = "$title: $count" ,
onClick = { count++ }
)
$composer.end
}
Что здесь происходит? У нас здесь есть composer, вы можете на него ссылаться, как доступ к Gap buffer. И именно поэтому только в composable-функции могут быть composable-функции: им всем нужен Gap buffer, им нужен scope, где они сейчас находятся, нужна текущая позиция Gap buffer.
Еще мы вставляем ключи. Это уникальный ключ, важно, чтобы они были разные, и на разные button будут разные ключи.
Также мы в начале вставляем в начале функции composer.start и в конце composer.end.
Посмотрим, что произойдёт, если возьму эту функцию и применю к UI.
Gap buffer может быть больше, а это просто отрезок, где мы сейчас находимся. Мы вызываем функцию Counter и идём по ней:
— Мы говорим: у нас есть start, кладём туда такую штуку, как Group ($key).
— Потом идём на следующую строчку, и там есть key = 123, это еще одна Group (123).
— После этого, как я уже говорил, state генерирует обёртку с классом state со значением 0.
— Потом у нас есть Button, где есть key = 456, кладем туда Group = 456.
— У Button есть параметры, мы кладём их в slot table.
— Естественно, у Button внутри тоже есть имплементация, это же не просто компонент. Внутри него проходят операции, у него есть текст и клики.
Так будет выглядеть наш Gap buffer, когда мы покажем этот Counter. И если мы посмотрим, как я и говорил, то даже линейные виртуальные репрезентации всё равно показывают это дерево, которое у нас и является UI. У нас есть Counter — это вся штука, которую мы построили, у нас есть State и остаток — Button.
В каком-то плане мы строим call stack-функцию в этом случае. Причём запоминая её параметры и уходя внутрь, то есть это depth first traversal-итерация по дереву.
Возвращаемся к тому, что мы делаем не статический UI, у нас бывают обновления.
Давайте представим, что мы обновили в этом Counter «Mobius» на «Matvei» или на что-то ещё. Новый заголовок title приходит в новый Counter изолированно. Мы знаем, что title начинается с Group (key), идем туда. Смотрим, что key=123 не поменялся, значит, state не поменялся, то есть возвращаем вверх тот же самый state, который был у нас до этого. key=456 тоже не поменялся, смотрим на text — он поменялся. Лямбда не поменялась, но так как у меня text, а он идет после ключа button, то есть является параметром Button, нам нужно зайти внутрь Button и тоже поменять его.
Всё связано таким образом, и мы можем понять, что у нас поменялось. Ключи дают возможность определить, та же самая это кнопка или нет. Если она та же самая, можно пойти внутрь и обновить её. И мы делаем императивный flush, перерисовывая нужные части UI, которые обновились.
Это простой пример, посмотрим на что-то сложнее. Как уже говорил, с Compose мы можем делать if else, for и прочие вещи. Мы не рассмотрели примеры, когда я куда-то двигаю Gap buffer.
@Composable
fun Feeds(items: List) {
if (items.isEmpty) {
Text("No feeds")
} else {
FeedList(items)
}
}
У нас есть Feeds — список каких-то вещей, чатов и чего угодно. Эта Composable-функция принимает List
, и он может быть пустой. Нужно показать какую-то нотификацию: no feeds, создать чат, какой-то UI.
Compose сгенерирует нам ключи с помощью Composable-аннотации, которые мы положим в buffer.
Элементов нет, идем в if (isEmpty == true) и попадаем в 123, ставим Group (123) и текст. Дальше не идем. Потом идем в базу данных, в интернет, и у нас появляются Item: новый чат или что-то еще. Снова вызываем функцию Feeds и попадаем в else statement, потому что у нас есть чат. И кажется, что 456 не равно 123.
Двигаем Gap за линейное время: это довольно длинная операция, но мы понимаем, что UI поменялся, нужно пересоздать дерево. Старое дерево нам уже не нужно, можем его удалить. Берем Group (456) и ставим новые Feeds. Таким образом мы можем создавать conditional-логики в нашем UI. Перестраиваем полностью под дерево, потому что if стал else, все поменялось.
Ключи — это не 123 и 456, мы не берём их с неба. Они зависят от местоположения в файле вашего Composable. Если у нас будет еще один text, то у него будет другой key внутри if, и мы будем генерировать эти цифры каждый раз для новой встречи в коде компонента. Это называется positional memoization. Это core-принцип в Jetpack Compose, потому что нам нужно понимать что, как и когда перезвать.
В positional memoization главная штука называется remember. Это функция, которая может принимать набор параметров, например:
@Composable
fun FilterList(items: List, query: String) {
val filteredItems = remember(items, query) {
items.filter(it.name.contains(query))
}
FeedList(items)
}
В примере эта функция принимает items и query и лямбду, которую она будет вызывать. Мы делаем фильтрацию списка, у нас есть items [«A», «AB», «BC»], есть query, который мы вводим в поиске, «A». Мы берем и кладем их в slot table. Она зеленая, значит, это тоже Composable-функция. И мы говорим, что в items у нас лежат [«A», «AB»].
Если мы призовем этот FilterList, например, когда поменялись параметры, то если items, query те же самые, мы можем пропустить вычисление и вернуть сразу [«A», «AB»].
Даже если у нас будет два разных FilterList с одинаковыми items, у них все равно будет свой remember, потому что они будут объявлены в разных местах в коде, у них будут разные ключи и разные remember.
Интересно, что у remember может не быть параметров. И мы можем сделать такую штуку:
@Composable
fun Item(name: String) {
val result = remember {
calculation()
}
Text("$name: $result”)
}
Пример, в котором calculation () является math.random (). Если я сделаю список этих item, у каждого item будет свой calculation, свой результат, и он сохранится на весь жизненный цикл этого item, даже если мы будем менять у него имя. Мы берём remember, у него нет параметров, то есть мы всегда возвращаем тот же самый calculation. Но мы можем пересчитать его только в первый раз, потому что тогда его не было в Gap buffer, а теперь он есть, и мы просто возвращаем его.
Все эти Composable-функции — это примерно то же самое. То есть мы берём параметры:
- Поменялись — идем внутрь функции;
- Не поменялись — возвращаем то же самое.
Стейт
Помните, я рассказывал вам о том, что у нас есть Counter, и мы можем там создать state?
@Composable
fun Counter(title: String) {
var count by state { 0f }
Button(
text = "$title: $count" ,
onClick = { count++ }
)
}
У state тогда был ключ 123, и после того, как мы возвращали, тот же самый state при апдейте counter. Все потому, что state внутри выглядит как просто remember. Это remember от функции MutableState. И функция mutableStateOf сделана примерно как MutableListOf в Kotlin.
@Composable
inline fun state(init: () -> T): MutableState =
remember { mutableStateOf(init()) }
fun mutableStateOf(value: T): MutableState { … }
Идея в том, что именно в функции state мы ремемберим MutableStateOf, мы можем понять, что он тот же самый. И если мы будем менять title у кнопки, state останется тем же самым. То есть я могу поменять title с Mobius на Matvei с counter = 14: title поменяется, а counter останется 14.
Интерфейс MutableState очень простой, наследуется от state.
interface MutableState : State {
override var value: T
}
interface State {
val value: T
}
Все, что делает MutableStateOf, — создает объект MutableState. Он принимает Initial, чтобы мы могли правильно создать его. Это не просто обертка, которая содержит класс с
, хотя для нас он так и выглядит.
Мы создаём такой инстанс MutableState, который позволяет отслеживать ваше прочтение и потом обновлять только прочитавших. Это не нужно в идеальном мире, потому что в Compose мы умеем сами пробежать по Gap buffer и понять, что происходит. Но для важных, интересных кейсов эта вещь позволяет нам обновлять прочитавших или, по-другому говоря, совершать scoped observations, когда мы можем понять, что и в каком scope произошло.
Разберем на примере.
Вот наш Counter с нашим сгенерированным slot table. Мы не будем создавать здесь этот state с помощью функции state, а перенесем его в лист параметров. Gap buffer поменяется.
Мы не будем говорить о том, хороший этот подход или плохой. Приходит Counter, и мы можем его менять. Если вы сгенерировали и передали MutableState, мы можем понять, что где-то в text есть state.value, мы прочитали value из MutableState. Мы прочитали это внутри scope Counter. Запомним это, обновляем только Counter и ничего кроме.
Давайте посмотрим, почему это здорово. Рассмотрим такой пример:
У нас есть три кнопки, у них есть свои counter и кнопка Increase all, которая будет инкризить все counter. Один из вариантов — сделать MutableState параметром counter, чтобы мы могли контролировать все. Мы можем создать App:
fun App(counterData: Map>) {
Column {
counterData.forEach { (title, counterState) ->
Counter(title, counterState)
}
Button(
text = "Increase All!",
onClick = {
counterData.forEach{ (_, countState) ->
countState.value++
}
}
)
}
}
После этого я говорю, что у нас есть Column в Compose, и завожу там counterData.forEach. И отдаю ему title (Mobius, Matvei или Memoization) и counterState нашему counter. После этого добавляем кнопку Button, которая будет по клику обновлять все counters, которые у нас есть. И у нас есть counters, которые работают отдельно, и Increase all, который обновляет все.
Мы помним, что у нас MutableState, и мы умеем это все обновлять скоупами. Мы построили примерно такое дерево:
Можете представить себе, что этот пример очень маленький, и помимо counter внизу, у нас может быть их много UI в середине. Increase all может быть далеко в штуке, у которой тоже есть доступ к MutableState.
В этих counters мы делаем count.value, который прочитали в каком-то скоупе этого counter. А button просто делает Increase all forEach по it.value. Запись в MutableState ни на что не обрекает тебя, тебе не нужно перерисовывать все, если ты обновляешь.
Кликаем на Increase all. В этом случае, когда мы понимаем, что value изменились, мы не бежим по всему дереву, от parent, от владельцев этих MutableState. Нам нужно понять, что все было это было прочитано внутри скоупов counter, и обновить только их. Если мы обновили только один counter, обновится только один. Если все три, то обновляются все три с помощью forEach. Мы избавимся от всех итераций по всем деревьям и просто обновим эти поддеревья, начиная с самого близкого, который прочитал.
Это возможно, потому что у нас есть compiler-плагин, который позволяет получать эти крутые штуки.
Обновляется только то, что поменялось; это важно, когда вам нужно написать супероптимизированный код.
State
все от нас прячут, мы говорим state.value и получаем текущее значение. Наша Composable-функция будет сама реализована, нам ничего не нужно для этого делать.
Также очень удобно, что можно создать новое значение одной строчкой, быстро создать composition-bound state внутри Composable-функции.
Интересно, что в Compose никуда не уходят стадии layout, redraw, они спрятаны для нас, есть только композиция. Но мы можем понять, где вы это читаете. Если мы прочитали это в композиции, как мы это сделали в counter, то мы перекомпозируем вас, перезовем всю функцию. Но если вы прочитаете value только в layout, как делает скроллер, то мы только перелейаутим и не будем вызывать перекомпозицию.
Если вы, например, рисуете какой-нибудь чекбокс, и читаете value только в стадии рисовки, мы перерисуем только ваш canvas. Это позволяет делать супероптимизированные вещи, которые, возможно, вам нужны.
Важно понимать, что Shared MutableState<>, который мы сделали, не бесплатный. Если вы принимаете MutableState как параметр компонента, то компонент сможет менять state родителя. Станет очень сложно следить за общим состоянием системы, и мы рискуем напороться на то, за что боролись. Мы хотели, чтобы все стало проще с помощью фреймворков, но нас заставляют понимать, куда ты отдал свой state.
Пример:
@Composable
fun Counter(
countState: MutableState
) {
Button(
text = "count: ${countState.value}",
onClick = { countState.value += 1 }
)
}
И мы его вот так используем:
@Composable
fun App() {
val counterState: MutableState = state { 0f }
Counter(counterState)
}
И когда я отдаю этот state в Counter, я прощаюсь с этим state и не знаю, что с ним произойдет. Я не контролирую, что произойдет внутри Counter. Его могут положить куда-то, кому-то отдать. Это усложняет систему, но дает классные штуки.
Решением этого может быть state hosting, когда мы поднимаем state выше, а владельцем становится какой-то parent, например, App. Button в этом случае будет принимать State
только для чтения, а не MutableState, как раньше.
Второе решение — Controlled components — это компоненты, у которых нет state, но есть snapshot этого state.
@Composable
fun Counter(
currentCount: Int,
onCountChange: (Int) -> Unit
) {
Button(
text = "count: $currentCount",
onClick = { onCountChange(currentCount + 1) }
)
}
Мы можем зарефакторить наш Counter так, чтобы он принимал currentCount и лямбду, которую он звал бы на onCountChange. Если мой текущий Count = 7, то я попросил бы кого-нибудь обновиться на 8. Это то, что мы делаем в чекбоксах, текстфилдах и прочем. Это то, почему очень сложно иногда писать приложение на андроиде, когда есть edit text, и нам хочется что-то поменять, потому что MutableState везде шарится. Тут же counter принимает только snapshot и предлагает кому-то поменять значение.
@Composable
fun App() {
val counterState: MutableState = state { 0f }
Counter(
currentCount = counterState.value,
onCountChange = { counterState.value += 1 }
)
}
У меня есть MutableState, мы говорим в App: вот тебе текущее значение. App обладает state и никому не отдает его. По взгляду на App сразу можно понять, что происходит. Именно поэтому вы можете превратить val counterState с помощью делегатов в var. И в этом случае у вас вообще нет доступа к MutableState. Если вам не нужна обертка, то это, возможно, лучший способ создавать state.
@Composable
fun App() {
var counterStateValue: Int by state { 0f }
Counter(
currentCount = counterStateValue,
onCountChange = { counterStateValue += 1 }
)
}
Важно понимать, что в этом случае мы теряем возможность перелейаутить, перерисовать или переобновить какой-то scope. Но гибкость = ответственность.
MutableState
— это классная штука, но если вы используете его в публичных API, оно ведет к разделению владения state. Альтернатива, о которой я уже говорил, — это controlled components.
Вторая альтернатива — принимать State
только для чтения. То есть, если вам все еще нужно, чтобы ваш Counter очень умно скоупом поглощал MutableState
, вы можете отдать ему этот State
и читать его там, где вам нужно. Все будет так же, мы все перекомпозируем, перелейаутим, перерисуем. Просто State
не дает записать это туда, потому что там val.
Вы можете сделать так и все также через control input вызывать лямбду onCounterChange, чтобы другие люди могли обновлять этот State
.
Другая альтернатива — иммутабельные классы и структуры данных, с ними таких проблем нет. И вообще MutableState использовать необязательно. Вы можете использовать иммутабельные классы, передавать их как параметры в ваши Composable-функции.
Вместо выводов
Декларативные UI-фреймворки абстрагируют обновления UI, дают нам UI как функцию от состояния и быстро обновляют его внутри себя.
На основе этих выводов можно понять, как работает любой такой фреймворк: React, Jetpack Compose и остальные.
Это позволяет нам заниматься важными вещами: анимациями, новой бизнес-логикой, новыми фичами для вашего проекта или вашего приложения. Но важно понимать, как это работает внутри, потому что позволяет делать определенные трейд-оффы, выбирать между вещами, которые позволяет делать фреймворк.
Ссылки
Главная
Туториал
Багтрекер
Kotlin slack, #compose channel
Если доклад Матвея оказался интересен, вероятно, вам будет интересно и на новом Mobius, который пройдёт 13–16 апреля.Там будет и доклад о проекте Compose for Desktop, и много контента для Android-разработчиков: про Gradle, корутины в Kotlin и так далее. Полную программу можно посмотреть на сайте конференции.