Отбираем хлеб у нативных разработчиков: миграция с Kotlin/Swift на RN

Меня зовут Александр Чернов, я фронтенд-разработчик в KODE и я использую React Native в разработке мобильных приложений уже более семи лет. Сейчас расскажу вам, как мы у нативных разработчиков хлеб отбирали.

718f0e0495aad93e5ff5375ac4d04bb9.png

Однажды к нам пришел заказчик с MVP от другой команды. Это были iOS и Android-приложение. iOS-приложение было написано на Swift, сверстано в сториборде, с архитектурой MVC (Massive Model View Controller). Android-приложение было написано на Kotlin, сверстано в XML. Архитектура отсутствовала — только два слоя, data и UI. Причем Activity или Fragment из UI-слоя содержали в себе всю бизнес-логику, которая зачастую дублировалась.

При ревью кодовой базы мы поняли, что в дальнейшем поддерживать MVP и добавлять новую функциональность будет больно. Поэтому решили основательно доработать продукт, чтобы при масштабировании бизнеса и внедрении новых фичей заказчику не пришлось переписывать код.

Для реализации мы рассматривали такие варианты:

  • Поддерживать нативное приложение, отрефакторить его и в дальнейшем дорабатывать на Kotlin/Swift. Но в таком случае бюджет проекта увеличился бы, поскольку заказчику нужно было бы задействовать iOS и Android-разработчиков.

  • Остановить поддержку Kotlin/Swift и разрабатывать приложение на React Native с нуля. Для заказчика этот вариант не подходил, так как для стартапа было важно быстрее получить готовый функционирующий продукт и начать разработку новых фич.

  • Поддерживать нативное приложение на Kotlin/Swift и постепенно переписать на React Native.

Мы обсудили перспективы с заказчиком и остановились на последнем варианте — плавном переезде на RN. У него было сильное преимущество: ранее на другом проекте мы уже опробовали React Native с этим заказчиком, и он остался доволен результатом. К тому же, у нас была уже собрана команда и наработана база, поэтому заказчику не пришлось заново проходить эти процессы и увеличивать стоимость проекта.

Как мы «переезжали»

  1. Потушили пожары

Сначала мы с минимальными усилиями сделали ребрендинг приложения, скрыли лишний функционал и исправили множество багов. Например, столкнулись с проблемой несогласованных моделей данных между клиентом и сервером. Также в приложении на Android отсутствовал некоторый функционал, который был на iOS — например, Google reCAPTCHA. 

В общем, мы провели минимальный рефакторинг и выпустили приложение в TestFlight, чтобы бизнес мог с ним работать и параллельно проходить проверку в сторах.

ef064a2de99c0dff718ec69870477804.jpeg

  1. Начали внедрять React Native

У React Native есть все необходимые инструменты и подробная документация, чтобы внедрять его в уже существующие нативные приложения.

Минимальная сущность, в которую рендерится RN-приложение для iOS — View, а для Android — Fragment. Зная это, я написал набор утилит, которые позволяют буквально в пару строчек запустить React-приложение. Получилось довольно лаконично, достаточно передать имя начального роута.

Android:

class RNDepositsFragment: RNFragment (
  options = object : RNFragmentOptions () {
    override fun getInitialRoute(): String {
      return "deposits"
    }
  }
)

iOS:

class RNDepositsViewController : RNViewController {
 override var options: RNViewControllerOptions {
   return RNDepositsOptions()
 }
}

struct RNDepositsOptions : RNViewControllerOptions {
 func getInitialRoute() -> String {
   return "deposits"
 }
}

Если возникает необходимость передать дополнительные данные, с этим также не возникает проблем — в JS-слое регистрируем компонент App,

AppRegistry.registerComponent('appName', () => App);

которыйпринимает переданные из нативного слоя свойства.

const App = ({ authToken, baseUrl, initialScreenName, …other }: AppInitialProps) => {
…
}

Для управления нативной навигацией по экранам, а также передачи данных между RN-представлением и нативным слоем был написан Native Module, который мы назвали AppBridge (см. схему ниже).

Посмотреть AppBridge-модуль

class AppBridgeModule(private val reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
    private val eventEmitter = AppBridgeEventEmitter(reactContext)
    override fun getName(): String = MODULE_NAME

    @ReactMethod
    fun logout() {
      ...
    }

    // пример авторизации нативной части приложения
    // React-приложение отправляет токен в натив
    @ReactMethod
    fun login(token: String, promise: Promise) {
      ...
    }

    // пример главного экрана
    @ReactMethod
    fun openHomeScreen() {
        val intent =
            Intent(reactContext, HomeActivity::class.java)
        intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
        reactContext.currentActivity?.startActivity(intent)
    }

    // Отправка ивентов с пейлоадом в RNFragment
    // Например в RN представлении пользователь нажимает кнопку "назад", 
    // но нам нужно выполнить не навигационный переход внутри RN-приложения, а закрыть целиком Activity
    @ReactMethod
    fun send(eventName: String, payload: ReadableMap?) {
        UiThreadUtil.runOnUiThread {
          // RN - это проектное решение, singleton-объект для доступа к React-native представлениям
          RN.shared.onEvent(eventName, payload);
        }
    }
}

