Jetpack Compose — одна из наиболее обсуждаемых тем из серии видео про Android 11, заменивших собой Google IO. Многие ожидают от библиотеки, что она решит проблемы текущего UI-фреймворка Android, содержащего много легаси-кода и неоднозначных архитектурных решений. Другим не менее популярным фреймворком, о применении которого я расскажу в этой статье является Kotlin Coroutines, а конкретнее — входящий в него Flow API, который может помочь избежать оверинжиниринга при использовании RxJava.
Применение этих инструментов я покажу на примере небольшого приложения для контроля за употреблением кофе, написанного с использованием Jetpack Compose для UI и StateFlow как инструмента для управления состоянием. В нем также используется MVI-архитектура.



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


Идея этого приложения родилась из моей любви к кофе. Однажды я заметил, что могу выпить его довольно много (особенно во время написания кода), и мне захотелось зафиксировать количество выпитых чашкек. Уже существует множество подобных приложений для контроля потребления воды или алкоголя, но не так много для кофе. В первой реализации приложение состоит из двух экранов:  календарь с иконками кофейных чашек на месяц и список с количеством чашек разных видов в конкретный день.


Jetpack Compose


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


Помочь с переходом может сайт, на котором можно найти компонент в Compose по его названию в традиционном UI.


Также как во Flutter, в Compose вы можете использовать MainActivity как точку входа в приложение. Навигация же может быть сделана посредством композиции виджетов без необходимости использовать другие активити или фрагменты. Вы можете также поместить часть, написанную на Flutter или Compose в любую другую новую активити уже существующего приложения. Разработчики Compose планируют также добавить удобный API для навигации, похожий на тот, который есть в Flutter.


Свой проект я начал с шаблона Compose-проекта в Android Studio. Ниже приведен код из MainActivity.kt:


class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            CoffeegramTheme {
                Greeting("Android")
            }
        }
    }
}

@Composable
fun Greeting(name: String) {
    Text(text = "Hello $name!")
}

@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    CoffeegramTheme {
        Greeting("Android")
    }
} 

Если вы уже знакомы с Compose, можете просмотреть эту часть бегло
Весь Compose построен на функциях, помеченных аннотацией @Composable. Она позволяет плагину компилятора Котлина сгенерировать необходимый для работы код.
Вместо обычной функции setContentView(), вызываемой внутри Activity.onCreate() для инфлейта лейаутов, нужно вызвать функцию setContent(), передав ей в качестве параметра Composable-функцию.


Недавно появилась аннотация @Preview для Composable-функций, с помощью которой в новых версиях Android Studio (я использовал 4.2 Canary) можно увидеть превью помеченного компонента. Однако для этого нужно будет пересобрать проект. Этот механизм немного напоминает Hot Reload из Flutter, но работает медленнее из-за необходимости пересборки и не предоставляет синхронного анализатора кода, подсвечивающего ошибки компиляции всего проекта. Так у вас не получится увидеть превью, если вы меняете UI в одном файле, а в других остались ошибки компиляции.
Другая проблема, с которой я столкнулся, возникла после удаления директории .idea из Git и с диска после коммита. Превью перестал работать совсем, из-за чего пришлось начать проект с шаблона заново. Надеюсь, что это поправят в следующих версиях студии.
Тем не менее будет полезным оставлять как минимум одну превью функцию в каждом файле, чтобы отслеживать изменения в текущем.
Можно также аннотировать одну Composable-функцию несколькими превью-аннотациями с разными параметрами, чтобы, например, отслеживать вид компонента сразу в светлой и темной теме, нескольких размерах или с тестовыми данными. Сейчас не буду останавливаться подробнее, но пример можно посмотреть тут.


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


image

data class CoffeeType(
    @DrawableRes
    val image: Int,
    val name: String,
    val count: Int = 0
)

@Composable
fun CoffeeTypeItem(type: CoffeeType) {
    Row(
        modifier = Modifier.padding(16.dp)
    ) {
        Image(
            imageResource(type.image), modifier = Modifier
                .preferredHeightIn(maxHeight = 48.dp)
                .preferredWidthIn(maxWidth = 48.dp)
                .fillMaxWidth()
                .clip(shape = RoundedCornerShape(24.dp))
                .gravity(Alignment.CenterVertically),
            contentScale = ContentScale.Crop
        )
        Spacer(Modifier.preferredWidth(16.dp))
        Text(
            type.name, style = typography.body1,
            modifier = Modifier.gravity(Alignment.CenterVertically).weight(1f)
        )
        Row(modifier = Modifier.gravity(Alignment.CenterVertically)) {
            val count = state { type.count }
            Spacer(Modifier.preferredWidth(16.dp))
            val textButtonModifier = Modifier.gravity(Alignment.CenterVertically)
                .preferredSizeIn(
                    maxWidth = 32.dp,
                    maxHeight = 32.dp,
                    minWidth = 0.dp,
                    minHeight = 0.dp
                )
            TextButton(
                onClick = { count.value-- },
                padding = InnerPadding(0.dp),
                modifier = textButtonModifier
            ) {
                Text("-")
            }
            Text(
                "${count.value}", style = typography.body2,
                modifier = Modifier.gravity(Alignment.CenterVertically)
            )
            TextButton(
                onClick = { count.value++ },
                padding = InnerPadding(0.dp),
                modifier = textButtonModifier
            ) {
                Text("+")
            }
        }
    }
}

Элемент списка здесь создаётся с использованием аналога ListView с горизонтальной ориентацией — виджета Row. Внутри него помещается иконка (загруженный в виджет Image png-файл из drawable); разделительный виджет Spacer; Text с названием напитка, занимающий всё свободное пространство из-за применения модификатора weight(1f) (принцип похож на веса в ListView); и вложенный Row с двумя кнопками и текстом для отображения количества чашек.


Превью в Android Studio позволяет запустить виджет в интерактивном режиме. Это позволит с помощью тапов и других действий изменить состояние прямо в превью, без запуска приложения целиком на устройстве. Но если виджет получается слишком сложным (и превью в Android Studio не справляется), его можно запустить отдельно на эмуляторе. Так можно увидеть конкретный виджет без необходимости докликивать до него по всему приложению.


State


Виджет, показанный в коде выше, уже запускается в интерактивном режиме с возможностью изменить кнопками количество чашек. Это возможно из-за строки val count = state { type.count }, в которой функция state забирает исходное количество из модели type и оповещает окружающую ее Composable-функцию о каждом изменении этого состояния. Внутренние виджеты могут получить и изменить текущее значение через свойство count.value. Когда ему будет присвоено новое значение, поддерево виджетов, начиная с виджета, вызывающего функцию получения состояния, будет перерисовано (помимо state это может быть также collectAsState и прочие).


В отличие от Flutter, в Compose нет разделения на Stateful (с состоянием) и Stateless (без состояния) виджеты. Каждый виджет, содержащий вызов функции получения состояния может условно считаться Stateful, а остальные Stateless.


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


@Composable
fun CoffeeList(coffeeTypes: List<CoffeeType>) {
    Column {
        coffeeTypes.forEach { type ->
            CoffeeTypeItem(type)
        }
    }
}

@Composable
fun ScrollableCoffeeList(coffeeTypes: List<CoffeeType>) {
    VerticalScroller(modifier = Modifier.weight(1f)) {
        CoffeeList(coffeeTypes: List<CoffeeType>)
    }
}

Composable функции могут быть вложены внутрь условных операторов и циклов, таких как  if, for, when и т.п. Виджет Column представляет собой аналог ListView с вертикальной ориентацией, а VerticalScroller — аналог ScrollView.
Проблема в этом коде должна быть очевидной. Список будет не оптимизированным и лагать во время скроллинга. Есть ли в Compose RecyclerView? Да — представленный виджетом LazyColumnItems (еще недавно он назывался AdapterList). С ним реализация списка CoffeeList будет выглядеть следующим образом:


@Composable
fun CoffeeList( coffeeTypes: List<CoffeeType>, modifier: Modifier = Modifier) {
    LazyColumnItems(data = coffeeTypes, modifier = modifier.fillMaxHeight()) { type ->
        CoffeeTypeItem(type)
    }
}

На данный момент нет аналога для RecyclerView с GridLayoutManager (для создания сетки вместо линейного списка). Но для приложения уже сделан один из двух экранов.


image

Перед тем как сделать следующий, нужно подумать о навигации.


Навигационные элементы из Material design реализованы очень похоже во Flutter и Compose. Корневым элементом является виджет Scaffold, обернутый темой. Он может содержать TopAppBar (верхнюю панель меню), BottomAppBar (нижнюю панель меню, с возможностью интегрировать кнопку — Floating action button) или Drawer (левое боковое меню). Чтобы реализовать BottomNavigationView из Material я поместил в Scaffold виджет Column с BottomNavigation внутри:


@Composable
fun DefaultPreview() {
    CoffeegramTheme {
        Scaffold() {
                Column() {
                    var selectedItem by state { 0 }
                    when (selectedItem) {
                        0 -> {
                            Column(modifier = Modifier.weight(1f)){}
                        }
                        1 -> {
                            CoffeeList(listOf(...))
                        }
                    }

                    val items =
                        listOf(
                            "Calendar" to Icons.Filled.DateRange, 
                            "Info" to Icons.Filled.Info
                        )

                    BottomNavigation {
                        items.forEachIndexed { index, item ->
                            BottomNavigationItem(
                                icon = { Icon(item.second) },
                                text = { Text(item.first) },
                                selected = selectedItem == index,
                                onSelected = { selectedItem = index }
                            )
                        }
                    }
                }
            }
    }
}

Состояние текущей выбранной вкладки содержится в selectedItem. Его состояние  с помощью оператора when формирует контент для вкладки. Изменение вкладки происходит по клику на BottomNavigation с последующим изменением значения selectedItem. Такой способ построения навигации позволяет отказаться от использования фрагментов и активити кроме корневой для дерева Compose.


Реализацию второго экрана с таблицей и покоммитное создание кода приложения можно посмотреть в репозитории. Полезной деталью, которую я встретил тут, стал способ доступа к контексту для получения, например, ресурсов или текущей локали. Для этого нужно вызвать ContextAmbient.current.context внутри любой Composable-функции. Сам же экран с месячной таблицей выглядит так:


image

Во время разработки я заменил используемые png-иконки для типов кофе на векторные. Для этого функцию imageResource внутри виджета Image надо заменить на vectorResource. Можно также попробовать использовать виджет Icon для этой цели (как сделал изначально я), но тогда иконки будут монохромными.


StateFlow


Теперь перейдем ко второй части названия статьи. Flow является аналогом реактивных стримов в составе Корутин. Его можно рассматривать как холодную последовательность данных — они начинают поступать только после подписки (вызова терминальной функции). Для передачи состояния между разными компонентами приложения в реактивном стиле нужен аналог BehaviorSubject из RxJava. Таким аналогом является StateFlow. Как и BehaviorSubject, он может иметь несколько подписчиков и должен быть проинициализирован исходным значением.


Для иллюстрации его использования в примере, показанном выше, состояние selectedItem может быть заменено с помощью selectedItemFlow:


val selectedItemFlow = MutableStateFlow(0)
@Composable
fun DefaultPreview() {
    ...
    val selectedItem by selectedItemFlow.collectAsState()

    when (selectedItem) {
        0 -> TablePage()
        1 -> CoffeeListPage()
    }
    ...
    BottomNavigationItem(
        selected = selectedItem == index,
        onSelected = { selectedItemFlow.value = index }
    )
}

Состояние получается из StateFlow (или другого Flow) с помощью вызова функции collectAsState().  Оно используется для определения необходимости перерисовки, а также получения текущего значения.


Чтобы изменить состояние, нужно присвоить значение свойству selectedItemFlow.value.


Так как текущее значение может быть также получено через это свойство, важно не забыть вызвать collectAsState() внутри виджета. Иначе он не будет обновляться вместе с состоянием. Возможным паттерном тут может быть использование состояния (val selectedItem by selectedItemFlow.collectAsState()) для чтения значения, а свойства MutableStateFlow (selectedItemFlow.value) — для изменения.


Для прототипа приложения, который позволяет переключать ежемесячные таблицы, смотреть и изменять количество ежедневых чашек, мне понадобилось три StateFlow:


val yearMonthFlow = MutableStateFlow(YearMonth.now())
val dateFlow = MutableStateFlow(-1)
val daysCoffeesFlow: DaysCoffeesFlow = MutableStateFlow(mapOf())

yearMonthFlow отвечает за текущий отображаемый месяц.
dateFlow — за выбранный день в таблице и навигацию между экранами: если текущее значение равно -1 — отображается экран с таблицей — TablePage. В другом случае это будет экран со списком — CoffeeListPage для конкретного дня месяца.
daysCoffeesFlow — прототип репозитория, содержащего все записанные чашки кофе. Его внутренняя структура стала решением следующей проблемы.


Когда пользователь переходит из TablePage в CoffeeListPage, состояние этого экрана должно быть частью (за конкретный день) из общего состояния, представленного в daysCoffeesFlow. Состояние элемента списка CoffeeList внутри должно быть также частью состояния целого списка. Когда изменяется количество чашек внутри, сам элемент не может знать, как изменить общее состояние daysCoffeesFlow. Мы помогаем ему, создавая набор мапперов из более общих Flow в более конкретные и наоборот.


Такое временное решение добавило четыре лишних типа, показанных в файле DayCoffee.kt. Если бы уровней вложенности было больше, то и мапперов было бы ещё больше.


Эти мапперы привели к трудночитаемому коду внутри UI-функций. Поэтому я решил применить MVI-архитектуру. Существующие решения, такие как MVICore, показались слишком завязанными на RxJava или другие асинхронные фреймворки и слишком сложными для текущей задачи. Моё решение базируется на статье Android MVI with Kotlin Coroutines & Flow article. Базовые концепции MVI с диаграммами можно найти там же. Здесь я покажу код базового класса Store:


abstract class Store<Intent : Any, State : Any>(private val initialState: State) {
    protected val _intentChannel: Channel<Intent> = Channel(Channel.UNLIMITED)
    protected val _state = MutableStateFlow(initialState)

    val state: StateFlow<State>
        get() = _state

    fun newIntent(intent: Intent) {
        _intentChannel.offer(intent)
    }

    init {
        GlobalScope.launch {
            handleIntents()
        }
    }

    private suspend fun handleIntents() {
        _intentChannel.consumeAsFlow().collect { _state.value = handleIntent(it) }
    }

    protected abstract fun handleIntent(intent: Intent): State
}

Store работает на входящих Intent-ах и предоставляет StateFlow<State> подписчикам. Он содержит несколько вспомогательных функций, которые позволяют наследникам реализовать только похожую на Reducer функцию handleIntent() и иерархию собственных интентов и состояний. Пользователи наследников Store могут получить состояние через свойство state, возвращающее StateFlow; или передать новый интент с помощью функции newIntent().


Ниже приведен пример такого наследника NavigationStore, реализующего снова логику навигации:


class NavigationStore : Store<NavigationIntent, NavigationState>(
        initialState = NavigationState.TablePage(YearMonth.now())
    ) {

    override fun handleIntent(intent: NavigationIntent): NavigationState {
        return when (intent) {
            NavigationIntent.NextMonth -> {
                increaseMonth(_state.value.yearMonth)
            }
            NavigationIntent.PreviousMonth -> {
                decreaseMonth(_state.value.yearMonth)
            }
            is NavigationIntent.OpenCoffeeListPage -> {
                NavigationState.CoffeeListPage(
                    LocalDate.of(
                        _state.value.yearMonth.year,
                        _state.value.yearMonth.month,
                        intent.dayOfMonth
                    )
                )
            }
            NavigationIntent.ReturnToTablePage -> {
                NavigationState.TablePage(_state.value.yearMonth)
            }
        }
    }

    private fun increaseMonth(yearMonth: YearMonth): NavigationState {
        return NavigationState.TablePage(yearMonth.plusMonths(1))
    }

    private fun decreaseMonth(yearMonth: YearMonth): NavigationState {
        return NavigationState.TablePage(yearMonth.minusMonths(1))
    }
}

sealed class NavigationIntent {
    object NextMonth : NavigationIntent()
    object PreviousMonth : NavigationIntent()
    data class OpenCoffeeListPage(val dayOfMonth: Int) : NavigationIntent()
    object ReturnToTablePage : NavigationIntent()
}

sealed class NavigationState(val yearMonth: YearMonth) {
    class TablePage(yearMonth: YearMonth) : NavigationState(yearMonth)
    data class CoffeeListPage(val date: LocalDate) : NavigationState(
        YearMonth.of(date.year, date.month)
    )
}

Начнем с конца. Здесь можно увидеть два sealed-класса, отвечающих за все возможные интенты и состояния, с которыми работает этот Store. Интенты представляют возможные действия в UI. А состояния навигации — соответствующие экраны приложения.


Параметр initialState в NavigationStore представляет собой состояние-экран, которое увидит пользователь, только открыв приложение.


Функция handleIntent() содержит бизнес-логику превращения интентов в состояния.
Второй DaysCoffeesStore, отвечающий непосредственно за кофе, как и весь код приложения, можно найти в репозитории.


Несмотря на детские болезни Jetpack Compose и необходимость поменять мышление для полного погружения, он уже выглядит как готовая для использования в некритичных приложениях технология и будущее Android-разработки. С ним легко применить современные техники, такие как архитектуры с однонаправленным потоком данных и Корутины, решая некоторые задачи (как, например, навигацию) легче. Я думаю, что разработчики должны начать знакомиться с ним, чтобы не пропустить тот момент, когда Compose (вместе с Корутинами) появится в списке обязательных технологий в вакансиях.


С популяризацией декларативных UI-фреймворков, таких как Compose, Flutter и SwiftUI, мобильная разработка становится всё больше похожей на Web. Это может привести, как к унификации используемых архитектур, так и к увеличению переиспользования кода между большинством клиентских платформ.