Миграция приложения на Jetpack Compose

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Привет! Меня зовут Андрей Берюхов, я Android-инженер в Авито. А ещё я уже третий сезон участвую в качестве спикера и ментора в Android Academy. 

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

Эта статья — выжимка из моей ​​видеолекции Jetpack Compose: Migration of existing app.

Зачем мигрировать на Compose

Jetpack Compose — это современный набор инструментов для создания пользовательского интерфейса на Android. Вот почему стоит на него переходить: 

  • позволяет писать меньше кода;

  • уменьшает время сборки (после полной миграции на Compose);

  • может улучшить производительность при запуске приложения;

  • уменьшает размер APK  (после полной миграции на Compose). 

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

Подробнее о том, зачем мигрировать на Jetpack Compose →

Рекомендации до миграции

В этом разделе поговорим про подводные камни, о которых стоит узнать до миграции на Compose. А также разберём рекомендации Google относительно того, с какой архитектурой будет проще переносить UI.

Убедитесь, что версия компилятора соответствует версии Kotlin. Когда мы добавляем Compose в существующее приложение, нужно установить в buildFeatures флаг compose=true и указать kotlinCompilerExtensionVersion в composeOptions — версию компилятора.

android {
    buildFeatures {
        compose = true
    }

    composeOptions {
        kotlinCompilerExtensionVersion = “1.4.3”
    }
}

Так как Compose основан на плагине для компилятора Kotlin (Compose Compiler), важно, чтобы его версия соответствовала версии Kotlin. Полную таблицу совместимости можно посмотреть в документации →

Совместимость некоторых версий Compose Compiler и Kotlin
Совместимость некоторых версий Compose Compiler и Kotlin

Если у вас не самый свежий Kotlin, но использовать новый Compose хочется, есть специальные сборки Compose Compiler для совместимости версий.

Полную таблицу совместимости смотрите в документации →

Подумайте о переходе на корутины. Это особенно актуально для старых проектов, в которых используются AsyncTask, RxJava, LiveData. 

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

Более того, переход на корутины до миграции поможет вам заранее ознакомиться с концепциями Compose. Это важно, потому что корутины и Jetpack Compose тесно связаны.

Другое решение — использовать адаптеры из пакета androidx.compose.runtime:runtime. Они позволяют оставить логику на RxJava и LiveData. 

Перенесите приложение на архитектуру, основанную на Unidirectional Data Flow (UDF). Идея такой архитектуры в том, что данные в приложении передаются только в одном направлении: от модели приложения к UI.

Так передаются данные на Unidirectional Data Flow
Так передаются данные на Unidirectional Data Flow

Несмотря на множество существующих реализаций UDF: например, MVICore, MVIKotlin, достаточно легко написать свою поверх ViewModel. 

Это возможно, потому что в Compose много extension-функций для работы с жизненным циклом через ViewModel и доступа к ViewModel.

Частичная миграция UI: интеграция Compose во View

Разработчики Jetpack Compose предусмотрели, что рано или поздно всем придётся мигрировать на этот набор инструментов с XML. Поэтому они изначально задизайнили Compose так, чтобы его было легко интегрировать в существующие решения с View. 

Постепенная миграция на Jetpack Compose через интеграцию во View
Постепенная миграция на Jetpack Compose через интеграцию во View

Давайте разберёмся, куда можно вставить Compose и как это сделать.

1. В Activity. Используем extension функцию setContent(), в которую передаем @Composable. Для этого понадобится артефакт activity-compose

ComponentActivity.setContent(@Composable)

Пример:

class ComposeActivity : ComponentActivity() {
	override fun onCreate(savedInstanceState: Bundle?) {
		super.onCreate()
		setContent {
			PagesContent()
		}
	}
}

Тут лучше сразу заменять AppCompatActivity (если она использовалась) на ComponentActivity, чтобы после полной миграции - можно было легко убрать зависимость на AppCompat библиотеку.

2. Во Fragment. Понадобится функция onCreateView(). В неё нужно передать предварительно созданный ComposeView и стратегию для ViewComposition.

onCreateView() { ViewCompositionStrategy + ComposeView()}

Пример:

class SomeFragment : Fragment() {
    override fun onCreateView(...): View {
        setViewCompositionStrategy(
            ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
        )
        return ComposeView(requireContext())
    }
}

Для фрагмента чаще всего будет использоваться стратегия DisposeOnViewTreeLifecycleDestroyed. Однако если вы постепенно добавляете Compose в кодовую базу, такое поведение может привести к потере состояния в некоторых сценариях.

Подробнее про стратегии можно узнать в статье на Medium →

3. В XML. Для этого можно заменить существующий View на тег <ComposeView/>

Пример:

<androidx.compose.ui.platform.ComposeView
	android:id=”@+id/compose_view”
	android:layout_width=”wrap_content”
	android:layout_height=”wrap_content”!/>

Дальше нужно в коде найти ComposeView по ID. Для этого можно использовать функцию findViewById(), а затем вызвать setContent() и передать в неё Composable-функцию с UI.

findViewById<ComposeView>(R.id.composeView).setContent { PagesContent() }

Вместо findBiewById() можно использовать ViewBinding. Для него Google сделала interop API

Частичная миграция UI: интеграция View в Compose

При миграции может возникнуть и обратная ситуация, когда View нужно использовать в Compose. Вот когда это может быть полезно:

  1. Если есть большая и сложная View, которую еще не перевели на Compose. Некоторые компоненты, такие как MapView или AdView, до недавнего времени полноценно не поддерживались в Compose. Такие View можно включить в Compose без потери функциональности.

  2. Если есть сложный пользовательский интерфейс с использованием View, и переписывать его на Compose долго и трудно. Вместо этого можно обернуть существующую View в Compose, и использовать её в контексте Compose.

В обоих случаях View можно интегрировать в Compose-архитектуру, сохранить функциональность, а потом постепенно переходить к полноценному использованию Compose.

Для интеграции View в Compose можно использовать composable-функцию AndroidView. У неё три основных параметра: modifier, factory и update.

@Composable
fun CustomView() {
    var selectedItem by remember { mutableStateOf(0) }
    AndroidView(

        modifier = Modifier.fillMaxSize(), // наибольший размер в дереве Compose UI

        factory = { context ->
            // Создаем вью
            MyView(context).apply {
                // Настраиваем слушателей на взаимодействие View -> Compose
	    setOnClickListener {
                    selectedItem = 1
                 }
	}
        },

        update = { view ->
            // К view был применен механизм надувания, 
            // или обновилось состояние чтения в этом блоке

            // Поскольку selectedItem читается здесь, 
            // AndroidView рекомпозируется
            // вне зависимости от изменений состояния            
            view.selectedItem = selectedItem
        }
    )
}

Здесь тоже есть специальный API — AndroidViewBinding interop API. Он позволяет обрабатывать фрагменто-специфичную логику: например, ситуации, когда Composable покидает композицию.

Для добавления шрифтов и настроек цветов можно пользоваться функциями: 

stringResource()

pluralStringResource()

colorResourse

painterReseource()

animatedVectorResource()

Для добавления Locals используется свойство current. Из него можно получить, например, локальный контекст и работать с ним в Compose.

Внутри свойства current — концепция CompositionLocal. Это аналог DI в Compose. Он позволяет передавать значения переменных по дереву Compose сверху вниз. При этом не нужно пробрасывать переменную в качестве параметра из одного Compose в другой.

Подробнее про CompositionLocal на официальном сайте → 

Для пробрасывания темы — аналогично. В теме есть три основных переменных: цвета, типографика и шейпы. Они также доступны через свойство current: LocalColors.current, LocalTypography.current, LocalShapes.current.

Чем хороша полная миграция на Compose

Выше мы обсудили, что миграция на Compose увеличивает скорость разработки, улучшает производительность, APK становится меньше. Но это работает только при полной миграции на Compose. 

Если мигрировать не полностью и оставить куски на View, пользы от Compose будет меньше. Метрики могут даже ухудшиться.

Рассмотрим размер APK и время сборки на примере двух приложений: Tivi и Sunflower. Tivi полностью перенесли на Compose, а в Sunflower оставили куски View. Вот как изменились метрики:

(оригиналы графиков тут: https://developer.android.com/jetpack/compose/ergonomics

При одновременном использовании View и Compose время сборки и размер APK увеличиваются. А при полном переходе на Compose эти показатели меньше, чем при использовании только View. 

Также нужно смотреть на зависимости, от которых позволяет избавиться Compose для улучшения этих показателей. Так скорость сборки удалось улучшить в основном за счёт избавления от библиотек DataBinding & Epoxy, использующих KAPT; уменьшить размер сборки - удалением AppCompat, а для улучшения времени старта - добавить Baseline Profile. 

Поэтому миграция на Jetpack Compose — это игра вдолгую. 

Подробнее про преимущества Compose-only подхода можно почитать на официальном сайте →

Полная миграция UI

В этом разделе обсудим общий подход к миграции UI и конкретные шаги, которые помогут перенести приложение на Jetpack Compose.

Google предлагает такой алгоритм:

  1. Выделить переиспользуемые элементы.  

  2. Создать библиотеку с общими UI-компонентами — дизайн-систему.

  3. По одной заменить существующие фичи с помощью UI-компонентов из дизайн-системы. 

Новые фичи при этом лучше делать сразу с помощью Compose. 

Подход bottom-up — это тоже рекомендация Google. Его суть заключается в том, чтобы мигрировать элементы снизу вверх:

  1. Добавить ComposeView в иерархию UI. Лучше делать это постепенно: сверху вниз, поэкранно — особенно если он сложный. При этом оставляя текущие контейнеры экранов - Fragment или даже Activity - и текущую навигацию между ними.

  2. Заменить все Fragments, Activity и навигацию между ними на новую навигацию, основанную на Compose.

Схема миграции по подходу bottom-up
Схема миграции по подходу bottom-up

Миграция архитектуры: MVVM → MVI

Помимо UI, нужно перенести и архитектуру. Лучше всего Jetpack Compose сочетается с MVI.

MVI (Model-View-Intent) — это архитектура с тремя важными составляющими: View, Feature и State.

Пример MVI-архитектуры, к которой нужно прийти
Пример MVI-архитектуры, к которой нужно прийти

 Для примера возьмём экран создания тестового пользователя. В нём есть поле ввода email и две кнопки: «Создать пользователя» и «Очистить список». 

Так выглядит экран создания пользователя
Так выглядит экран создания пользователя

Алгоритм миграции:

1. Превратить публичные ViewModel-методы в Action. Для этого переписываем их, заменяя функции на data-класс или object с наследованием. 

До миграции → После миграции
До миграции → После миграции

2. Перенести роутерные методы в OneTimeEvent. Это могут быть и просто свойства — как в нашем примере. 

До миграции → после миграции

3. Создать неизменяемый класс — заготовку под State. Для этого удобно использовать data-класс с неизменяемыми свойствами. 

Хорошая идея — сразу сделать Parcelable и навесить @Parcelize. Так класс можно будет сохранять как rememberSaveable — это позволит запоминать состояния при поворотах экрана, смене конфигурации и так далее.

@Parcelize
data class TestUserState(
    val …
) : Parcelable

4. Организовать изменение State-полей через метод copy(). Важно получать новый State после Action копированием из предыдущего State.

class TestUserReducer... {

    override fun reduce 
        internalAction: TestUserInternalAction,
        previousState: TestUserState
    ): TestUserState = when (internalAction) {
        ...
      someAction -> previousState.copy(...)
    }
}
class TestUserReducer...{

    override fun reduce(
        internalAction: TestUserInternalAction,
        previousState: TestUserState
    ): TestUserState = when (internalAction) {
        ...
        someAction -> previousState.copy (...)
    }
}

5. Убедиться, что UI напрямую не дёргает ViewModel, а только получает из него новый State. 

