Меня зовут Родион, и я уже около 2,5 лет работаю в VK Android-разработчиком в крупном многомодульном проекте с сотнями экранов и довольно большой аудиторией. Когда я попал на проект, стек был классическим и проверенным: XML-вёрстка, навигация через Cicerone, Dagger 2 для DI, Coroutines и Flow для асинхронщины, а в качестве архитектурного паттерна — MVVM. 

Рано или поздно любая растущая кодовая база упирается в потолок своих архитектурных решений. У нас этот момент настал, когда количество экранов выросло до нескольких сотен и команда начала тратить больше времени на борьбу с неконсистентным состоянием UI. Классическая связка XML + ViewBinding + MVVM работала, но с каждым новым экраном мы всё острее чувствовали её ограничения: разрозненные StateFlow, дублирование кода во фрагментах, сложность переиспользования компонентов. 

Нужно было что-то менять — пересмотреть сам подход к построению UI. Так мы начали миграцию на Jetpack Compose (который на момент начала перехода уже был стабильным и самодостаточным). Полтора года спустя, пройдя через рефакторинг базовых классов, переход с MVVM на MVI и постепенную замену содержимого всех фрагментов, мы получили стек, на котором разработка ускорилась, а баги, связанные с состоянием экрана, практически исчезли. 

Полный переход на Jetpack Compose мы разделили на три больших этапа:

  • переписываем содержимое всех фрагментов на ComposeView;

  • переходим с Dagger2 на Koin;

  • меняем навигацию с Cicerone на Compose-навигацию.

О втором и третьем этапах кратко расскажу ниже —  в главе стратегии перехода, а на первом этапе остановлюсь подробнее.

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

Почему выбрали Jetpack Compose и инициировали полный переход

Решение не было спонтанным. Мы следили за трендами в разработке и официальными рекомендациями Google, который уже несколько лет называет Compose основным инструментом для построения UI в Android-разработке. В нашем случае накопились конкретные плюсы, которые и определили цели миграции:

  1. Скорость создания новых экранов и фич. Это, наверное, главный аргумент, который мы почувствовали на себе с первых же недель. Декларативный подход позволяет создавать сложные UI-компоненты значительно быстрее, чем связка XML + ViewBinding + Adapter.

  2. Упрощение адаптации и рефакторинга. Код, который описывает UI, читать и ревьюить гораздо проще, чем разрозненные XML-файлы с вложенными <include>, <merge> и кастомными стилями. Переиспользовать UI-компоненты стало удобнее — теперь это просто Composable-функции, а не фрагменты с обязательной обвязкой.

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

Почему решили перейти с Dagger2 на Koin

Для инъекции зависимостей при работе с Jetpack Compose мы выбрали Koin, так как он в большей мере удовлетворял нашим новым требованиям при работе с технологией:

  • Подход Kotlin-first. Koin — это чистый Kotlin DSL, без аннотаций и кодогенерации. Он гораздо органичнее встраивается в Compose-окружение, особенно с его функциями koinViewModel() и koinInject(), которые можно вызывать прямо внутри Composable-функций.

  • Скорость сборки. Отказ от кодогенерации Dagger пусть и не кардинально, но заметно ускорит время компиляции, особенно в многомодульном проекте.

К минусам Koin относительно Dagger2 можно было бы отнести runtime-проверку зависимостей, но на момент написания статьи вышла новость о том, что Koin переехал на нативный Kotlin Compiler Plugin (K2) и научился проверять всё во время сборки!

Стратегия перехода на Jetpack Compose

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

Этап 1: переписываем содержимое фрагментов на ComposeView

Первый и самый длительный этап, которому посвящена статья. Мы не трогаем навигацию и DI-фреймворк, мы меняем только облик и поведение UI внутри каждого конкретного экрана.

Технически это выглядит так:

  • Каждый экран по-прежнему остаётся Fragment-ом, управляемым через Cicerone.

  • Всю XML-вёрстка, которая раньше лежала в res/layout, удаляем. Вместо неё в методе onCreateView мы создаём один-единственный ComposeView.

  • Вся логика UI (отрисовка списков, кнопок, анимаций) теперь описываем внутри Composable-функций.

  • Fragment превращаем в тонкую оболочку, задача которой — создать ComposeView, получить ViewModel из Dagger 2 и передать её внутрь Compose-дерева.

Почему мы начали именно с этого? Потому что это самый безопасный шаг. Мы можем переписывать экраны по одному, в любом порядке, не боясь сломать переходы между ними. Сегодня переписали экран профиля, завтра — настройки, послезавтра — сделали новый фича-экран сразу на Compose. Старые XML-экраны и новые Compose-экраны отлично уживаются в одном графе навигации Cicerone.

После краткого описания следующих этапов я подробно и с примерами кода расскажу, как мы это делали: настраивали BaseComposeFragment, переезжали с MVVM на MVI внутри этих фрагментов и решали проблемы, возникающие в ходе разработки.

Этап 2: постепенно сменяем Dagger 2 на Koin

