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:

  1. Открывает доступ к сторонним UI-библиотекам. Сейчас их немного, но всё больше крупных компаний переписывают на Сompose свои open-source-решения. Актуальные сторонние библиотеки собраны в репозитории Jetpack Compose Awesome.

  2. Позволяет создавать собственные компоненты для React Native. React Native — довольно ограниченный фреймворк. Например, проблематично реализовать waterfall layout с самовыравнивающимися ячейками разной высоты на React Native. Проще написать такой компонент с помощью встроенной в Jetpack Compose layout-системы и интегрировать его в основной React-Native-проект.

  3. Упрощает миграцию на нативный стек. Если приложение только для 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 для создания компонента в конце статьи.

Перед интеграцией нужно:

  1. Добавить зависимости в build.gradle. Их список есть на официальном сайте Android for Developer.

  2. Обновить плагин Gradle до 7-й версии. Пошаговая инструкция по обновлению в руководстве Update the Android Gradle plugin.

  3. Пропатчить зависимости для совместимости с 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-менеджеру.

Полезные ссылки

А если вам всё это известно, откликайтесь на вакансию и приходите к нам: React Native разработчик в СберМаркет.


Мы завели соцсети с новостями и анонсами Tech-команды. Если хотите узнать, что под капотом высоконагруженного e-commerce, следите за нами там, где вам удобнее всего: Telegram, VK, FB, Twitter.


ссылка на оригинал статьи https://habr.com/ru/company/sbermarket/blog/652683/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *