Привет! Меня зовут Андрей Берюхов, я 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. Полную таблицу совместимости можно посмотреть в документации →
Если у вас не самый свежий Kotlin, но использовать новый Compose хочется, есть специальные сборки Compose Compiler для совместимости версий.
Полную таблицу совместимости смотрите в документации →
Подумайте о переходе на корутины. Это особенно актуально для старых проектов, в которых используются AsyncTask, RxJava, LiveData.
Корутины — более легковесный способ организации асинхронных операций, чем AsyncTask, RxJava или LiveData. Они используют неблокирующие операции ввода-вывода, что позволяет эффективнее использовать ресурсы устройства.
Более того, переход на корутины до миграции поможет вам заранее ознакомиться с концепциями Compose. Это важно, потому что корутины и Jetpack Compose тесно связаны.
Другое решение — использовать адаптеры из пакета androidx.compose.runtime:runtime. Они позволяют оставить логику на RxJava и LiveData.
Перенесите приложение на архитектуру, основанную на Unidirectional Data Flow (UDF). Идея такой архитектуры в том, что данные в приложении передаются только в одном направлении: от модели приложения к UI.
Несмотря на множество существующих реализаций UDF: например, MVICore, MVIKotlin, достаточно легко написать свою поверх ViewModel.
Это возможно, потому что в Compose много extension-функций для работы с жизненным циклом через ViewModel и доступа к ViewModel.
Частичная миграция UI: интеграция Compose во View
Разработчики Jetpack Compose предусмотрели, что рано или поздно всем придётся мигрировать на этот набор инструментов с XML. Поэтому они изначально задизайнили 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. Вот когда это может быть полезно:
Если есть большая и сложная View, которую еще не перевели на Compose. Некоторые компоненты, такие как MapView или AdView, до недавнего времени полноценно не поддерживались в Compose. Такие View можно включить в Compose без потери функциональности.
Если есть сложный пользовательский интерфейс с использованием 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 предлагает такой алгоритм:
Выделить переиспользуемые элементы.
Создать библиотеку с общими UI-компонентами — дизайн-систему.
По одной заменить существующие фичи с помощью UI-компонентов из дизайн-системы.
Новые фичи при этом лучше делать сразу с помощью Compose.
Подход bottom-up — это тоже рекомендация Google. Его суть заключается в том, чтобы мигрировать элементы снизу вверх:
Добавить ComposeView в иерархию UI. Лучше делать это постепенно: сверху вниз, поэкранно — особенно если он сложный. При этом оставляя текущие контейнеры экранов - Fragment или даже Activity - и текущую навигацию между ними.
Заменить все Fragments, Activity и навигацию между ними на новую навигацию, основанную на Compose.
Миграция архитектуры: MVVM → MVI
Помимо UI, нужно перенести и архитектуру. Лучше всего Jetpack Compose сочетается с MVI.
MVI (Model-View-Intent) — это архитектура с тремя важными составляющими: View, Feature и State.
Для примера возьмём экран создания тестового пользователя. В нём есть поле ввода 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, поэтому я приведу только общий план миграции:
Публичные Presenter-методы превратить в Action.
Роутерные методы конвертировать в подкласс OneTimeEvent.
Методы интерфейса View превратить в Action.
Создать неизменяемый класс — State, который полностью описывает UI.
Методы интерфейса View, которые затрагивают UI, перенести в State.
State-поля должны изменяться только через метод copy().
Убрать синхронизацию UI с жизненным циклом Presenter. Например, если отправляли в Presenter onAttach.
Миграция дизайн-системы
Дизайн-система помогает разработчикам и дизайнерам разговаривать на одном языке и проще договариваться. И её тоже нужно мигрировать на Compose. Вот как это сделать:
1. Перенести базовые компоненты из дизайн-системы на View. Допустим, вы работаете в части приложения, которая связана с авторизацией. Там очень часто экран состоит из двух инпутов и кнопки. Значит, лучше перенести в новую дизайн-систему и инпут, и кнопку.
2. Добавить недостающие элементы. Например, на экран авторизации нужно добавить карусель с соцсетями. Этот экран мы уже сделали на Compose, но новые компоненты в нём потенциально полезны в других экранах, поэтому их мы тоже переносим в дизайн-систему.
3. Перенести оставшиеся экраны, используя Compose дизайн-систему .
Теперь вы знаете, почему стоит мигрировать классический UI на новый фреймворк Compose. Инструменты и шаги, которые я описал в статье, помогут разобраться со сложностями.
В Авито мы тоже начали этот путь. Сейчас постепенно мигрируем на Coroutines наш собственный MVI-Flow. А ещё готовим дизайн-систему и экспериментируем с первыми экранами, замеряем перфоманс.
Полезные материалы
Полная лекция Jetpack Compose: Migration of existing app (1 час 12 минут)
Кодлаба Compose migration live code-along. Android dev summit 2021 (51 минута)
Предыдущая статья: Go's Garbage Collection: как работает и почему это важно знать
Комментарии (10)
Rusrst
21.08.2023 09:42Есть одно но - после создания вью пула rv все равно ощутимо (т.е. замеряемо и это повторяемо) быстрее чем lazy lists. Но на 1.5.0 про пводительность конечно заметно поднялась.
А зачем фрагменты с compose? Вроде же вся прелесть в том, что у нас вообще одна view на все приложение и в теории это должно работать быстрее чем старая система.
Phansier Автор
21.08.2023 09:42Фрагменты с Compose нужны для более поэтапной миграции. Даже сейчас большие приложения - это далеко не single activity. У нас есть множество как Activity, так и фрагментов.
Одновременно переезжать и по вью-стеку и по стеку навигации может быть неподъёмно.Rusrst
21.08.2023 09:42Да, не сингл, согласен, но так ещё и навигацию тянуть придется... Кстати, а как вопрос с Deeplinks обстоит? Активити то одна.
Phansier Автор
21.08.2023 09:42+2Если не трогать "контейнеры" - activity/fragment - навигация не меняется, пока мы явно не захотим её упростить.
У NavComponent Compose примерно так же, как и раньше https://developer.android.com/jetpack/compose/navigation#deeplinks
alexey_p
21.08.2023 09:42Расскажите как вы тестируете такие экраны?
Phansier Автор
21.08.2023 09:42Примерно так же, как и экраны на View.
ДобавляетсяAndroidComposeTestRule
+ делается PageObject на основеSemanticsNodeInteractionsProvider
.
У нас свой тестовый фреймворк, в котором коллеги это поддержали.Rusrst
21.08.2023 09:42А почему именно Android? Обычного недостаточно?
Phansier Автор
21.08.2023 09:42+1Зависит от ситуации. Для чисто Compose-экранов может быть достаточно.
Т.к. мы мигрируем с View - нам удобно поправить в E2E тестах только те экраны, которые переписаны на Compose. И для этого подходитAndroidComposeTestRule
(чтобы получить PageObject от Activity, а не передавая Composable функцию в тест напрямую).
Похожим образом работает Kakao и Kaspresso
Firsto
Как раз собрался переводить на Compose новые экраны на одном из проектов, очень полезная информация, кратко и по делу.)
Поделитесь опытом кастомизации тем для приложения, и, возможно, какими-нибудь полезными расширениями, которые постоянно используются. (ʘ‿ʘ)
Phansier Автор
И кастомизация тем и расширения очень зависят от конкретного приложения и необходимости. Но в целом никаких сложностей там возникнуть не должно.