
Привет! Меня зовут Сергей Курочкин, я руковожу Android-разработкой в СберМаркете. Сегодня я расскажу, зачем нужен Jetpack Compose в проектах React Native, и поделюсь опытом интеграции фреймворка в наши приложения. В конце на примере простого компонента разберем весь процесс разработки на Jetpack Compose.
Этот материал можно послушать
Я выступал с этим докладом на Android Meetup | СберМаркет Tech, здесь его дополненная версия.
Jetpack Compose — UI-фреймворк для Android
Jetpack Compose — это набор инструментов для разработки UI в Android-приложениях на языке программирования Kotlin. Он ускоряет и упрощает создание пользовательского интерфейса благодаря декларативному подходу.
Декларативная парадигма позволяет выносить детали реализации в отдельные функции и переиспользовать их для других целей.
Jetpack Compose — свежий UI-тулкит: Google выпустил первую стабильную версию 1.0 в июле 2021 года. Актуальная версия на момент написания статьи — Jetpack Compose 1.1, она вышла 26 января 2022 года.
Зачем нужен Jetpack Compose в React-Native-проекте
Jetpack Compose уменьшает количество кода и позволяет не зависеть от версии Android. Описывать UI можно прямо в Kotlin-коде — без xml. Помимо этого, Compose:
-
Открывает доступ к сторонним UI-библиотекам. Сейчас их немного, но всё больше крупных компаний переписывают на Сompose свои open-source-решения. Актуальные сторонние библиотеки собраны в репозитории Jetpack Compose Awesome.
-
Позволяет создавать собственные компоненты для React Native. React Native — довольно ограниченный фреймворк. Например, проблематично реализовать waterfall layout с самовыравнивающимися ячейками разной высоты на React Native. Проще написать такой компонент с помощью встроенной в Jetpack Compose layout-системы и интегрировать его в основной React-Native-проект.
-
Упрощает миграцию на нативный стек. Если приложение только для Android, но написано на React Native, удобнее использовать нативный стек. Перейти на него можно с помощью стандартного Android View, но проще — с помощью Jetpack Compose, который концептуально ближе к React Native. Это особенно актуально, если в команде только RN-разработчики.
Jetpack Compose — молодой тулкит. Возможно, необходимые конкретной команде инструменты ещё не реализовали. Список готовых групп классов есть на сайте Android for Developers. Там же дорожная карта Jetpack Compose.
Jetpack Compose и RN: функциональные компоненты
Jetpack Compose и React Native используют декларативный подход, поэтому в них есть похожие концепции. Это делает Jetpack Compose понятным и простым для разработчиков на React Native.
Первая похожая концепция — функциональные компоненты, которые позволяют разбить интерфейс на самодостаточные компоненты. В Jetpack Compose такие компоненты можно создавать с помощью composable-функций.
Давайте напишем функциональный компонент на React Native и реализуем его же с помощью composable-функции на Jetpack Compose.
Функциональный компонент на React Native
Определим функциональный компонент Container и передадим в него свойство children. Это обязательное property, которое есть практически всегда.
В React Native свойства компонента оборачиваются в объект и передаются функции. Поэтому мы с помощью композиции в JSX вкладываем чилды один внутрь другого и передаем компоненту контейнер.
function Container(props) { return <div>{props.children}</div>; } <Container> <span>Hello world!</span> </Container>
Функциональный компонент на Jetpack Compose
Тот же контейнер на Jetpack Compose можно написать в виде composable-функции с похожей логикой. Мы тоже складываем друг в друга, только теперь не чилды, а сами компоненты: внутренний во внешний.
И если в React Native свойства нужно было обернуть в объект, то здесь компоненты мы передаём непосредственно аргументами внутренней composable-функции.
@Composable fun Container(children: @Composable () -> Unit) { Box { children() } } Container { Text("Hello world"!) }
Jetpack Compose и RN: хуки
Второй похожий концепт — это хуки. В React Native с помощью хуков из компонента выносят логику, связанную с его жизненным циклом, чтобы использовать в других компонентах. В Jetpack Composable то же самое можно сделать с помощью всё тех же composable-функций.
Хук на React Native
Определим хук useFriendStatus, который будет сообщать статус друга с идентификатором friendID: онлайн или офлайн.
Чтобы возвращать информацию о статусе из хука, будем использовать useState со значением по умолчанию null. Для него у нас есть геттер isOnline и сеттер setIsOnline.
Обратите внимание на то, как работает useEffect. Первый раз он выполняется при маунте. Если одна из зависимостей, которая передается в массиве вторым аргументом, изменится, useEffect выполняется ещё раз. То есть после смены каждой зависимости и при анмаунте будет вызываться коллбэк, который мы возвращаем на очистку.
function useFriendStatus(friendID) { const [isOnline, setIsOnline] = useState(null); useEffect(() -> { function handleStatusChange(status) { setIsOnline(status.isOnline); } ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange); return () -> { ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange); }; }, [friendID]); return isOnline; }
Такой хук можно переиспользовать сразу в нескольких компонентах. При этом они не будут знать ничего о внутренней реализации.
Хук на Jetpack Compose
В Jetpack Compose такой же хук можно реализовать с помощью composable-функции. По аналогии с React Native определяем State со значением по умолчанию null. Для него у нас также есть геттер isOnline и сеттер setIsOnline.
Вспомните UseEffect в хуке на React Native. Здесь ту же роль играет DisposableEffect. Главное отличие — в DisposableEffect зависимости перенимаются позиционными аргументами, а последним аргументом мы передаем лямбду. Она будет вызываться при смене этих аргументов, при маунте и анмаунте.
То есть в React Native мы возвращаем коллбэк, а здесь передаём его в метод onDispose. Эффект получается одинаковый.
@Composable fun friendStatus(friendID: String): State<Boolean?> { val (isOnline, setIsOnline) = remember { mutableStateOf<Boolean?>(null) } DisposableEffect(friendID) { val handleStatusChange = { status: FriendStatus -> setIsOnline(status.isOnline) } ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange) onDispose { ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange) } } return isOnline }
Хуки и функциональные компоненты — не единственные сходства Compose и React Native. Подробнее о похожих концепциях в Jetpack Compose и React можно почитать в статье React to Jetpack Compose RC Dictionary.
Как интегрировать Jetpack Compose в проект на React Native
Чтобы интегрировать Jetpack Compose, в проекте нужно использовать фреймворк ComposeView. Он обеспечивает Interop для Jetpack Compose и нативный View. Мы будем использовать ComposeView для создания компонента в конце статьи.
Перед интеграцией нужно:
-
Добавить зависимости в build.gradle. Их список есть на официальном сайте Android for Developer.
-
Обновить плагин Gradle до 7-й версии. Пошаговая инструкция по обновлению в руководстве Update the Android Gradle plugin.
-
Пропатчить зависимости для совместимости с Gradle 7 с помощью patch.package.
Последний пункт понадобится, только если у вас есть несовместимые с Gradle 7 зависимости. Например, нам нужно было пропатчить React Native CLI и Flipper. А вот в версии Native 0.66 и выше они уже совместимы с Gradle 7.
Подробнее об интеграции Jetpack Compose можно почитать в официальном руководстве Adding Jetpack Compose to your app.
Какие проблемы могут возникнуть при интеграции
Мы столкнулись с двумя проблемами, когда интегрировали Jetpack Compose в проект на React Native.
Проблема 1: не работают переходы при использовании React Native Navigation + React Native Screens.
Решение: строго указать версию fragment 1.2.1.
В проекте для переходов между экранами в нашем проекте используются библиотеки React Native Navigation и React Native Screens. Когда мы попытались интегрировать Jetpack Compose в этот проект, на некоторых устройствах перестали работать переходы. Проблема решилась после строгого указания версии fragment 1.2.1 в gradle-файле:
implementation("androidx.fragment:fragment") { version { strictly '1.2.1' } }
Проблема 2: «Cannot locate windowRecomposer; View $this is not attached to a window» после navigation.reset.
Решение: переопределить метод onMeasure внутри View-враппера для Compose View.
При вызове navigation.reset в некоторых компонентах возникал exception: «Cannot locate windowRecomposer». Проблема была в методе onMeasure, который использует ComposeView. Внутри ComposeView стояла явная проверка на то, приаттачен ли он к окну, и если нет, возникало исключение.
Мы переопределили метод onMeasure и вынесли в него отдельную проверку composeView.isAttachedView. В случае false задаём размеры по умолчанию, и исключение не возникает.
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { if (composeView.isAttachedToWindow) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) } else { val width = maxOf(0, MeasureSpec.getSize(widthMeasureSpec) - paddingLeft - paddingRight) val height = maxOf(0, MeasureSpec.getSize(heightMeasureSpec) - paddingTop - paddingBottom) val child = composeView.getChildAt(0) if (child == null) { setMeasuredDimension( width, height ) return } child.measure( MeasureSpec.makeMeasureSpec(width, MeasureSpec.getMode(widthMeasureSpec)), MeasureSpec.makeMeasureSpec(height, MeasureSpec.getMode(heightMeasureSpec)), ) setMeasuredDimension( child.measuredWidth + paddingLeft + paddingRight, child.measuredHeight + paddingTop + paddingBottom ) } }
Пример компонента на Jetpack Compose для React Native
Давайте рассмотрим процесс разработки простого UI-компонента на Jetpack Compose, который можно будет использовать в проекте на React Native. В качестве примера возьмём пин, который отображается на карте.

