Orbit MVI с сахаром: вкусный фреймворк для однородной архитектуры

47504ad1bd5d5ce5830fcdd02b0853a8.jpg

Привет, $username! Меня зовут Анастасия, я junior-android-разработчик в МТС Диджитал. Пойдем, расскажу тебе про фреймворк OrbitMVI. Узнала я о нем от своего лида: он делегировал мне задачу заменить наш самодельный Redux на Orbit, чтобы в нашем приложении архитектура стала удобнее и однороднее. 

Сначала я пошла читать документацию, что из себя представляет Orbit MVI и с чем его едят. Затем попробовала его на небольшом тестовом проекте и поняла: это интересный, удобный и понятный фреймворк. В этом посте я покажу, что в «Орбите» есть полезного и как его можно использовать.

Где тут Orbit и какие есть сладости?

445167dbc90fb18cd46167e8aa4d44cb.jpg

Когда я пришла на проект, мне нужно было погрузиться в кодовую базу. В процессе ее изучения я поняла, что тут что-то не так. В проекте не было общего code style: в одном компоненте я нашла MVVM, в другом — самописный Redux, в третьем — самописный Redux 2.0. То есть подход MVI у нас уже был внедрен, но при этом реализован не до конца и в нескольких вариантах. В результате мне пришлось задаться вопросом: что внедрять в новые фичи и нужно ли переделывать старые? Ответ моего лида не заставил себя долго ждать: «Нужно!».

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

Так как проект кроссплатформенный, нужно было найти удобный и простой инструмент, который можно было бы внести в наш KMM-проект. В результате и был выбран Orbit MVI.

Чтобы проверить работу и посмотреть основные синтаксические плюшки Orbit MVI, я сделала небольшое демоприложение. Тратить на него больше одного дня не хотелось, поэтому я реализовала в нем несколько простых функций: получение фото по API, просмотр деталей о них, удаление из списков.

На примере этой демки я покажу, как работает Orbit MVI. Не буду останавливаться на слоях с сетевыми запросами и репозиториями, так как моя главная цель — подсветить основные плюсы использования данного фреймворка.

MVI: весь мир — это состояние

MVI расшифровывается как Model-View-Intent и принадлежит к UDF-архитектурам (Undirectional Data Flow). Они предполагают наличие однонаправленного потока действий и управления состоянием. Подробней про них вы можете почитать по этой ссылке, где Ханнес Дорфман описал свой опыт ее использования. 

Контейнер Orbit: состояния и эффекты в одном месте

Основой всего является класс Container, который будет хранить в себе поток состояний и сайд-эффекты.

Из официальной документации:

Контейнер предоставляет потоки, которые отправляют обновления состояния контейнера и побочных эффектов.

f49945723e53c8d1690f807485989bb3.jpg

Опционально ему можно задать другие параметры, написав собственные DSL (domain specific language). Любые изменения состояния — загрузка и успешное получение данных, ошибка — будут переданы подписчикам через поток. Внутри фреймворка это реализуется с помощью механизма StateFlow.

Orbit позволяет написать DSL при помощи аннотаций @OrbitDsl, но в данном посте я это не буду рассматривать, т. к. каждый пишет их для своих нужд и удобства.

При работе с Container я впервые в своей практике использовала класс Nothing (не считая мини-задач для подготовки к собесам). Его можно прокинуть, например, если вам не нужны сайд-эффекты на экране и лень писать свой кастомный класс, который не будет принимать в себя их.

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

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

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

Для этого внутри каждой функции, которая использует intent, Container создает свой собственный контекст — ContainerContext. В нем предоставляется «снимок» состояния в момент создания. Этим снимком можно манипулировать при помощи различных операций. Об intent и доступных операциях я расскажу ниже. ContainerContext выполняет логику, обновляет состояние или отправляет сайд-эффекты, результат передается обратно в Container.

Перейдем к примеру в виде небольшого проекта, чтобы лучше понимать, о чем идет речь.

Время для практики

Для начала подключим фреймворк к своему проекту, для этого в libs.versions.toml (ты ведь уже мигрировал на каталоги версий, да?) пишем:

[versions]
orbit = "9.0.0"

[libraries]
orbit-compose = { module = "org.orbit-mvi:orbit-compose", version.ref = "orbit" }
orbit-core = { module = "org.orbit-mvi:orbit-core", version.ref = "orbit" }
orbit-test = { module = "org.orbit-mvi:orbit-test", version.ref = "orbit" }
orbit-viewmodel = { module = "org.orbit-mvi:orbit-viewmodel", version.ref = "orbit"
  
implementation(libs.orbit.core)
// Для View Android:
  implementation(libs.orbit.viewmodel)
// Для Jetpack Compose:
implementation(libs.orbit.compose)

// Для тестов
testImplementation(libs.orbit.test)

Для экрана с картинками делаем обычный data class с параметрами отображения списка, ошибки или загрузки/рефреша изображений и для выбранного фото:

data class ImagesListState( 
val images: List = emptyList(), 
val selectedImage: Image? = null,
 val isLoading: Boolean = false,
 val error: String? = null, 
)

Сюда же добавляем класс с побочными эффектами:

sealed class ImagesListSideEffect {
    data class ShowToast(val message: String) : ImagesListSideEffect()
    data class NavigateToErrorScreen(val errorMessage: String) : ImagesListSideEffect()
    data class NavigateToPhotoDetail(val photo: Photo) : ImagesListSideEffect()
}

Определим класс действий, которые могут происходить:

sealed class MainScreenAction {
    data object LoadPhotos : MainScreenAction()
    data class DeletePhoto(val image: Image) : MainScreenAction()
    data class SelectPhoto(val image: Image) : MainScreenAction()
}

Переходим к самой вью-модели. Здесь дело вкуса: можно из Redux переносить нейминг и называть класс AppStore, я же предпочту AppViewModel.

Мы наследуемся от ContainerHost, куда передаем состояние экрана и сайд-эффекты:

class AppViewModel( private val repo: ApiRepository ) : ContainerHost, ViewModel() { 

override val container = container(ImagesListState()) 

init { dispatch(MainScreenAction.LoadPhotos) } 

private val ceh = CoroutineExceptionHandler { _, exc -> 
intent { 
reduce { state.copy(isLoading = false, error = exc.message) 
} 
 }

Здесь делаем функцию dispatch, которая действует как центральный обработчик всех пользовательских действий (actions), поступающих в нашу вью-модель. Она принимает объект действия, определяет его тип и вызывает соответствующую функцию для выполнения.

    fun dispatch(action: MainScreenAction) {
        when (action) {
            is MainScreenAction.LoadPhotos -> loadPhotos()
            is MainScreenAction.DeletePhoto -> deletePhoto(action.image)
            is MainScreenAction.SelectPhoto -> handleSelectPhoto(action.image)
        }
    }


    private fun loadPhotos() = intent {
        viewModelScope.launch(ceh) {
            reduce { state.copy(isLoading = true) }

            repo.getPhotos().collect { images ->
                reduce { state.copy(images = images, isLoading = false, error = null) }
            }
        }
    }

    private fun deletePhoto(image: Image) = intent {
        viewModelScope.launch(ceh) {
            val updatedImages = state.images.filter { it.id != image.id }
            reduce { state.copy(images = updatedImages) }
            postSideEffect(ImagesListSideEffect.ShowToast("Фото удалено"))
        }
    }

    private fun handleSelectPhoto(image: Image) = intent {
        viewModelScope.launch(ceh) {
            reduce { state.copy(selectedImage = image) }
            postSideEffect(ImagesListSideEffect.NavigateToPhotoDetail(image))
        }
    }

}

Разберем функции выше и посмотрим, как Orbit MVI меняет структуру вью-модели и какие синтаксические слова здесь используем.

Intent и Reduce — наши друзья

intent — это блок, содержащий бизнес-логику. Так как под капотом используются корутины, есть возможность прямо из него вызывать suspend-функции без viewModelScope. Если простыми словами, intent — это запрос на выполнение какого-то действия, которое влияет на состояние приложения. Мы говорим системе: «Я хочу, чтобы ты что-то сделал: загрузил данные, обновил UI или что-то еще». 

private fun deletePhoto(image: Image) = intent {
    //наша логика 
}

Что можно сделать в intent?

Reduction 

Reduce — это основной оператор для изменения состояния. Он позволяет обновить состояние, создавая новый объект на основе текущего состояния, и применять изменения:

private fun handleSelectPhoto(photo: Photo) = intent {
        reduce { state.copy(selectedPhoto = photo) }
        postSideEffect(ImagesListSideEffect.NavigateToPhotoDetail(photo))
    }

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

Когда мы вызываем reduce, происходит следующее:  

Сначала вызывается редуктор, который является функцией, изменяющей текущее состояние. В лямбде редуктора прокидываем логику для изменения состояния выбранного фото:

reduce { state.copy(selectedPhoto = photo) }

Затем происходит создание нового состояния, которое основывается на текущем, но с обновленными данными. Этот процесс осуществляется внутри контейнера через вызов containerContext.reduce. Эта функция:

  • принимает редуктор (S) → S (S — тип состояния);

  • применяет его к текущему состоянию, извлекаемому из stateFlow;

  • обновляет stateFlow новым значением состояния.

После обновления все изменения транслируются через StateFlow, и подписчики могут наблюдать за ними, поэтому вся система остается синхронизированной.

Side-effect

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

private fun handleSelectPhoto(photo: Photo) = intent {
        reduce { state.copy(selectedPhoto = photo) }
        postSideEffect(ImagesListSideEffect.NavigateToPhotoDetail(photo))
    }

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

Чтобы отобразить сайд-эффект на UI, мы используем collectSideEffect.

collectSideEffect — это функция-расширение, которая подписывается на поток сайд-эффектов во вью-модели. Когда в ней срабатывает какой-то сайд-эффект, например показ тоста или навигация на другой экран, этот поток передает его в collectSideEffect, и они сразу обрабатываются на UI.

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

viewModel.collectSideEffect { sideEffect ->
        when (sideEffect) {
            is ImagesListSideEffect.ShowToast -> {
                Toast.makeText(
                    context,
                    sideEffect.message,
                    Toast.LENGTH_SHORT
                ).show()
            }

            is ImagesListSideEffect.NavigateToPhotoDetail -> {
                navController.navigate("photo_detail_screen_route/${sideEffect.image.id}")
            }

        }
   }

Здесь используется collectSideEffect, который «слушает» сайд-эффекты и дожидается, пока появится новое действие для обработки. Когда это происходит, collectSideEffect использует LaunchedEffect, чтобы выполнить необходимые задачи (например, сделать запрос к серверу или показать уведомление). Внутри он получает из StateFlow события, которые он должен обработать. В LaunchedEffect прокидывается флоу событий и lifecycleOwner, чтобы в дальнейшем передать в repeatOnLifecycle, и собирает в collect все события.

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

@Composable
fun ImagesScreen(
    viewModel: AppViewModel,
    navController: NavController
) {
    val state by viewModel.collectAsState()
    val context = LocalContext.current

    Box(modifier = Modifier.fillMaxSize()) {
        when {
            state.isLoading -> {
                CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
            }

            state.error != null -> {
                Text(text = "${state.error}", modifier = Modifier.align(Alignment.Center))
            }

            state.images.isNotEmpty() -> {
                ImagesList(
                    images = state.images,
                    onItemClick = { photo ->
                        viewModel.dispatch(MainScreenAction.SelectPhoto(photo))
                    },
                    onDeleteClick = { photo ->
                        viewModel.dispatch(MainScreenAction.DeletePhoto(photo))
                    }
                )
            }

            else -> {
                Text(
                    text = "Нет доступных изображений",
                    modifier = Modifier.align(Alignment.Center)
                )
            }
        }
    }
  
    //Можно вынести отдельно в функцию
    viewModel.collectSideEffect { sideEffect ->
        when (sideEffect) {
            is ImagesListSideEffect.ShowToast -> {
                Toast.makeText(
                    context,
                    sideEffect.message,
                    Toast.LENGTH_SHORT
                ).show()
            }

            is ImagesListSideEffect.NavigateToPhotoDetail -> {
                navController.navigate("photo_detail_screen_route/${sideEffect.image.id}")
            }

        }
    }
}

Другие полезные DSL

Выше я рассказала про базовые и частые DSL-ки, но хотелось бы упомянуть еще:

Repeat on subscription

fun loadPhotosRepeatOnSub() = intent(registerIdling = false) {
        //гарантирует, что собираем изображения только тогда, когда подписка на UI активна
        repeatOnSubscription {
            repo.getPhotos().collect { images ->
                reduce { state.copy(images = images, isLoading = false, error = null) }
            }
        }
    }

Если мы собираем данные из потока в блоке intent, подписка может продолжаться даже с неактивным UI. Это не всегда хорошо, особенно если мы работаем с ресурсозатратными данными (например, геолокацией или Bluetooth).

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

Sub-intent

    @OptIn(OrbitExperimental::class)
    suspend fun filterImages() = subIntent {
        val filteredImages = state.images.filter { it.photographer.contains("John") }
        reduce { state.copy(images = filteredImages) }
    }

    @OptIn(OrbitExperimental::class)
    suspend fun fetchImages() = subIntent {
        repo.getPhotos().collect { images ->
            reduce { state.copy(images = images) }
        }
    }

    fun loadAndFilterPhotos() = intent {
        fetchImages() 
        filterImages() 
    }

subIntent — функция, которая работает как suspend, но с возможностью использовать синтаксис Orbit. Она помогает разбивать большие intent на части, позволяя выполнять задачи параллельно и управлять отдельными процессами внутри основного intent-блока.

Моя любимая часть — тесты (нет)

47e212bc1b37074812f918b368e834ef.png

«Синтаксис, конечно, приятно, а что с тестированием?» — подумала я после написания основной части поста. Создадим ознакомительный тест для этой функции:

private fun handleSelectPhoto(image: Image) = intent {
        viewModelScope.launch(ceh) {
            reduce { state.copy(selectedImage = image) }
            postSideEffect(ImagesListSideEffect.NavigateToPhotoDetail(image))
        }
    }

Тестирование с Orbit начинается с того, что мы помещаем наш ContainerHost в тестовый режим с помощью функции test (). Это позволяет нам использовать тестовый скоуп и при необходимости задать начальное состояние для контейнера:

@Test
    fun `Updates selectedImage and posts navigation side effect`() = runTest {
        val testImage = Image(id = 1, url = "url1", photographer = "John")
        AppViewModel(fakeRepo).test(this) {
            expectInitialState()
            containerHost.dispatch(MainScreenAction.SelectPhoto(testImage))
            expectState {
                copy(selectedImage = testImage)
            }
            expectSideEffect(ImagesListSideEffect.NavigateToPhotoDetail(testImage))
        }
    }

expectInitialState () проверяет, что состояние вашего экрана (или приложения) в самом начале теста именно такое, как вы задумали.

Далее мы создали тестовое изображение, чтобы при его помощи обновить состояние. Чтобы обновить его через containerHost, вызываем функцию из вью-модели. Затем вызываем лямбду expectState, в которой прокидываем excpected result, т. е. что мы ожидаем увидеть после отработки функции.

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

Стоит добавить, что фреймворк основан на библиотеке Turbine, которая используется для тестирования корутин и потоков.

Чем же меня зацепил Orbit MVI

С помощью небольшой демки я подтвердила, что фреймворк нам подходит. Мы с коллегами взяли его в тестовую ветку боевого проекта и убедились, что все работает. В итоге сели переписывать проект и перевели часть feature-модулей на Orbit. С ним стало проще вносить изменения в логику экранов, код стал более читабельным, и в итоге Orbit влился в наш CodeStyle проекта, а техдолг по рефакторингу уменьшился. 

При работе с Orbit MVI я выделила для себя такой список его плюсов:  

  • модульность и чистота кода;

  • удобная обработка сайд-эффектов и работа с асинхронностью;

  • потокобезопасность и однонаправленность, т.к. мы работаем с одной сущностью;

  • достаточно простая библиотека для тестирования + поддержка тестирования Flow

  • гибкость в трансформации данных;

  • интеграция с Kotlin Flow;

  • мультиплатформенная библиотека.

Все это обеспечивает встроенную надежность и помогает в работе с микросервисами. Но также вы можете столкнуться со следующими проблемами:  

  • этот фреймворк сложно интегрировать в уже написанные большие вью-модели с большой вложенностью;

  • относительно маленькое комьюнити. Если у вас сложный кейс, не факт, что будет готовый ответ;

  • дополнительные самописные DSL незначительно увеличивают объем кода.

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

© Habrahabr.ru