Отбираем хлеб у нативных разработчиков: миграция с Kotlin/Swift на RN
Меня зовут Александр Чернов, я фронтенд-разработчик в KODE и я использую React Native в разработке мобильных приложений уже более семи лет. Сейчас расскажу вам, как мы у нативных разработчиков хлеб отбирали.
Однажды к нам пришел заказчик с 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 с этим заказчиком, и он остался доволен результатом. К тому же, у нас была уже собрана команда и наработана база, поэтому заказчику не пришлось заново проходить эти процессы и увеличивать стоимость проекта.
Как мы «переезжали»
Потушили пожары
Сначала мы с минимальными усилиями сделали ребрендинг приложения, скрыли лишний функционал и исправили множество багов. Например, столкнулись с проблемой несогласованных моделей данных между клиентом и сервером. Также в приложении на Android отсутствовал некоторый функционал, который был на iOS — например, Google reCAPTCHA.
В общем, мы провели минимальный рефакторинг и выпустили приложение в TestFlight, чтобы бизнес мог с ним работать и параллельно проходить проверку в сторах.
Начали внедрять 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-приложение со своими моделями данных, навигацией, сетевым слоем и так далее.
Напомню, что в 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 и другие.
Мигрировали на React Native
Следующим шагом была замена флоу авторизации/регистрации. Теперь React Native получает авторизационный токен, сохраняет его, рефрешит и передает в нативный слой. Также управляет биометрией, логаутом и т.д.
А еще мы добавили CodePush — киллер-фичу React Native, которая позволяет обновлять мобильное приложение без прохождения проверок в сторах.
Постепенно заменили все табы и отдельные Activity/UIViewController на RN-представления. Но основная навигация по приложению — неавторизованная зона, флоу авторизации, основной экран с табами и другие экраны, до сих пор осуществляется в нативном слое.
Наконец, остался последний нативный раздел. Прежде чем переделывать его, мы доработали приложение так, чтобы React Native начал доминировать. Получилось полноценное RN-приложение, запущенное в одном Activity/UIViewController, в котором вся навигация осуществляется через react-navigation. Оставшийся раздел я завернул в Native UI Component (документация Android, документация iOS) и использую его в React-приложении.
Финальным шагом мы переделали последний нативный раздел на RN и выпилили оставшийся мусор. Теперь это полностью React Native приложение, как будто мы написали его с нуля.
Преимущества и недостатки подхода
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, а после переезда потеряли ценность и были удалены из проекта. Поэтому исходники мы показать не можем.
Спасибо за внимание!