К этому этапу мы приступим, когда все фрагменты станут тонкими контейнерами с ComposeView. Мы меняем только получение экранами своих зависимостей.

Технически это выглядит так:

  • Все Dagger-компоненты и модули постепенно переводим в Koin-модули — обычные Kotlin-файлы с DSL-описанием зависимостей.

  • ViewModelFactory, которую раньше генерировал Dagger, заменяем на вызов koinViewModel() прямо внутри Composable-функций или фрагментов-контейнеров.

  • @Inject-аннотации в полях фрагментов и ViewModel исчезают — вместо них используем by inject() или getKoin().inject().

Стратегия снова станет постепенной: новые модули сразу будем подключать через Koin, старые модули на Dagger переводить по мере проведения рефакторинга помодульно. Какое-то время в проекте просуществуют два DI-фреймворка.

Этап 3: меняем навигацию с Cicerone на Compose

Финальный этап, к которому мы приступим, когда все фрагменты станут тонкими контейнерами с ComposeView, а DI полностью переведём на Koin.

  • Навигационный граф теперь описываем декларативно, как Compose-функцию с NavHost.

  • Миграцию фрагмента-контейнера сведём к простому действию: удаляем обёртку Fragment-класса, а вызов ScreenComposable(...), который был внутри setContent, переносим в аргумент composable("route") { ... } нашего нового графа.

  • MainActivity превращаем в единую точку входа с NavHost верхнего уровня, а Cicerone убираем из проекта.

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

  • Как отрефакторили BaseFragment в BaseComposeFragment
    и BaseViewModel в BaseComposeViewModel и переиспользовали их для всех экранов.

  • Почему решили одновременно с переездом на Compose сменить MVVM на MVI, и как это сделали.

  • С какими проблемами столкнулись при смешивании старого View-мира и нового Compose-мира.

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

Настройка Gradle: включаем Compose в масштабах проекта

Самое важное здесь — сделать так, чтобы Compose был доступен во всех модулях, где он потенциально может понадобиться, без дублирования конфигурации в каждом build.gradle. Тем более, что проект у нас многомодульный.

У нас был модуль с базовыми view проекта, от которого зависят другие модули фич, который условно называется uicomponents. В его dependencies мы поместили все необходимые зависимости. Вы же можете завести для этого convention plugin:

plugins {
    // Необходимо подключать во все модули где используется compose
    alias(libs.plugins.kotlin.compose)
}
android {
    // Включаем compose
    buildFeatures {
        compose = true
    }
}

