Jetpack Compose в проектах на React Native: плюсы, минусы и интеграция

3ee445d7510ab22b8920a6bb094503c3.png

Привет! Меня зовут Сергей Курочкин, я руковожу 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 
{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 для создания компонента в конце статьи.

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

  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. В качестве примера возьмём пин, который отображается на карте.

1ff9d26ddad6a20fd003d06bacd330b3.jpg

У пина есть анимированный внутренний белый круг. Когда анимация включена, круг расширяется и сужается. Когда анимация выключается, круг сужается до минимального радиуса и останавливается.

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.

© Habrahabr.ru