У пина есть анимированный внутренний белый круг. Когда анимация включена, круг расширяется и сужается. Когда анимация выключается, круг сужается до минимального радиуса и останавливается.
data class PinParams( val outerDiameter: Dp = 46.dp, val innerDiameterDefault: Dp = 14.dp, val innerDiameterExpanded: Dp = 24.dp, val pointDiameter: Dp = 7.dp, val lineWidth: Dp = 4.dp, val lineHeight: Dp = 15.dp ) { val innerRadiusDefault = innerDiameterDefault / 2 val innerRadiusExpanded = innerDiameterExpanded / 2 val innerRadiusDiff = innerRadiusExpanded - innerRadiusDefault val outerRadius = outerDiameter / 2 val pointRadius = pointDiameter / 2 }
Определим параметры пина PinParams в data class. Задаём значения диаметров внутреннего и внешнего кругов, диаметра чёрной точки на карте, ширины и высоты «ножки». Параметры удобно определять с помощью data class — структуры данных из Kotlin. В data class есть метод equality, который позволяет сравнивать все параметры, и метод copy для копирования объектов — аналог оператора spread в JavaScript.
@Composable fun Pin( pinParams: PinParams, animated: Boolean = false ) { val radiusCoef = animatedRadiusCoef(animated) AnimatedCanvasComponent(params = PinParams, radiusCoef = radiusCoef) }
Определим composable-функцию Pin. В качестве параметров Pin принимает параметры пина pinParams и флаг animated с информацией о том, включена ли сейчас анимация.
Функция отрисовывает пин на Canvas — для этого мы используем отдельный компонент AnimatedCanvasComponent. В него передаём значение коэффициента радиуса анимированного круга: от 0 до 1.
Всю логику определения радиуса анимации на основе флага animated удобно вынести в хук animatedRadiusCoef:
@Composable fun animatedRadiusCoef( animated: Boolean ): Float { val infiniteTransition = rememberInfiniteTransition() var isRunning by remember { mutableStateOf(animated) } val radiusCoef = infiniteTransition.animateFloat( initialValue = 0f, targetValue = if (isRunning) 1f else 0f, animationSpec = infiniteRepeatable( animation = tween(300, easing = LinearEasing), repeatMode = RepeatMode.Reverse ) ) val radiusIsZero = radiusCoef.value = 0f LaunchedEffect(animated, isRunning, radiusIsZero) { if (!animated && isRunning && radiusIsZero) { isRunning = false } if (animated && !isRunning && radiusIsZero) { isRunning = true } } return radiusCoef.value }
В animatedRadiusCoef мы используем стандартный хелпер из Kotlin rememberInfiniteTransition для бесконечной анимации.
Чтобы анимация завершилась, объявляем mutableState — isRunning. Когда внешний флаг animated поменяется с true на false, нужно дать ещё некоторое время компоненту, чтобы он завершил последний цикл анимации и уменьшил круг до исходного состояния; после этого анимацию можно отключить.
class PinViewModel : ViewModel() { var uiState by mutableStateOf(false) private set fun setValue(next: Boolean) { uiState = next } } @SuppressLint("ViewConstructor") class PinView(context: Context) : FrameLayout(context) { private val viewModel = PinViewModel() fun setValue(next: Boolean) { viewModel.setValue(next) } init { val composeView = ComposeView(context = context) composeView.setViewCompositionStrategy( ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed ) composeView.setContent { Pin(pinParams = PinParams(), animated = viewModel.uiState) } layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) addView(composeView) } }
Обернём composable-функцию Pin в нативный View. Чтобы управлять логикой компонента, создаём класс PinViewModel, наследуемый от ViewModel. Внутри определяем метод setValue, чтобы прокинуть сеттер наружу. Он понадобится позже.
Для Interop и нативного View создаём класс PinView, наследуемый от FrameLayout, и используем в нём ComposeView.
Composable-функцию Pin будем вызывать в методе setContent.
Чтобы добавить ComposeView во FrameLayout, используем метод addView.
class PinViewManager : SimpleViewManager<PinView>() { override fun getName() = "PinView" override fun createViewInstance(reactContext: ThemedReactContext): PinView { return PinView(reactContext.currentActivity!!.applicationContext) } @ReactProp(name = "isAnimating") fun toggle(view: PinView, isAnimating: Boolean) { view.setValue(isAnimating) } }
Напишем View-менеджер — наследник SimpleViewManager в React Native. Он нужен, чтобы использовать компонент PinView, написанный с помощью Jetpack Compose, в проекте на React Native. View-менеджером в нашем случае будет класс PinViewManager, наследуемый от SimpleViewManager.
В нём переопределяем метод getName, чтобы он возвращал PinView. Таким образом, мы можем получить доступ к нативному компоненту, используя стандартный метод из React Native — requireNativeCompontent. Также переопределяем createViewInstance, он будет создавать instance PinView.
Если нужно передавать параметры, нужно использовать нотацию ReactProp. В name мы передаем, как будет называться property в JS-части. В метод toggle приходит instance PinView и текущее значение isAnimating из JS-части. Используя сеттер setValue, который мы определили в PinView, изменяем состояние isAnimating.
UI-компонент Pin готов. Он написан на Jetpack Compose, но его можно использовать в проекте на React Native благодаря View-менеджеру.
Полезные ссылки
-
Статья нашего разработчика Виктора Ильтимирова про переход на RN: React → React Native: снится ли фронтендерам мобильная разработка? / Хабр
-
GitHub-репозиторий. Предложение добавить поддержку Compose-компонентов в React Native из коробки
А если вам всё это известно, откликайтесь на вакансию и приходите к нам: React Native разработчик в СберМаркет.
Мы завели соцсети с новостями и анонсами Tech-команды. Если хотите узнать, что под капотом высоконагруженного e-commerce, следите за нами там, где вам удобнее всего: Telegram, VK, FB, Twitter.
ссылка на оригинал статьи https://habr.com/ru/company/sbermarket/blog/652683/
Добавить комментарий