Визуально это выглядит так. Нативный слой доминирует и занимает всю площадь экрана, нижний таббар тоже нативный. Но контент внутри таба — это React-приложение со своими моделями данных, навигацией, сетевым слоем и так далее.

a6313ffe49265621ab8c962e871f9649.png

Напомню, что в Android основной экран с таб-навигацией — Activity, а каждый таб — Fragment. Мне достаточно было добавить новый или заменить существующий на RN фрагмент, чтобы при переходе на таб запускалось RN-приложение с нужным флоу. Очень просто. В iOS тоже самое: UITabBarController для каждого таба рендерит UIViewController, мы также легко можем добавлять новые или изменять текущие контроллеры на RNViewController (см. код выше). 

Как это работает под капотом: нативный слой запускает React Native-представление, которое запускает React-приложение с начальными параметрами, такими как токен авторизации, ссылка на API, имя роута и другие.

Как все работает

Как все работает

Как видно из схемы, React-приложение может асинхронно обмениваться данными с нативным слоем и непосредственно с RN-представлением в обе стороны через AppBridge (Native Module). 

Также можно заметить, что все RN-представления открывают одно и то же React-приложение, просто с разными входящими параметрами. Слой React Native намеренно сделан моноприложением, так как конечной целью было оставить одну точку входа. Но ничто не мешает организовать и микроприложения, чтобы у каждого из них была своя песочница и не было общего контекста. А RN Utils — это набор самописных утилит для работы с RN, туда входят классы RNFragment, RNViewController и другие.

  1. Мигрировали на React Native

Следующим шагом была замена флоу авторизации/регистрации. Теперь React Native получает авторизационный токен, сохраняет его, рефрешит и передает в нативный слой. Также управляет биометрией, логаутом и т.д. 

А еще мы добавили CodePush — киллер-фичу React Native, которая позволяет обновлять мобильное приложение без прохождения проверок в сторах.

Постепенно заменили все табы и отдельные Activity/UIViewController на RN-представления. Но основная навигация по приложению — неавторизованная зона, флоу авторизации, основной экран с табами и другие экраны, до сих пор осуществляется в нативном слое. 

Наконец, остался последний нативный раздел. Прежде чем переделывать его, мы доработали приложение так, чтобы React Native начал доминировать. Получилось полноценное RN-приложение, запущенное в одном Activity/UIViewController, в котором вся навигация осуществляется через react-navigation. Оставшийся раздел я завернул в Native UI Component (документация Android, документация iOS) и использую его в React-приложении.

df1a85c4d39d9b4c513d1618bd4c9fcd.png

Финальным шагом мы переделали последний нативный раздел на RN и выпилили оставшийся мусор. Теперь это полностью React Native приложение, как будто мы написали его с нуля.

e0f0a35aeba0c9a3ef4903a941d2de7d.png

Преимущества и недостатки подхода

React Native — крутая и зрелая технология, которая хорошо себя проявляет. Забегая вперед, скажу, что при ее внедрении в существующее приложение я не встретил непреодолимых проблем, а процесс был подробно описан в документации.

Рассмотрим преимущества и недостатки нашего подхода.

Преимущества

  • Нон-стоп. Мы бесшовно переписали приложение, улучшили пользовательский опыт, кодовую базу и поддерживаемость продукта, и при всем этом не тормозили бизнес.

  • Снижение стоимости разработки. При переходе на кроссплатформу над проектом работает одна команда вместо двух, то есть вместо iOS и Android — React Native. 

  • Увеличение скорости разработки. Инструменты для React Native очень развиты и позволяют быстро разрабатывать приложения. В Storybook можно сверстать приложение практически полностью и посмотреть, как оно будет выглядеть. Плюс hot-reload — мы пишем код, он сразу же обновляется и мы видим результат. Тот же самый CodePush — очень крутая вещь.

Недостатки

  • Проблемы, с которыми мы бы не столкнулись при обычной разработке. Например, работа с диплинками или кнопкой «Назад» на Android. Когда на экране два слоя — нативное приложение и React Native, они оба начинают обрабатывать эти действия, происходят некоторые конфликты и приходится тратить время на их решение.

  • Высокий порог входа для React Native-разработчика. Помимо React Native, ему нужно неплохо разбираться в нативных платформах, писать нативный код и в целом понимать, как все работает.

P.S. Возможно, вы хотите спросить, где исходники RNViewController, RNFragment и RN Utils. Это были временные, узкоспециализированные решения, которые помогли нам переехать на RN, а после переезда потеряли ценность и были удалены из проекта. Поэтому исходники мы показать не можем.

Спасибо за внимание!

© Habrahabr.ru