Jetpack Compose в проектах на React Native: плюсы, минусы и интеграция
Привет! Меня зовут Сергей Курочкин, я руковожу 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 {props.children};
}
Hello world!
Функциональный компонент на 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 {
val (isOnline, setIsOnline) = remember { mutableStateOf(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() {
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-менеджеру.
Полезные ссылки
А если вам всё это известно, откликайтесь на вакансию и приходите к нам: React Native разработчик в СберМаркет.
Мы завели соцсети с новостями и анонсами Tech-команды. Если хотите узнать, что под капотом высоконагруженного e-commerce, следите за нами там, где вам удобнее всего: Telegram, VK, FB, Twitter.