dependencies {
    // Базовый набор, который нужен везде
    api(libs.compose.runtime) // androidx.compose.runtime:runtime
    api(libs.compose.material) // androidx.compose.material:material
    api(libs.compose.ui.android) // androidx.compose.ui:ui-android
    api(libs.compose.tooling) // androidx.compose.ui:ui-tooling
    api(libs.compose.tooling.preview) //androidx.compose.ui:ui-tooling-preview

Если у вас версия Kotlin 2.0+, то описанного уже достаточно для работоспособности Jetpack Compose в проекте, далее только добавление дополнительных зависимостей в соответствующих модулях по мере необходимости. Более подробно о подключении Compose, а также о контроле версий Compose Bill of Materials (BOM) можно прочитать здесь.

Переход с MVVM на MVI: переосмысление архитектуры экранов

Когда мы начали переезжать на Compose, то были готовы к тому, что надо переходить на MVI. Наш старый MVVM, прекрасно работавший с XML и ViewBinding, в мире декларативного UI начал давать сбои. Главная проблема была в том, что состояние экрана было размазано по множеству отдельных MutableStateFlow.

Например: экран должен показать список, индикатор загрузки и, возможно, ошибку. В старой схеме это три разных StateFlow:

  • isLoading;

  • errorMessage;

  • items.

UI, который подписан на каждый из них по отдельности, может на короткий миг оказаться в некорректном состоянии: например, isLoading = false, а items ещё пустой, потому что данные не доехали. Именно в такие моменты появляются неуловимые баги и «моргания» интерфейса.

Как было: базовый класс ViewModel MVVM

Вот как выглядел наш базовый класс ViewModel и State в проекте до перехода.

// === Было: MVVM ===

// Базовый State с общими свойствами для всех экранов
open class BaseViewModelState {
    val keyboardVisibility = MutableStateFlow(false)
    val fullScreenLoader = MutableStateFlow(false)
    // Каждый наследник добавлял сюда свои поля,
    // и каждое из них слушалось отдельно
}

// Состояния конкретного экрана
class PlaceDetailsState : BaseViewModelState() {
    val title = MutableStateFlow("")
    val description = MutableStateFlow("")
    val isLoading = MutableStateFlow(false)
}

// Базовые события (например, показать общий диалог с ошибкой)
// Реализованы через отдельный канал
abstract class BaseViewModel<S : BaseViewModelState>(
    open val state: S
) : ViewModel() {

    private val uiDispatcher = Dispatchers.Main.immediate
    protected val ioDispatcher = Dispatchers.IO

    private var modelJob = SupervisorJob()
    protected var modelScope = CoroutineScope(uiDispatcher + modelJob)

    // Отдельный канал для событий "из VM в UI"
    private val baseEventsChannel = createEventsChannel<BaseViewModelEvent>()
    val baseEventsFlow: Flow<BaseViewModelEvent>
        get() = baseEventsChannel.receiveAsFlow()

    fun sendBaseEventToUi(event: BaseViewModelEvent) {
        modelScope.launch(ioDispatcher) {
            baseEventsChannel.send(event)
        }
    }
}

// ViewModel конкретного экрана
class PlaceDetailsViewModel() : BaseViewModel<PlaceDetailsState>(PlaceDetailsState()) {

    override fun onViewCreated() {
        super.onViewCreated()
        state.title.tryEmit("Заголовок")
        state.description.tryEmit("Заголовок")
        state.isLoading.tryEmit(true)
        sendBaseEventToUi(BaseViewModelEvent.ShowLocationPermission)
    }

    fun onScreenButton1Clicked() {...}
    fun onScreenButton2Clicked() {...}
    fun onScreenButton2Clicked() {...}
    ...
  }

Проблемы такого подхода:

  • Неатомарность состояния: нельзя обновить несколько свойств состояния одним действием так, чтобы UI увидел только конечный результат.

  • Распыление логики: бизнес-логика, реагирующая на действия пользователя, была разбросана по множеству методов ViewModel. Не было единой «точки входа» для всех UI-событий.

Как стало: базовые классы MVI — State, Event, Effect, ViewModel

Для MVI мы спроектировали три ключевые сущности и новый базовый класс ViewModel, который связал их воедино. Подробнее про эту архитектуру можно почитать здесь.

Коротко о каждой сущности:

  1. MviUiState — единый, иммутабельный data class (про иммутабельность рекомендую почитать отдельные статьи об аннотациях @Stable, @Immutable` в Compose. если не знакомы с ними), который описывает всё состояние экрана в любой момент времени. Больше никаких отдельных StateFlow для лоадера, клавиатуры и данных. Теперь это один объект.

  2. MviUiEvent — маркерный интерфейс для событий, которые приходят от пользователя. Клики, ввод текста, скролл до конца списка — всё это описывается sealed-классом, реализующим этот интерфейс.

  3. MviSideEffect — маркерный интерфейс для одноразовых событий, которые идут от ViewModel обратно в UI, но не являются частью состояния. Например: показать снекбар, открыть клавиатуру, выполнить навигацию.

Вот базовый класс MviViewModel, который стал нашим новым фундаментом:

// === Стало: MVI ===

abstract class MviViewModel<STATE : MviUiState, EVENT : MviUiEvent, EFFECT : MviSideEffect>(
    startState: STATE
) : ViewModel(), CoroutineScope {

    protected val uiDispatcher = Dispatchers.Main.immediate
    protected val ioDispatcher = Dispatchers.IO

    // Родительская job для асинхронных вызовов на экране, необходимо отменять при    
    // onClear() viewmodel
    private val parentJob = SupervisorJob()
    override val coroutineContext: CoroutineContext = ioDispatcher + parentJob

    // Единый поток состояния экрана
    private val _mviStateFlow: MutableStateFlow<STATE> = MutableStateFlow(startState)
    val mviStateFlow: StateFlow<STATE> = _mviStateFlow.asStateFlow()

    // Поток сайд-эффектов
    private val _mviEffectFlow: MutableSharedFlow<MviSideEffect> = MutableSharedFlow()
    val mviEffectFlow: SharedFlow<MviSideEffect> = _mviEffectFlow.asSharedFlow()

    // Удобное свойство для чтения текущего состояния изнутри ViewModel
    protected val state: STATE
        get() = _mviStateFlow.value

    /**
     * Единственная публичная точка входа для всех событий от UI.
     * Fragment или Compose-экран вызывают этот метод на любое действие пользователя.
     */
    abstract fun handleUiEvent(event: EVENT)

    /**
     * Обработать событие жизненного цикла Compose экрана.
     * Заменил старые коллбэки onCreateView/onViewCreated.
     */
    open fun onComposeLifecycleChanged(event: Lifecycle.Event) {}

    /**
     * Атомарно обновить состояние экрана.
     * Принимает лямбду с текущим состоянием и возвращает новое.
     */
    protected fun updateUiState(function: (STATE) -> STATE) {
        launch(uiDispatcher) {
            _mviStateFlow.update(function)
        }
    }

    /**
     * Отправить одноразовый сайд-эффект в UI (снекбар, навигация и т. п.).
     */
    protected fun sendSideEffect(effect: MviSideEffect) {
        launch(uiDispatcher) {
            _mviEffectFlow.emit(effect)
        }
    }
}

Ключевые улучшения, которые дала эта архитектура:

  • Единая точка входа для событий: handleUiEvent(event: EVENT) — теперь все действия пользователя (клики, свайпы, ввод текста) попадают в один метод. Это делает логику ViewModel предсказуемой и легко тестируемой.

  • Атомарное обновление UI: метод updateUiState гарантирует, что состояние экрана обновится как единое целое. Никаких «промежуточных» состояний при комбинации «убрать лоадер и показать данные».

  • Явное разделение State и Effect: mviStateFlow отвечает за то, что видит пользователь на экране; mviEffectFlow — за одноразовые команды, которые UI должен выполнить и забыть. Это решает проблему «повторного показа снекбара при пересоздании конфигурации», так как SharedFlow не воспроизводит старые события для новых подписчиков.

Как выглядит конкретный экран: пример State и Event

// Состояние экрана — один data class со всеми полями
data class EventMviUiState(
    val title: String = "",
    val description: String = "",
    val isLoading: Boolean = false,
) : MviUiState

// Все возможные действия пользователя на этом экране
sealed interface EventDetailsEvent : MviUiEvent {
    data class LoadEvent(val eventId: Long) : EventDetailsEvent
    data object OnFavoriteClick : EventDetailsEvent
    data object OnBackClick : EventDetailsEvent
}

// Сайд-эффекты для этого экрана
sealed interface EventDetailsEffect : MviSideEffect {
    data class ShowSnackbar(val message: String) : EventDetailsEffect
    // Навигация через SideEffect будет управляться после ее полного рефакторинга 
    // на Compose, сейчас же она происходит через координатор Cicerone во ViewModel
    data object NavigateBack : EventDetailsEffect 
}

А его ViewModel будет обрабатывать события централизованно:

class EventDetailsViewModel : MviViewModel<EventMviUiState, EventDetailsEvent, EventDetailsEffect>(
    startState = EventMviUiState()
) {
    
    override fun handleUiEvent(event: EventDetailsEvent) {
        when (event) {
            is EventDetailsEvent.LoadEvent -> loadEvent(event.eventId)
            is EventDetailsEvent.OnFavoriteClick -> toggleFavorite()
            is EventDetailsEvent.OnBackClick -> {
            // до рефакторинга навигации
              coordinator.moveBack
            // после рефакторинга навигации на Compose
              sendSideEffect(EventDetailsEffect.NavigateBack)
            }
        }
    }

    private fun loadEvent(id: Long) {
        updateUiState { it.copy(isLoading = true) }
        launch {
            // запрос в сеть...
            val result = repository.getEvent(id)
            updateUiState { state ->
                state.copy(
                    title = result.title,
                    description = result.description,
                    isLoading = false
                )
            }
        }
    }
   // ...

updateUiState получает текущий state и возвращает его изменённую копию. Compose, наблюдая за mviStateFlow, получит новый объект EventMviUiState, сравнит с предыдущим и перерисует только те элементы UI, которые реально изменились.

Новый базовый класс для фрагментов: от ViewBinding к ComposeView

После того как мы разобрались с архитектурой ViewModel и перешли на MVI, следующим логичным шагом стал рефакторинг самих фрагментов. Старый BaseFragment был прочно завязан на ViewBinding и XML, и нам нужен был новый фундамент, который бы органично работал с Compose.

Главная задача была такой: превратить фрагмент из умного контроллера, управляющего десятками View, в тонкий контейнер, который просто создаёт ComposeView и связывает его с ViewModel.

Как было: старый BaseFragment с ViewBinding

До рефакторинга каждый наш фрагмент был типизирован двумя параметрами: VM (ViewModel) и B (ViewBinding). Фрагмент сам создавал биндинг, сам находил View, сам вешал адаптеры для списков и обработчики кликов.

Вот базовый класс, который управлял этой логикой:

// === Было: BaseFragment на ViewBinding ===

abstract class BaseFragment<VM : BaseViewModel<*>, B : ViewBinding> : Fragment() {

    //Общий метод получения viewModel
    val viewModel: VM by lazy {
        getViewModelInstance()
    }

    private var realBinding: B? = null
    val binding: B
        get() = realBinding
            ?: throw IllegalStateException(
                "Trying to access the binding outside of the view lifecycle."
            )

    //Инициализация viewBinding
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        realBinding = getViewBinding()
        viewModel.onCreateView()
        return binding.root
    }

    //Подключение слушателей базовых состояний и событий для всех фрагментов
    protected open fun observeViewModel() {
        viewLifecycleOwner
            .lifecycleScope.apply {
                launch {
                    viewModel.state.fullScreenLoader.stateFlow.collect {
                        requireActivity().setFullScreenLoader(it)
                    }
                }
                launch {
                    viewModel.baseEventsFlow.collect { event ->
                        when (event) {
                            is BaseViewModelEvent.ShowSnackBarEvent -> {
                                (requireActivity() as? SnackBarManagerOwner)
                                    ?.showSnackBar(event.config)
                            }
                            is BaseViewModelEvent.RequestPermissionEvent -> 
                                processPermission(event.request)
                           ...
                        }
                    }
                }
            }
    }
}

Фрагмент конкретного экрана, наследующего BaseFragment:

// Фрагмент XML
class PlaceDetailsFragment : 
    BaseFragment<PlaceDetailsViewModel, FragmentPlaceDetailsBinding>() {

    override fun getViewModelInstance(): PlaceDetailsViewModel {
        // Получение через Dagger...
    }

    override fun getViewBinding() = 
        FragmentPlaceDetailsBinding.inflate(layoutInflater)

    override fun observeViewModel() {
        super.observeViewModel()
        // Дополнительные подписки на свойства state ViewModel...
        viewLifecycleOwner.lifecycleScope.launch {
            viewModel.state.title.collect { title ->
                binding.toolbar.title = title
            }
        }
        // ...
    }
}

И его примерный XML:

<!-- res/layout/fragment_place_details.xml (ДО) -->
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.appbar.MaterialToolbar
        android:id="@+id/toolbar"
        android:layout_width="match_parent"
        android:layout_height="?attr/actionBarSize"
        app:layout_constraintTop_toTopOf="parent" />

    <ProgressBar
        android:id="@+id/progressBar"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toBottomOf="@id/toolbar"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/detailsRecyclerView"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        app:layout_constraintTop_toBottomOf="@id/progressBar"
        app:layout_constraintBottom_toBottomOf="parent" />

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/favoriteFab"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintBottom_toBottomOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

Так код выглядел до рефакторинга на Compose.

Как стало: новый MviFragment — тонкий Compose-контейнер

В текущем подходе фрагмент радикально упростили. Он больше не знает ничего о ViewBinding, XML-разметке и конкретных UI-элементах. Его единственная задача — создать ComposeView и передать управление Composable-функциям.

Вот наш новый базовый класс:

// === Стало: MviFragment под Compose ===

abstract class MviFragment<VM : MviViewModel<*, *, *>> : Fragment() {

    @Inject
    lateinit var viewModelFactory: ViewModelFactory<VM>
  
    protected abstract val viewModelType: Class<VM>
  
    val viewModel: VM by lazy {
        ViewModelProvider(this, viewModelFactory)[viewModelType]
    }


    /**
      * Инициализируем ComposeView который будет содержать в себе весь контент экрана
    */
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        return ComposeView(requireContext()).apply {
            initViewCompositionStrategy()
            setContent {
                // Рисует контент экрана
                getScreenContent()
                // Обрабатывает базовые сайд эффекты
                viewModel.handleSideEffects()
                // Добавляет слушатель жизненного цикла compose экрана
                ComposableLifecycleObserver(
                    onEventChanged = { viewModel.onComposeLifecycleChanged(it) }
                )
            }
        }
    }

    /**
     * Инициализация стратегии восстановления контента.
     * Можно переопределить в наследниках для кастомного поведения.
     */
    open fun ComposeView.initViewCompositionStrategy() {}

    /**
     * Абстрактный метод, который каждый фрагмент реализует,
     * возвращая своё Composable-дерево.
     */
    @Composable
    abstract fun getScreenContent()

    /**
     * Сбор сайд-эффектов от ViewModel и их обработка.
     * Теперь это происходит декларативно внутри Compose-дерева.
     */
    @Composable
    private fun MviViewModel<*, *, *>.handleSideEffects() {
        mviEffectFlow.collectInLaunchedEffect { event ->
            when (event) {
                is BaseMviSideEffect.ShowSnackBar -> {
                    (requireActivity() as? SnackBarManagerOwner)
                        ?.showSnackBar(event.config)
                }
                is BaseMviSideEffect.RequestPermission -> 
                    processPermission(event.request)
            }
        }
    }
}

Раньше мы вешали LifecycleObserver на фрагмент напрямую. Теперь это делает специальный Composable-компонент, который живёт внутри дерева и автоматически отписывается при dispose:

@Composable
fun ComposableLifecycleObserver(
    onEventChanged: (Lifecycle.Event) -> Unit
) {
    val lifecycleOwner = LocalLifecycleOwner.current
    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            when (event) {
                Lifecycle.Event.ON_DESTROY -> {}
                Lifecycle.Event.ON_ANY -> {}
                else -> {
                    onEventChanged(event)
                }
            }
        }
        lifecycleOwner.lifecycle.addObserver(observer)
        onDispose {
            //Отправляем ON_DESTROY когда элемент покидает композицию
            onEventChanged(Lifecycle.Event.ON_DESTROY)
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }
}

Как теперь выглядит конкретный экран

Фрагмент места после рефакторинга — это всего лишь DI-обвязка и одна Composable-функция:

class PlaceDetailsFragment : MviFragment<PlaceDetailsViewModel>() {

    override val viewModelType: Class<PlaceDetailsViewModel>
        get() = PlaceDetailsViewModel::class.java

    override fun inject() {
        PlacesComponent.get(requireContext()).inject(this)
    }

    @Composable
    override fun getScreenContent() {
        ApplicationTheme {
            ComposablePlaceDetailFragment(viewModel)
        }
    }
}
// Composable-экран — теперь это чистая функция
@Composable
fun ComposablePlaceDetailFragment(viewModel: PlaceDetailsViewModel) {
    //Состояние экрана
    val state by viewModel.mviStateFlow.collectAsStateWithLifecycle()
    val scrollState = rememberScrollState()
    //Обработка сайд эффектов конкретного экрана если они есть
    mviEffectFlow.collectInLaunchedEffect {
        when (it) {
            PlaceDetailSideEffect.ScrollToFirstItem -> scrollState.scrollTo(0)
        }
    }
    PlaceDetailsScreen(
        title = state.title,
        description = state.description,
        isLoading = state.isLoading,
        scrollState = scrollState,
        onFavoriteClick = { 
            viewModel.handleUiEvent(PlaceDetailsEvent.OnFavoriteClick) 
        }
    )
}

Таким образом мы провели полный рефакторинг одного экрана с XML на Jetpack Compose. В результате во фрагменте стала храниться всего одна логика вызова getScreenContent(). Мы забыли про binding.toolbar.title = …, binding.recyclerView.adapter = ... и прочие императивные манипуляции.  Теперь каждый экран — это просто Composable-функция, а фрагмент — лишь стандартная обёртка для неё.

Несколько полезных наработок, которые помогли нам справиться с некоторыми сложностями

Вынесение событий экрана в обратные вызовы

Когда мы только начинали писать Composable-экраны, мы действовали по наитию и передавали в каждую Composable-функцию ViewModel целиком. Выглядело это примерно так:

@Composable
fun PlaceDetailsScreen(viewModel: PlaceDetailsViewModel) {
    val state by viewModel.mviStateFlow.collectAsStateWithLifecycle()
    
    PlaceDetailsContent(
        title = state.title,
        onFavoriteClick = { viewModel.handleUiEvent(PlaceDetailsMviUiEvent.OnFavoritePlaceClicked) },
        onBackClick = { viewModel.handleUiEvent(PlaceDetailsMviUiEvent.OnBackPressed) },
        onShareClick = { viewModel.handleUiEvent(PlaceDetailsMviUiEvent.OnShareClicked) },
        // ... и так 20+ коллбеков
    )
}

Проблема здесь в том, что создаётся необходимость передавать внутрь контента Composable целую viewModel, что излишне, так как внутри экрана от неё нам нужен только доступ к единственному публичному методу handleUiEvent, чтобы передавать в него события из PlaceDetailsMviUiEvent. Соответственно такой вариант нам не подходит.

Второй вариант — не передавать viewModel целиком, а отдельно коллбэки — тоже нам не подходит, так как функция контента экрана будет выглядеть сильно загромождённой параметрами (как видно из примера выше). 

Решение пришло само собой, когда мы посмотрели на наш sealed-класс PlaceDetailsMviUiEvent. По сути, он уже описывал все возможные действия пользователя на экране. Оставалось только «переупаковать» их в удобную для UI структуру — единый data class Callbacks.

Мы создали отдельный класс ScreenCallbacks, который аккумулирует в себе все возможные действия пользователя на экране:

data class PlaceDetailsCallbacks(
    val onFavoritePlaceClicked: () -> Unit = {},
    val onBackButtonClicked: () -> Unit = {},
    val onGalleryImageClicked: (String) -> Unit = {},
    val onShareClickAction: () -> Unit = {},
) : ProviderEventCallbacks {

    constructor(viewModel: MviViewModel<*, PlaceDetailsMviUiEvent, *>) : this(
        onFavoritePlaceClicked = { viewModel.handleUiEvent(PlaceDetailsMviUiEvent.OnFavoritePlaceClicked) },
        onBackButtonClicked = { viewModel.handleUiEvent(PlaceDetailsMviUiEvent.OnBackPressed) },
        onGalleryImageClicked = { viewModel.handleUiEvent(PlaceDetailsMviUiEvent.OnGalleryImageClicked(it)) },
        onShareClickAction = { viewModel.handleUiEvent(PlaceDetailsMviUiEvent.OnShareClicked) },
    )
}
/**
 * На верхнем уровне Composable-экрана мы создаём объект callbacks через remember, 
 * чтобы он не пересоздавался при каждой рекомпозиции:
 */
@Composable
fun ComposablePlaceDetailFragment(viewModel: PlaceDetailsViewModel) {
    val callbacks = rememberEventCallbacks(viewModel) { 
        PlaceDetailsCallbacks(viewModel) 
    }
    val state = viewModel.mviStateFlow.collectAsStateWithLifecycle()    
    PlaceDetailsContent(
        state = state,
        callbacks = callbacks
    )
}

Теперь внутрь renderDataState и renderErrorState передаём не ViewModel целиком, а лёгкий объект callbacks. Дочерние Composable-функции получают только то, что им реально нужно для работы.

Чтобы сделать Preview для экрана, больше не нужно поднимать ViewModel или писать сложные моки. Достаточно создать PlaceDetailsMviUiState со значениями по умолчанию и объект PlaceDetailsCallbacks() — все лямбды уже имеют пустые реализации:

@Preview
@Composable
fun PlaceDetailsScreenPreview() {
    ApplicationTheme {
        PlaceDetailsContent(
            state = PlaceDetailsMviUiState(
                title = "Тестовый заголовок",
                description = "Тестовое описание"
            ),
            callbacks = PlaceDetailsCallbacks() // Все лямбды уже {} по умолчанию
        )
    }
}

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

Сложные списки

В нашем проекте большое количество экранов-списков, которые содержат много элементов разных типов. Когда мы работали на XML, для каждого такого экрана мы писали свой RecyclerView.Adapter с набором ViewHolder-ов, переопределяли getItemViewType(), вручную управляли переиспользованием View — в общем, занимались привычной, но рутинной работой. С переходом на Compose встал вопрос: как теперь делать сложные списки?

В Compose за ленивые списки отвечает LazyColumn (и его горизонтальный аналог LazyRow). Он работает иначе, чем RecyclerView: не переиспользует элементы через пул View, а вызывает Composable-функции только для видимых на экране индексов и пропускает то, что ушло за границы. Это эффективно, но требует от нас двух важных вещей для оптимизации рекомпозиций:

  • key — уникальный ключ для каждого элемента. Без него Compose ориентируется только на позицию в списке, и при любом переупорядочивании или вставке элементов перерисовывает всё, что сдвинулось. С ключом фреймворк понимает, что элемент с тем же ключом просто переместился, и пропускает его перерисовку.

  • contentType — тип содержимого элемента. Если несколько элементов имеют одинаковый тип, то Compose понимает, что их структура одинакова, и можно повторно использовать композицию между ними. Это аналог getItemViewType() из мира RecyclerView, но работающий на уровне Composable-дерева.

Ранее при работе с RecyclerView у нас была похожая архитектура, где мы описывали все элементы списка, но мы её немного модернизировали. Прежде чем создать список, необходимо описать структуру всех его элементов, как и раньше, но также теперь у каждого элемента обязательно появляются два свойства: key и contentType, которые необходимо передавать в items у LazyColumn для оптимизации рекомпозиций. Их мы вынесли в общий интерфейс.

// На базовом уровне будем использовать в качестве ключа
// и типа контента наименование класса
interface ComposeLazyItem {

    // Для однотипных элементов нужно обязательно переопределить и сделать ключ 
    // уникальным, иначе LazyColumn выбросит исключение дубликатов ключей
    val lazyItemKey: String
        get() = this::class.java.name

    // В большинстве случаев тип контента совпадает с названием класса, поэтому 
    // переопределять это свойство практически не придется
    val lazyItemContentType: String
        get() = this::class.java.name
}

//Далее создаем sealed class со всеми нашими элементами списка

/**
 * Элементы списка
 */
sealed class PlaceDetailsItem : ComposeLazyItem {

    /**
     * Карточка деталей места
     */
    sealed class PlaceDetails : PlaceDetailsItem() {

        /**
         * Шиммер (карточка в зугрузке)
         */
        data class Shimmer(
          val id: Int
        ) : PlaceDetails(){
          
            // Переопределяем значение ключа
          // Добавляем ид шиммера, так как их на экране запланировано несколько
          override val lazyItemKey: String = super.lazyItemKey + id
        }

        /**
         * Место (карточка загружена)
         *
         * @property info Данные места
         */
        data class Place(
            val info: PlaceInfo,
        ) : PlaceDetails(){

          // Переопределяем значение ключа
          // Добавляем ид места для уникальности ключа повторяемых элементов
          override val lazyItemKey: String = super.lazyItemKey + info.placeId
        }
    }

    /**
     * Итем фильтров
     * Так как шиммер и фильтры находятся в одном экземпляре item'а списка, им можно
     * не переопределять lazyItemKey, он по умолчанию берет название класса
     */
    sealed class Filters : PlaceDetailsItem() {

        /**
         * Шиммер (фильтры в загрузке)
         */
        object Shimmer : EventsCategoryAndFilter()

        /**
         * Список загруженных фильтров
         */
        data class Filters(
            val filters: FiltersInfo
        ) : EventsCategoryAndFilter()
    }

         /**
         * Состояния пустого списка, либо ошибки
         */
        sealed class EmptyOrError : PlaceDetailsItem() {

            /**
             * Пустой список
             */
            object Empty : EmptyOrError() 

            /**
             * Пустой список
             */
            object NetworkError : EmptyOrError() 

            /**
             * Пустой список
             */
            object LoadError : EmptyOrError()
        }
   // ... И еще много разных элементов + горизонтальные списки
   // как отдельные элементы и тд
}

После того как мы определили все виды элементов списка, их можно отправлять в state в нужном нам порядке:

@Stable
data class PlaceDetailsMviState(
    val items: List<PlaceDetailsItem> = emptyList(),
    val fullscreenErrorState: CommonFullScreenError = CommonFullScreenError.None,
    val title: String = ""
) : MviUiState

Внутри Compose-экрана мы добавили обёртки для правильного добавления элементов списка:

//Здесь добавляются наши List<PlaceDetailsItem> и применяются ключи и типы контента
inline fun <T : ComposeLazyItem> LazyListScope.addItems(
    items: List<T>,
    crossinline itemContent: @Composable LazyItemScope.(index: Int, item: T) -> Unit
) {
  // Необходим валидатор ключей на дубли, иначе будет крэш
    val result = LazyKeyValidator.validate(items)
    itemsIndexed(
        items = result.uniqueItems,
        key = { _, item -> item.lazyItemKey },
        contentType = { _, item -> item.lazyItemContentType },
        itemContent = itemContent
    )
}

Ключи у одинаковых элементов мы заполняем по id тех элементов, что приходят с бэка. Согласно нашим контрактам, у полученного списка не могут быть одинаковые id, но на всякий случай делаем валидацию ключей у списка на уникальность, иначе возможен крэш. Валидатор мы реализовали таким образом, что на dev-сборках произойдёт крэш, а на проде дубликат удалится и отправится событие в Crashlytics. Таким образом мы точно уверены, что все наши элементы имеют уникальные ключи.

Далее просто добавляем наш список в LazyColumn:

LazyColumn(
    modifier = Modifier
        .fillMaxSize()
        .navigationBarsPadding(),
    state = scrollState
) {
    with(state.value.items) {
        addItems(
            items = this,
            itemContent = provide(callbacks)
        )
    }
}

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

/**
 * Базовый элемент для провайдера элементов lazy списка без параметров
 */
abstract class ComposeLazyItemProvider<T : ComposeLazyItem, C : ProviderEventCallbacks> : BaseProviderCallbacks<T>() {

    /**
     * Предоставить отображение элемента
     *
     * @param callbacks Коллбэки вьюмодели
     */
    abstract fun List<T>.provide(callbacks: C): @Composable LazyItemScope.(index: Int, item: T) -> Unit
}

object ComposePlaceDetailsLazyItemProvider : ComposeLazyItemProvider<PlaceDetailsItem, PlaceDetailsCallbacks>() {
    
    /**
     * Обработчик элементов списка [PlaceDetailsItem]
     */
    override fun List<PlaceDetailsItem>.provide(
        callbacks: PlaceDetailsCallbacks,
    ): @Composable LazyItemScope.(index: Int, item: PlaceDetailsItem) -> Unit {
        return { _, item ->
            with(item) {
                when (this) {
                    is PlaceDetailsItem.Filters -> insert(callbacks)
                    is PlaceDetailsItem.PlaceDetails.Place -> insert(callbacks)
                    is PlaceDetailsItem.PlaceDetails.Shimmer -> insert()
                    is PlaceDetailsItem.PlaceDetails.EmptyOrError -> insert(callbacks)
                    is PlaceDetailsItem.PlaceDetails.ErrorLoadingMore -> insert(callbacks)
                    is PlaceDetailsItem.PlaceDetails.Loader -> insertCommonLoader()
                }
            }
        }
    }
    @Composable
    private fun PlaceDetailsItem.EventsCategoryAndFilter.insert(callbacks: PlaceDetailsCallbacks) {
        ComposablePlaceDetailsFilters(
            modifier = Modifier.padding(horizontal = MaterialTheme.customSpacing.padding8),
            onFiltersButtonClickedAction = callbacks.onFiltersClickedAction
        )
    }
  //...
}

Итог

Мы прошли путь от классической связки XML + ViewBinding до декларативного Compose с архитектурой MVI, не остановив разработку фич и не потеряв в качестве, сохраняя crash-free на уровне 99,95%+.

Мы не пытались переписать всё сразу. Сначала — один экран как песочница, потом — тонкие фрагменты-контейнеры с ComposeView. Такой подход позволял откатывать отдельные экраны, не затрагивая остальное приложение, и сохранить спокойный темп рефакторинга.

Создание правильных базовых классов ViewModel привело к понятным дальнейшим действиям. Когда у каждого экрана одинаковый контракт (MviUiState, MviUiEvent, MviSideEffects, Callbacks), рефакторинг превращается в конвейер. Мы смогли относительно быстро перевести десятки экранов, не изобретая каждый раз архитектуру с нуля.

Отдельно хочется отметить очень приятный эффект от перехода на Compose: в процессе рефакторинга мы смогли отказаться от множества сторонних зависимостей, связанных с UI-компонентами. Раньше мы тащили библиотеки для кастомных ProgressBar, Shimmer-эффектов, анимированных списков, а теперь всё это пишем своими силами, что позволяет гибко управлять всеми элементами и исправлять баги, от которых мы зависели из-за использования сторонних библиотек. Команда стала более увлечённой в построении Composable-элементов, так как появляются решения, которые раньше казались слишком затратными по времени.

Как я упомянул в описании стратегии, впереди — миграция Dagger 2 на Koin и замена Cicerone на навигацию Compose. После этого весь стек станет Kotlin-first.

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

Комментарии (0)