Оригинал: Compose From First Principles
В начале текущего месяца (7 мая 2019) тысячи разработчиков со всего мира посетили Google I/O 2019. Для меня (Leland Richardson) данное предприятие было особенно интересным, поскольку именно на нем компания Google представила Jetpack Compose, проект, для работы над которым я был нанят в феврале 2018 года.
Compose является самым амбициозным проектом, по переосмыслению Android UI Toolkit, с момента выпуска Android c оригинальным UI Toolkit.
Если вы еще не посмотрели сессию "Declarative UI Patterns", то там был комплексный обзор мотиваций и целей этого проекта, о которых речь в посте не идет. В этой статье обсуждаются подробности реализации, тк что если вы хотите узнать мотивации данного проекта, посмотрите сессию!
С тех пор, как мы анонсировали Compose, этот проект вызвал большой интерес и породил множество вопросов о том, как это работает. Я потратил некоторое время на размышления о том, о чем нужно поговорить в первую очередь.
Данный пост призван дать людям понимание о том, что делает Compose и исключить любые мысли о какой-либо "магии". Сделать это наилучшим образом, как я полагаю, этот попытаться создать упрощенную версию того, что мы делаем, а затем пошагово дополнять эту версию до того, как у нас получится что-либо, что походит на реальную реализацию. Другими словами, давайте попытаемся построить Compose, начиная с базовых принципов.
В данном посте я не буду пытаться убедить вас в мотивациях, лежащих в основе этой архитектуре или всего проекта. Я только попытаюсь объяснить "что", а не "почему". Я также предполагаю, что читатель данного поста знаком с языком программирования Kotlin и в частности - с функциями расширения.
И наконец, если это еще не ясно, код, приведенный в данном посте, не отображает то, как выглядит реальный код, в котором используется Compose.
По своей сути Compose предназначен для эффективного построения и обработки древовидных структур данных. Если быть более точным, Compose предоставляет модель программирования, описывающую изменение дерева со временем.
Такая модель программирования не является чем-то новым. Мы черпали вдохновение из других фреймворков, таких как React, Litho, Vue, Flutter и другие. Они все выполняют ту же задачу, пусть и немного другими способами.
Как вы уже могли догадаться из списка фреймворков, приведенного выше, одним из наиболее наглядных случаев использованием таких систем, является создание пользовательских интерфейсов (UI). Обычно UI представляют собой древовидные структуры данных, которые меняются со временем. Более того, UI становятся все боле динамичными и сложными, в результате чего возникает потребность в модели программирования, которая поможет справиться с этой сложностью.
Compose не нацелена на конкретный тип дерева и уже используется для работы с несколькими различными типами деревьев. Android Views, ComponentNodes, Vectors, TextSpan, и скорее всего в будущем появятся новые.
Вместо того чтобы фокусироваться на каком-либо из них, давайте обозначим базовую древовидную структуру данных, которую мы могли бы использовать для упрощения примеров, приведенных в данной статье.
Мы можем представить простейшую UI библиотеку, в которой определены следующие типы:
abstract class Node {
val children = mutableListOf()
}
enum class Orientation { Vertical, Horizontal }
class Stack(var orientation: Orientation) : Node()
class Text(var text: String) : Node()
Тут у нас всего два простейших элемента: Stack
и Text
. В реальности их тут возможно было бы больше и они, наверное, имели бы больше свойств, методов и т. д, но, опять-таки, мы не будем их усложнять. В существующем инструментарии Android эти элементы будут соответствовать View
и всем его подклассам, а в html эти элементы отвечали бы любому Element
.
Теперь нам необходим способ взять дерево Node
и отобразить их в пиксели на экране. Как это сделать, не важно для данной статьи, так что давайте просто предположим, что у нас в наличии имеется функция следующей формы:
fun renderNodeToScreen(node: Node) { }
Приложение "Hello World" в данном случаем могла бы выглядеть как-то так:
fun main() {
renderNodeToScreen(Text("Hello World!"))
}
Давайте перейдем к немногим более сложному примеру приложения "To Do List" (Список задач).
Основным руководящим принципом структурирования приложения является отделение концепции "модель" от концепции "UI".
Поскольку наша "модель" является списком TodoItem
, одним способом сделать это будет создание функции, которая преобразует наш список задач в дерево Node
:
fun TodoApp(items: List): Node {
return Stack(Orientation.Vertical).apply {
for (item in items) {
children.add(Stack(Orientation.Horizontal).apply {
children.add(Text(if (item.completed) "x" else " "))
children.add(Text(item.title))
})
}
}
}
Это можно использовать для отрисовки UI из модели данных вашего приложения. Работающее приложение могло выглядеть что-то вроде:
fun main() {
todoItemRepository.observe { items ->
renderNodeToScreen(TodoApp(items))
}
}
Прямое добавление узлов к детям родителей, так как мы сделаем в TodoApp
, может добавить сложности. Для всех узлов дерева мы должны убедиться, что у нас есть доступ к children
родительского Node
и вызвать children:add(...)
. В данном примере это было довольно просто сделать, но, по мере увеличения логики в этой функции, делать это станет все сложнее.
???
Единственное, что мы можем сделать, это создать объект, который будет иметь ссылку на текущей "родительский" Node
. Тогда у нас может быть функция "emit", которая будет добавлять узлы к родителю, а также заиспользовать лямбду "content" для Node
, который вы передали current
. Слово "emit" используется здесь для описания того, что мы храним узел в этой "позиции" в дереве, без необходимости точно знать, к какому узлу мы его добавляем.
В дальнейшем мы еще поработаем с этим контекстным объектом. Он помогает нам, в семантическом смысле, "компоновать дерево ("compose" the tree), так что давайте назовем его Composer
. Мы можем определить его следующим интерфейсом:
interface Composer {
fun emit(node: Node, content: () -> Unit = {})
}
class ComposerImpl(root: Node): Composer {
private var current: Node = root
override fun emit(node: Node, content: () -> Unit = {}) {
val parent = current
parent.children.add(node)
current = node
content()
current = parent
}
}
С помощью этой новой абстракции мы можем переписать нашу функцию TodoApp как функцию расширения на Composer
.
fun Composer.TodoApp(items: List) {
emit(Stack(Orientation.Vertical)) {
for (item in items) {
emit(Stack(Orientation.Horizontal)) {
emit(Text(if (item.completed) "x" else " "))
emit(Text(item.title))
}
}
}
}
Затем мы создадим top-level функцию под названием compose
, которая создает Composer
, выполняет в нем лямбда-выражение в качестве приемника и возвращает корневой узел (node):
fun compose(content: Composer.() -> Unit): Node {
return Stack(Orientation.Vertical).also {
ComposerImpl(it).apply(content)
}
}
И всякий раз, когда мы хотим отобразить наш UI на основе имеющихся у нас элементов, мы можем выполнить следующее:
renderNodeToScreen(compose { TodoApp(items) })
С этой новой абстракцией также можно легко разделить наш UI на более мелкие функции:
fun Composer.TodoItem(item: TodoItem) {
emit(Stack(Orientation.Horizontal)) {
emit(Text(if (item.completed) "x" else " "))
emit(Text(item.title))
}
}
fun Composer.TodoApp(items: List) {
emit(Stack(Orientation.Vertical)) {
for (item in items) {
TodoItem(item)
}
}
}
???
Такой пример простой декомпозиции, или разбиение общих частей логики UI на функции - является критически важной особенностью. Каждую из этих функций мы можем назвать "Components" (Компоненты).
Люди, серьезно относящиеся к производительности, могут посмотреть приведенный выше код и заметить, что мы создаем новое дерево каждый раз при выполнении compose
. Для больших приложений это повлечёт множество лишних операций выделения объектов. С точки зрения корректности — это также значит, что если любые из этих узлов имеют внутренние состояние, оно потеряется при перестройке иерархии.
Исправить ситуацию можно несколькими способами, однако Compose использует технику, которую мы называем "Позиционная мемоизация" (Positional Memoization). Большая часть архитектуры Compose построена вокруг этой концепции, так что попытаемся создать целостное представление о том, как это работает.
В предыдущем разделе мы представили объект Composer
, который содержит контекст того, где мы находимся в дереве и в какой узел обрабатываем в данный момент. Нашей целью является сохранение модели программирования, приведенной выше, и попытаться повторно использовать узлы, созданные нами при предыдущем выполнении UI вместо того, чтобы создавать новые узлы при каждом выполнении. По существу, мы стремимся кэшировать каждый узел.
Большинство кэшей требует ключи, то есть, некоторого способа идентификации результата кэширования определенного объекта. В приведенном выше примере мы видим, что каждый раз, когда мы выполняем функцию TodoApp
, мы каждый раз и в том же порядке создаем одинаковое количество узлов (Nodes
). Если мы предположим, что нам нужно кэшировать каждый узел, из этого следует, что мы должны обращаться к кэшу в том же порядке при каждом выполнении функции (эта логика ломается, если мы привносим в наше приложение любую условную логику, но мы поговорим об этом позже).
В результате, если мы используем порядок исполнения в качестве ключа кэша, мы можем полностью избежать затрат на просмотр и, для переиспользования узлов, можем просто использовать плоский список или массив, в результате чего накладные расходы на извлечение станут очень низкими. Мы можем просто отслеживать "текущий индекс" при выполнении функции преобразования приложения и добавлять его каждый раз при получении значения.
В качестве простой реализации рассмотрим добавление следующего метода memo
к классу Composer
, упомянутому выше:
interface Composer {
fun memo(vararg inputs: Any?, factory: () -> T): T
}
class ComposerImpl: Composer {
private var cache = mutableListOf()
private var index = 0
private val inserting get() = index == cache.size
private fun get(): Any? = cache[index++]
private fun set(value: Any?) {
if (inserting) { index++; cache.add(value); }
else cache[index++] = value
}
private fun changed(value: T): Boolean {
return if (inserting) {
set(value)
false
} else {
val index = index++
val item = cache[index]
cache[index] = value
item != value
}
}
private fun cache(update: Boolean, factory: () -> T): T {
return if (inserting || update) factory().also { set(it) } else get() as T
}
override fun Composer.memo(vararg inputs: Any?, factory: () -> T): T {
var valid = true
for (input in inputs) {
valid = !changed(input) && valid
}
return cache(!valid) { factory() }
}
}
Здесь мы просто используем MutableList
, но в Compose мы используем Gap Buffer с Array
с целью свести к минимуму накладные затраты на поиск, вставки и удаления.
Заметьте, что memo
увеличивает индекс кэша в n+1
раз, когда он вызывается с n
элементов в input. Это обуславливается ожиданием, что каждый раз при вызове для данной "позиции" он будет вызываться одним и тем же количеством input, в противном случае, со временем кэш станет рассогласованным.
С помощью функции memo
мы можем изменить наш предыдущий пример TodoApp
так, чтобы использовать преимущества мемоизации:
fun Composer.TodoItem(item: TodoItem) {
emit(memo { Stack(Orientation.Horizontal) }) {
emit(
memo(item.completed) {
Text(if (item.completed) "x" else " ")
}
)
emit(
memo(item.title) {
Text(item.title)
}
)
}
}
fun Composer.TodoApp(items: List) {
emit(memo { Stack(Orientation.Vertical) }) {
for (item in items) {
TodoItem(item)
}
}
}
Теперь при каждом выполнении compose
узлы в дереве используются повторно, если только они не меняются. Однако, поскольку мы используем порядок выполнения для мемоизации, объем используемой памяти остается неизменным, как и наша модель программирования.
В текущем примере весь узел либо мемоизирован, либо нет, однако мы можем начать мемоизировать отдельные свойства узла при условии, что они модифицируемы.
Например, предположим, что text
является модифицируемым свойством Text
:
class Text(var text: String) : Node()
Поэтому мы можем повторно использовать узел Text просто обновляя атрибут text
каждый раз, когда он меняется. Чтобы сделать это, нам понадобится несколько другая сигнатура emit
:
interface Composer {
fun emit(
factory: () -> T,
update: (T) -> Unit = {},
block: () -> Unit = {}
)
}
class ComposerImpl(val root: Node) : Composer {
override fun emit(
factory: () -> T,
update: (T) -> Unit = {},
block: () -> Unit = {}
) {
val node = memo(factory)
update(node)
emit(node, block)
}
}
В этой версии emit
мы передаем factory
функцию, которую emit
мемоизирует для создания самого Node
. Затем, для текущего экземпляра Node
вызывается функция update
. Лямбда update
может выполнять код, использующий memo
для обновления только измененных свойств.
Например, компонент TodoItem
можно переписать как:
fun Composer.TodoItem(item: TodoItem) {
emit({ Stack(Orientation.Horizontal) }) {
emit(
{ Text() }
{ memo(item.completed) { it.text = if (item.completed) "x" else " " } }
)
emit(
{ Text() }
{ memo(item.title) { it.text = item.title } }
)
}
}
Для оптимизации повторного использования мы можем мемоизировать каждое свойство отдельно и повторно использовать экземпляры Node
при каждом вызове compose
.
Любознательный читатель может посмотреть на это и заметить, что в подходе мемоизации, основанном на порядке исполнения есть проблема. Кажется, что при внесении потока управления любого типа в наши функции преобразования наш подход не сработает. Например, рассмотрим следующую функцию TodoApp
:
fun Composer.TodoApp(items: List) {
emit({ Stack(Orientation.Vertical) }) {
for (item in items) {
TodoItem(item)
}
}
val text = "Total: ${items.size} items"
emit(
{ Text() },
{ memo(text) { it.text = text }}
)
}
В данном примере, если бы у нас было 2 элемента в items
после первой компоновки приложения и 3 после второй, что бы произошло?
Первые два элемента будут мемоизированы правильно. В предыдущем выполнение были те же два элемента, что значит, что кэш был просмотрен одинаковое количество раз с одинаковыми значениями. Так что, никакой проблемы нет.
Интереснее, когда мы добираемся до третьего TodoItem
. В предыдущем исполнении у нас было только два элемента, поэтому, когда мы доходим до третьего элемента в списке, мы начинаем обращаться к кэшу поверх того, что было ранее использовано для узла Text
с "Total: ${items.size} items"
как его текст, поскольку этот узел был следующим в кэше. К тому же, когда мы дойдем до этого узла Text
, кэш не будет иметь значений из предыдущего выполнения, так что мы разместим новый узел Text
.
В принципе, при появлении любого потока управления, из-за которого количество кэшированных элементов меняется, или меняется порядок их исполнения, наш кэш может стать рассогласованным, и мы получим неопределенное поведение.
Чтобы это исправить, нам потребуется внести еще одну фундаментальную концепцию в "Позиционную мемоизацию": Группы.
interface Composer {
fun group(key: Any?, block: () -> Unit)
}
Реализацию этой концепции я оставлю вне данного поста. В реальности ее правильная реализация является довольно сложной и мне кажется, что ее полное объяснение отвлечет нас от основной темы.
Эта концепция усложняет реализацию кэша мемоизации composer, но является критически важной для правильной работы позиционной мемоизации. В принципе, группа это то, что превращает линейный кэш в древовидную структуру, где мы сможем идентифицировать перемещение, удаление или добавление узлов.
Метод group
предполагает использование в нем ключа. Этот ключ будет кэширован в массиве кэша точно так же, как и inputs в memo
, но если он не будет совпадать с ключом от предыдущего исполнения, рантайм будет стремиться выяснить, была ли группа перемещена, удалена или она является новой группой, которую необходимо вставить.
Заметьте, что ключ группы относится только к родительской группе, так что ключи не должны быть глобально уникальными. Они должны быть уникальными только среди объектов, имеющих общего родителя. Теперь, если мы хотим правильно использовать группы в нашем примере TodoApp
, у нас может получиться что-то вроде такого:
fun Composer.TodoItem(item: TodoItem) {
group(3) {
emit({ Stack(Orientation.Horizontal) }) {
group(4) {
emit(
{ Text() }
{ memo(item.completed) { it.text = if (item.completed) "x" else " " } }
)
}
group(5) {
emit(
{ Text() }
{ memo(item.title) { it.text = item.title } }
)
}
}
}
}
fun Composer.TodoApp(items: List) {
group(0) {
emit({ Stack(Orientation.Vertical) }) {
for (item in items) {
group(1) {
TodoItem(item)
}
}
}
}
val text = "Total: ${items.size} items"
group(2) {
emit(
{ Text() },
{ memo(text) { it.text = text } }
)
}
}
В этом случае мы присвоили уникальные числа в качестве ключей каждой группы. Что важно, мы здесь также окружили вызов TodoItem
группой, что обеспечивает независимую мемоизацию каждого TodoItem
.
Теперь, когда количество элементов в items
увеличилось с 2 до 3, мы можем добавлять элементы в кэш вместо того, чтобы смотреть на часть кэша, находящуюся впереди, поскольку она не находится в нашей группе. То же касается случаев удаления элементов из кэша.
Элементы, которые "перемещаются", обрабатываются таким же образом, хотя алгоритм такой обработки сложнее. Мы не будем разбирать это детально, но важным будет отметить, что мы отслеживаем "движения" в группе на основании ключа дочерней группы. Если мы пересортируем список items
в данном примере, тот факт, что каждый вызов TodoItem окружен группой с ключом 1
, значит, что Composer не может узнать, что порядок элементов изменился. Это не фатально и просто значит, что количество мемоизированных изменений вряд ли будет минимальным, и любое состояние, связанное с элементом, может быть связано с другим элементом. Мы можем использовать сам item
как ключ:
for (item in items) {
group(item) {
TodoItem(item)
}
}
Теперь, каждая группа и набор кэшированных значений, содержащихся в ней, будет передвигаться вместе с элементами, а затем TodoItem
будет вызываться с кэшем мемоизации той же группы из предыдущей композиции, увеличивая вероятность того, что изменения будут минимальными, несмотря на возросшие затраты на перемещение кэшированных элементов.
В будущем посте я более детально рассмотрю способы создания ключей с помощью атрибута @Pivotal
.
Пока что мы рассматривали примеры UI, в которых представлен виде простого преобразования или отображения данных. В реальности большинство UI так же содержат состояние, которое не нужно в предметной модели, но важно для самого UI (то есть, является "состоянием UI"). Например, было бы неудобно, если бы выбор текста, положение прокрутки, фокус, видимость диалога и т.п., были бы частью вашей предметной модели данных. Это состояние касается только UI и не более того.
Compose должен иметь модель состояния, которая будет работать с таким вариантом "локального состояния". Возможно, понять такую модель будет проще, если мы попробуем создать ее из концепций, которые мы уже рассмотрели в рамках Позиционной мемоизации (Positional Memoization).
Для обсуждения состояния давайте рассмотрим другой пример простого UI счетчика с кнопками "Increment" и "Reset". Для начала представим, что мы пытаемся получить состояние путем чтения топ-левел count
переменной.
var count = 0
fun Composer.App(recompose: () -> Unit) {
emit({ Text() }, { memo(count) { it.text = "$count" } })
emit({ Button() }, { it.text = "Increment"; it.onClick = { count++; recompose(); } })
emit({ Button() }, { it.text = "Reset"; it.onClick = { count = 0; recompose(); } })
}
fun main() {
var recompose: () -> Unit = {}
recompose = {
renderNodeToScreen(compose { App(recompose) })
}
recompose()
}
Поскольку мы используем глобальное состояние, если этот компонент используется во множестве мест, то состояние будет распределено по всем использованиям. И хотя это может быть полезным в некоторых ситуациях, обычно это не то, что нам нужно. Мы должны иметь возможность создать "экземпляр" счетчика, который может быть использован локально в "экземпляре" App
по композициям ("экземпляр" взят в кавычки, поскольку здесь не существует "экземпляра" App
в ООП смысле. Это просто функция).
Как это сделать в Compose?
Основным в нашей попытке является перенест count
в App
в качестве локальной переменной.
fun Composer.App(recompose: () -> Unit) {
var count = 0
emit({ Text() }, { memo(count) { it.text = "$count" } })
emit({ Button() }, { it.text = "Increment"; it.onClick = { count++; recompose(); } })
emit({ Button() }, { it.text = "Reset"; it.onClick = { count = 0; recompose(); } })
}
Это не сработает, поскольку переменная count
будет сбрасываться в ноль каждый раз при вызове функции.
Заметьте, что это удивительно похоже на то, как каждый раз при вызове функции повторно создаются Node
, где мы использовали позиционную мемоизацию, чтобы исправить это. Получается, что тут мы можем поступить точно так же для с локальным состоянием!
class State(var value: T)
fun Composer.App(recompose: () -> Unit) {
val count = memo { State(0) }
emit({ Text() }, { memo(count.value) { it.text = "${count.value}" } })
emit({ Button() }, { it.text = "Increment"; it.onClick = { count.value++; recompose(); } })
emit({ Button() }, { it.text = "Reset"; it.onClick = { count.value = 0; recompose(); } })
}
Теперь, когда мы используем memo
, экземпляр State
будет одинаковым для каждого последующего вызова функции (но уникальным для ее положения в дереве UI). Затем мы можем мутировать ее и запустить рекомпозицию иерархии, так что экран будет отображать новое значение экземпляра State
.
Итак, мы достигли неплохих успехов в создании UI нашего приложения с помощью этих функций расширения Composer
. При этом, нам пришлось сильно усложнить наш изначальный подход к UI, чтобы сделать данный его эффективным и надежным.
Весь добавленный нами бойлерплейт мог быть добавлены автоматически. Мы могли следовать простой формуле или набору правил и добавить этот бойлерплейт правильным образом, не зная ничего о конкретном применении.
В результате, будет разумно сгенерировать этот код с помощью компилятора. Compose добавляет аннотацию @Composable
, которая делает именно это. В частности, эта аннотация имеет следующие эффекты:
- Все вызовы конструкторов подклассов
Node
заключаются в вызовemit
функции, а так же изменений любых их свойств, оборачивается вызовомmemo
. - Любые другие функции, отмеченные
@Composable
, которые вызываются в теле функции, заключаются в группы. Ключ каждой группы будет скомпилирован в виде целого числа, уникального для позиции в исходном коде точки вызова. - Все вызовы
emit
в теле функции также заключены в группы. Таким же образом, ключ каждой группы будет скомпилирован в виде целого числа, уникального для позиции в исходном коде точки вызова. - Функция получает дополнительный, неявно определенный
Composer
в качестве параметра, вместо того, чтобы стать функцией расширения Composer. Это возможно, поскольку единственный код, использующийComposer
, стал неявным, согласно (1) и (2). - Это значит, что он может быть вызван только из другой функции
@Composable
. Это требуется для работы (3), поскольку в точке вызова мы вынуждены передать объект вComposer
неявно.
Имея эти эффекты, мы видим, что упомянутая выше функция App
превратится в:
class State(var value: T)
@Composable fun App(recompose: () -> Unit) {
val count = memo { State(0) }
Text("${count.value}")
Button(text = "Increment", onClick = { count.value++; recompose(); })
Button(text = "Reset", onClick = { count.value = 0; recompose(); })
}
Таким же образом, приведенная выше функция TodoApp
может стать:
@Composable fun TodoItem(item: TodoItem) {
Stack(Orientation.Horizontal) {
Text(if (item.completed) "x" else " ")
Text(item.title)
}
}
@Composable fun TodoApp(items: List) {
Stack(Orientation.Vertical) {
for (item in items) {
TodoItem(item)
}
}
Text("Total: ${items.size} items")
}
Это в определенной степени упрощает дело. Хотя аннотация @Composable
реализует некоторый объем механизмов обработки данных вокруг их вызовов, это не должно коренным образом менять представление, сложившуюся у кого-либо, про простой вызов функции. Это аналогично механизму, необходимому для реализации suspend
функций и корутин в Kotlin. Мы могли бы написать аналогичный код с помощью Futures, но если мы можем создать представление вокруг того, что значит "приостановление" (suspend
), мы можем встроить его в язык и значительно снизить количество бойлерплейта.
???
С таким отображением @Composable
на наш придуманный рантайм, у вас должно было появиться понимание того, что делает @Composable
, а также откуда появились дизайнерские решений, приведшие Compose к текущее состояние.
Конечно, существует еще множество аспектов, требующих рассмотрения, но думаю, что для одного поста этого более чем достаточно. Вот еще список вещей, которые Compose делает (или будет), не вошедщих в данный пост:
- Работа аннотации
@Model
- Откладывание и параллелизация композируемых функций
- Пропуск выполнения композируемых функций, когда они мемоизированы
- Инвалидация/рекомпозиция определенных ветвей иерархии в структуре дерева
- Наличие функций
@Composable
, сосредоточенных на различных типах деревьев с безопасностью во время компиляции - Оптимизация сравнений выражений, которые, никогда не изменятся
Это все потенциальные будущие темы для обсуждения!
Расскажите, помог ли вам данный пост лучше понять Compose, или нет. Если нет, расскажите, что вам было непонятно!
Возникли вопросы? Вы можете найти меня в Twitter!
Заинтересовались Compose и хотите поболтать о нем с другими? Заходите на канал #compose
на Kotlin Slack (получить приглашение)