В этом примере у TestUserScreen два параметра: 

  • stateFlow, которым мы передаём State целиком.

  • onAction, который ViewModel принимает в виде лямбды, — это позволяет передавать UI-экшены во ViewModel.

6. Создать единый State. Переносим лишние методы, которые не ушли на прошлых этапах миграции, в виде свойств в data-класс. 

До миграции → после миграции
До миграции → после миграции

Миграция архитектуры: MVP → MVI

Ситуация гораздо сложнее, когда нужно переходить с MVP. Многое во флоу будет дублировать схему MVVM → MVI, поэтому я приведу только общий план миграции: 

  1. Публичные Presenter-методы превратить в Action.

  2. Роутерные методы конвертировать в подкласс OneTimeEvent.

  3. Методы интерфейса View превратить в Action.

  4. Создать неизменяемый класс — State, который полностью описывает UI. 

  5. Методы интерфейса View, которые затрагивают UI, перенести в State. 

  6. State-поля должны изменяться только через метод copy().

  7. Убрать синхронизацию UI с жизненным циклом Presenter. Например, если отправляли в Presenter onAttach. 

Миграция дизайн-системы

Дизайн-система помогает разработчикам и дизайнерам разговаривать на одном языке и проще договариваться. И её тоже нужно мигрировать на Compose. Вот как это сделать:

1. Перенести базовые компоненты из дизайн-системы на View. Допустим, вы работаете в части приложения, которая связана с авторизацией. Там очень часто экран состоит из двух инпутов и кнопки. Значит, лучше перенести в новую дизайн-систему и инпут, и кнопку.

2. Добавить недостающие элементы. Например, на экран авторизации нужно добавить карусель с соцсетями. Этот экран мы уже сделали на Compose, но новые компоненты в нём потенциально полезны в других экранах, поэтому их мы тоже переносим в дизайн-систему.

3. Перенести оставшиеся экраны, используя Compose дизайн-систему . 

Теперь вы знаете, почему стоит мигрировать классический UI на новый фреймворк Compose. Инструменты и шаги, которые я описал в статье, помогут разобраться со сложностями. 

В Авито мы тоже начали этот путь. Сейчас постепенно мигрируем на Coroutines наш собственный MVI-Flow. А ещё готовим дизайн-систему и экспериментируем с первыми экранами, замеряем перфоманс.

Полезные материалы

  • Полная лекция Jetpack Compose: Migration of existing app (1 час 12 минут)

  • Quick Start по миграции на Jetpack Compose

  • Кодлаба Compose migration live code-along. Android dev summit 2021 (51 минута)

  • Таблица совместимости компилятора Compose и Kotlin

  • Альтернативные сборки для старых версий Kotlin

  • Статья о том, зачем полностью переходить на Compose

  • Все лекции Android Academy 2023

Предыдущая статья: Go's Garbage Collection: как работает и почему это важно знать

Источник: https://habr.com/ru/companies/avito/articles/753246/


Интересные статьи

Интересные статьи

Доброе время суток, уважаемое Хабр коммьюнити. В этой публикации я хотел бы показать несколько известных мне подходов к версионной миграции данных в контексте DTO. Примеры будут продемонстрированы на ...
Советов «как ускорить веб-приложение» в интернете немало. Но при попытке применить их на деле может вспоминаться мем «делойте хорошо а плохо не делойте». Ситуации очень различаются, и универсальные ре...
А что, если я скажу, что подобное #application.properties spring.datasource.url=${SPRING_DATASOURCE_URL}?someProperty=${PROPERTY} содержит ошибку. Не согласны? Разбор под катом.
На дня ученый из Гарварда Джордж Черч дал американскому телеканалу CBSN интервью, в котором заявил, что работает над прототипом приложения для знакомств, которое будет анализировать ДНК-совме...
Перевод статьи подготовлен специально для студентов курса «Android-разработчик. Базовый курс». Также напоминаем о том, что мы продолжаем набор на расширенный курс «Специализация Android-разработч...