Всем привет! В конце весны 2024 новая архитектура React Native вышла в бета‑версию. Хотя команда React Native пока не рекомендует использовать её в продакшн‑приложениях, многие библиотеки уже адаптированы для работы с ней или находятся на пути к полноценной интеграции. React Native всегда предоставлял возможность интеграции с нативным кодом, а новая архитектура делает этот процесс ещё более эффективным и гибким.
В этой статье я хочу поделиться своим опытом интеграции SwiftUI компонента с использованием Fabric и базы данных Realm с помощью Turbo Modules. Всё это я реализовал на примере iOS‑приложения, которое показывает список популярных фильмов, позволяет добавлять их в избранное, просматривать список избранного и удалять из него.
Само приложение достаточно объёмное, поэтому в данной статье я затрону лишь ключевые моменты, касающиеся интеграции. Мы не будем углубляться в детали реализации нативных компонентов, а сосредоточимся на процессе интеграции с React Native. Ссылку на репозиторий с приложением я оставлю в конце статьи.
Термины
-
Fabric — новая система рендеринга в React Native, подробнее можно прочитать здесь.
-
Turbo Module — это следующая эволюция нативных модулей в React Native, которая предоставляет дополнительные преимущества. В частности, Turbo Modules используют JSI (JavaScript Interface), интерфейс для нативного кода, который обеспечивает более эффективное взаимодействие между нативным и JavaScript кодом по сравнению мостом (Bridge).
-
Codegen — это инструмент, используемый в новой архитектуре React Native для автоматической генерации кода на основе определённых с помощью TypeScript / Flow интерфейсов.
Итак, начнём с обзора основного функционала приложения и мест, где используются нативные модули.
Функционал приложения
-
Главный экран — мы загружаем фильмы и показываем их пользователю в виде бесконечно скролящегося списка. Запрос выполняется на стороне JS, мы передаём статус загрузки и полученные данные в компонент MovieListView, который реализован на SwiftUI.
При нажатии на фильм мы можем перейти на экран с более подробной информацией о фильме, который полностью реализован нативно, но данные для этого мы всё равно запрашиваем на стороне JS, а затем передаём в тот же компонент. Также на главном экране мы используем функционал нативного модуля favourite-movies-storage, который отвечает за запись и чтение в базу данных Realm. Вся коммуникация также происходит через JS слой.
-
Экран списка избранных фильмов — это самый обычный Flatlist, но данные для него мы берем из базы данных Realm, используя всё тот же модуль favourite-movies-storage.
Есть несколько способов создать нативный модуль на новой архитектуре. Мы можем пойти и вручную написать конфигурацию для Codegen, как описано здесь для Fabric компонента, или здесь для Turbo Module.
Также мы можем использовать инструмент react-native-builder-bob, который создаст нам примитивный компонент или турбомодуль, который мы потом можем использовать как отправную точку для реализации своего функционала.
Я использовал последний подход. С помощью Bob мы можем создать как локальный модуль, так и библиотеку. В моем случае это был локальный модуль. Для этого в корне проекта я выполнил следующую команду:
npx create-react-native-library@latest favourite-movies-storage
После этого нужно ввести данные о конфигурации нашей библиотеки. Я создавал нативные модули без обратной совместимости со старой архитектурой.
Данную команду нужно выполнить дважды: первый раз для компонента, второй для турбомодуля. Но я хотел иметь весь нативный функционал, касающийся списка фильмов, в одном модуле, поэтому после создания я произвёл ряд манипуляций, чтобы объединить их в один модуль.
В итоге я получил следующую структуру:
Папку Android мы игнорируем.
Основные файлы конфигурации
-
package.json — представляет собой описание нашей библиотеки. Здесь стоит обратить внимание на поле codegenConfig:
"codegenConfig": { "name": "RNMovieList", "type": "all", "jsSrcsDir": "src" }
Здесь мы описываем название нашего модуля, папку, где лежит наш JS код, и тип модуля.
Если мы хотим иметь Fabric компонент и Turbo Module в одном месте, то он должен быть all.
-
react-native-movie-list.podspec — конфигурация CocoaPods модуля. Здесь есть несколько ключевых моментов:
-
Файлы исходного кода:
s.source_files = "ios/**/*.{h,m,mm,swift}"
По умолчанию там нет Swift, поэтому это нужно добавить вручную.
-
Здесь же мы добавляем зависимости для нашего модуля. Помимо стандартных, я добавил Realm и SwiftUIIntrospect:
s.dependency "SwiftUIIntrospect" s.dependency "RealmSwift", '~> 10'
-
Интеграция нативных компонентов
Fabric компонент списка фильмов
Сначала идём в index.tsx
. Здесь нас интересуют две строки, которые экспортируют наш компонент и связанные с ним типы за пределы модуля:
export {default as MovieListView} from './MovieListViewNativeComponent'; export * from './MovieListViewNativeComponent';
Конфигурация нашего компонента происходит в файле MovieListViewNativeComponent.ts
.
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; import type {ViewProps} from 'react-native'; import { Double, WithDefault, DirectEventHandler, Int32, } from 'react-native/Libraries/Types/CodegenTypes'; type Movie = { readonly id: Int32; readonly title: string; readonly url: string; readonly movieDescription: string; readonly rating: Double; }; type Genre = { id: Int32; name: string; }; type MovieDetails = { readonly id: Int32; readonly title: string; readonly posterURL: string; readonly overview: string; readonly genres: Genre[]; readonly rating: Double; readonly isFavourite: boolean; }; export type OnMoviePressEventData = { readonly movieID: Int32; }; export type OnMovieAddedToFavorites = OnMoviePressEventData; export type OnMovieRemovedFromFavorites = OnMovieAddedToFavorites; export type OnMovieInteractionCallback = DirectEventHandler<OnMoviePressEventData>; type NetworkStatus = WithDefault<'loading' | 'success' | 'error', 'loading'>; interface NativeProps extends ViewProps { readonly movies: Movie[]; readonly onMoviePress: DirectEventHandler<OnMoviePressEventData>; readonly onMovieAddedToFavorites: DirectEventHandler<OnMovieAddedToFavorites>; readonly onMovieRemovedFromFavorites: DirectEventHandler<OnMovieRemovedFromFavorites>; readonly movieListStatus?: NetworkStatus; readonly movieDetailsStatus?: NetworkStatus; readonly movieDetails?: MovieDetails; readonly onMoreMoviesRequested: DirectEventHandler<null>; } export default codegenNativeComponent<NativeProps>('MovieListView');
Здесь мы должны описать пропсы, которые будет принимать наш компонент, и именно на основе этих типов Codegen будет генерировать нашу нативную часть, что и происходит на последней строке файла.
На этом конфигурация нашего модуля на стороне JS завершена, всё оказалось достаточно просто. Теперь перейдём к нативной части.
Нас интересуют следующие файлы: MovieListView.h
, MovieListView.mm
, MovieListViewManager.mm
.
-
MovieListView.h
— заголовочный файл, определяющий интерфейс для нашего компонента. Мы могли бы добавить здесь методы, которые можем вызвать на вью, но в нашем случае он пуст. Помимо этого здесь мы импортируем файл заголовка react_native_movie_list-Swift.h, который содержит интерфейсы для Swift кода, доступного нам в Objective-C.
// This guard prevents this file from being compiled in the old architecture. #ifdef RCT_NEW_ARCH_ENABLED #import <React/RCTViewComponentView.h> #import <UIKit/UIKit.h> #import "react_native_movie_list-Swift.h" #ifndef MovieListViewNativeComponent_h #define MovieListViewNativeComponent_h NS_ASSUME_NONNULL_BEGIN @interface MovieListView : RCTViewComponentView @end NS_ASSUME_NONNULL_END #endif /* MovieListViewNativeComponent_h */ #endif /* RCT_NEW_ARCH_ENABLED */
-
MovieListViewManager.mm
— это менеджер нашего компонента, React Native использует его во время выполнения, чтобы зарегистрировать модуль, доступный в JS. Самым важным здесь является вызов методаRCT_EXPORT_MODULE
, который и регистрирует наш модуль.
#import <React/RCTViewManager.h> #import <React/RCTUIManager.h> #import "RCTBridge.h" @interface MovieListViewManager : RCTViewManager @end @implementation MovieListViewManager RCT_EXPORT_MODULE(MovieListView) @end
-
MovieListView.mm
— файл реализации нашего компонента, здесь происходит основная работа по созданию компонента. Сам файл достаточно объёмный и содержит много вспомогательного кода, поэтому я затрону лишь основные методы, которые отвечают за интеграцию.
#import "MovieListView.h" #import <react/renderer/components/RNMovieList/ComponentDescriptors.h> #import <react/renderer/components/RNMovieList/EventEmitters.h> #import <react/renderer/components/RNMovieList/Props.h> #import <react/renderer/components/RNMovieList/RCTComponentViewHelpers.h> #import "RCTFabricComponentsPlugins.h" #import "React/RCTConversions.h" using namespace facebook::react; @interface MovieListView () <RCTMovieListViewViewProtocol> @end @implementation MovieListView { MovieListViewController *_movieListViewController; UIView *_view; } + (ComponentDescriptorProvider)componentDescriptorProvider { return concreteComponentDescriptorProvider<MovieListViewComponentDescriptor>(); } - (instancetype)initWithFrame:(CGRect)frame { self = [super initWithFrame:frame]; if (self) { static const auto defaultProps = std::make_shared<const MovieListViewProps>(); _props = defaultProps; _movieListViewController = [MovieListViewController createViewController]; } return self; } - (void)didMoveToWindow { [super didMoveToWindow]; if (self.window) { [self setupView]; } } - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps { const auto &oldViewProps = *std::static_pointer_cast<MovieListViewProps const>(_props); const auto &newViewProps = *std::static_pointer_cast<MovieListViewProps const>(props); [self updateMovieListAndStatusIfNeeded:oldViewProps newProps:newViewProps]; [self updateMovieDetailsStatusAndMovieDetilsIfNeeded:oldViewProps newProps:newViewProps]; [self setupEventHandlers]; [super updateProps:props oldProps:oldProps]; } Class<RCTComponentViewProtocol> MovieListViewCls(void) { return MovieListView.class; }
Сначала отметим, что компонент должен реализовывать протокол RCTMovieListViewViewProtocol, который был сгенерирован с помощью Codegen.
(ComponentDescriptorProvider)componentDescriptorProvider
— метод, который используется Fabric для получения дескриптора, необходимого для создания экземпляра нашего компонента.
Также стоит обратить внимание на объявление переменной экземпляра:
@implementation MovieListView { MovieListViewController *_movieListViewController; }
Этот контроллер отвечает за создание, взаимодействие и обмен данными с нашим SwiftUI вью.
initWithFrame
— метод, объявленный в интерфейсе UIView, который является базовым классом для всех вью‑компонентов в UIKit. Он инициализирует новый экземпляр UIView с указанным размером и положением (передаваемым в параметре CGRect frame
. В нашем случае, в методе initWithFrame
происходит не только инициализация вью с заданными размерами, но и создание MovieListViewController
, который управляет SwiftUI компонентом. Помимо этого, здесь мы создаем и устанавливаем пропсы по умолчанию.
didMoveToWindow
— это метод жизненного цикла UIView. Он вызывается, когда наше вью добавляется в иерархию, прикреплённую к окну, когда удаляется из него и когда перемещается в другое окно. Когда вью удаляется, то self.window
будет равен nil
. Также данный метод вызывает setupView
, который в свою очередь устанавливает constraints для вью, содержащего наш SwiftUI компонент. Также нам важно добавить *_movieListViewController*
в иерархию вью контроллеров, так как из нашего SwiftUI компонента мы можем перейти на новый экран в виде модального окна (Sheet), в котором можно увидеть больше деталей о выбранном фильме.
updateProps
— это метод, который вызывается Fabric каждый раз, когда в JavaScript изменяется любой из пропсов. Этот метод обеспечивает синхронизацию состояния между JavaScript и нативным кодом, передавая обновленные значения свойств в нативную часть компонента. Здесь переданные параметры приводятся к нужному типу, соответствующему ожидаемым пропсам компонента (в нашем случае это MovieListViewProps). Затем эти параметры используются для обновления нативного компонента при необходимости. Важно отметить, что метод суперкласса [super updateProps]
должен быть вызван в самом конце метода updateProps
. Если этот вызов сделать раньше или не сделать вовсе, структуры props
и oldProps
будут содержать одни и те же значения, что лишит возможности сравнить старые и новые значения свойств. Помимо этого здесь вызывается метод setupEventHandlers
, который отвечает за создание коллбеков, которые позже передаются в SwiftUI компонент.
Разберём один из них:
- (void)onMovieAddedToFavorites:(NSInteger)movieId { if (_eventEmitter != nullptr) { auto emitter = std::dynamic_pointer_cast<const facebook::react::MovieListViewEventEmitter>(_eventEmitter); if (emitter) { emitter->onMovieAddedToFavorites(facebook::react::MovieListViewEventEmitter::OnMovieAddedToFavorites{static_cast<int>(movieId)}); } } }
Здесь происходит отправка события в JS, когда фильм добавляется в избранное. Сначала проверяется, существует ли объект _eventEmitter
, который отвечает за отправку событий. Затем выполняется приведение _eventEmitter
к типу MovieListViewEventEmitter, который был сгенерирован Codegen на основе типов, о которых мы говорили в начале секции. Если приведение успешно, создается событие с идентификатором добавленного фильма, которое отправляется в React Native через вызов метода onMovieAddedToFavorites
. Если всё прошло успешно, то будет вызван нужный коллбек на стороне JS.
MovieListViewCls
— это статический метод, используемый для получения правильного экземпляра класса MovieListView
во время выполнения, что позволяет React Native корректно идентифицировать и рендерить этот нативный компонент.
Это были основные моменты, касающиеся интеграции Fabric компонента. Реализацию самого нативного компонента мы рассматривать не будем.
Модуль работы с базой данных Realm
Далее рассмотрим основные моменты интеграции Turbo Module на примере нашего модуля работы с базой данных.
Здесь всё также начинается с описания нашего модуля на TypeScript, чтобы Codegen смог сгенерировать нативные интерфейсы для нас.
import type {TurboModule} from 'react-native'; import {TurboModuleRegistry} from 'react-native'; export interface FavouriteMovie { id: number; url: string; title: string; rating: string; } export interface Spec extends TurboModule { getFavouriteMovies(): FavouriteMovie[]; addFavouriteMovie(movie: FavouriteMovie): Promise<FavouriteMovie[]>; removeFavouriteMovie(movieId: number): Promise<FavouriteMovie[]>; removeAllFavouriteMovies(): Promise<void>; } export default TurboModuleRegistry.getEnforcing<Spec>('FavouriteMoviesStorage');
Сначала мы должны создать интерфейс для нашего модуля, который должен наследоваться от интерфейса TurboModule и называться Spec. Здесь мы описываем 4 метода, которые мы хотим реализовать. Примечательно, что метод getFavouriteMovies
является синхронным. Это было возможно и в старой архитектуре, но имело свои недостатки и было не рекомендовано для использования.
В конце мы вызываем
TurboModuleRegistry.getEnforcing<Spec>('FavouriteMoviesStorage')
Мы делаем это, для того чтобы получить нативный модуль FavouriteMoviesStorage, если он доступен. И на этом спецификация модуля окончена.
Далее разберёмся с тем, что у нас происходит в нативной части.
Здесь нас интересуют два ключевых для интеграции файла: FavouriteMoviesStorage.h и FavouriteMoviesStorage.mm.
-
FavouriteMoviesStorage.h
— здесь мы объявляем интерфейс, который наследуется от NSObject и реализует протокол NativeFavouriteMoviesStorageSpec, сгенерированный для нас с помощью Codegen. Если новая архитектура не включена, то данный код скомпилирован не будет.
#ifdef RCT_NEW_ARCH_ENABLED #import "RNMovieList/RNMovieList.h" #import "react_native_movie_list-Swift.h" @interface FavouriteMoviesStorage : NSObject <NativeFavouriteMoviesStorageSpec> @end #endif
-
FavouriteMoviesStorage.mm
— именно в этом файле происходит реализация всей логики нашего модуля базы данных.
#import "FavouriteMoviesStorage.h" #import "react_native_movie_list-Swift.h" @implementation FavouriteMoviesStorage RCT_EXPORT_MODULE() - (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:(const facebook::react::ObjCTurboModule::InitParams &)params { return std::make_shared<facebook::react::NativeFavouriteMoviesStorageSpecJSI>(params); } ...... @end
Самым важным здесь является вызов RCT_EXPORT_MODULE()
, который делает наш модуль доступным на стороне JS.
Метод getTurboModule
получает экземпляр нашего Turbo Module, чтобы его методы могли вызываться со стороны JS. Этот метод определён и обязателен в файле FavouriteMoviesStorageSpec.h, который был сгенерирован ранее с помощью Codegen.
Далее рассмотрим примеры реализации методов работы с базой данных.
- (NSArray<NSDictionary *> *)getFavouriteMovies { NSArray *movies = [[FavouriteMoviesManager shared] fetchAllFavouriteMoviesAsList]; NSMutableArray *result = [NSMutableArray array]; for (IntermediateFavouriteMovie *movie in movies) { [result addObject:[self dictionaryFromFavouriteMovie:movie]]; } return result; }
Это синхронный метод. Он ничего не принимает и возвращает нам массив фильмов. Он вызывает метод fetchAllFavouriteMoviesAsList
, после чего конвертирует данные в ожидаемый формат и возвращает их. Реализацию FavouriteMoviesManager мы рассматривать не будем, но там нет ничего примечательного, просто обращение к Realm и получение списка фильмов.
Теперь рассмотрим метод для удаления всех фильмов из избранного — removeAllFavouriteMovies
.
- (void)removeAllFavouriteMovies:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { [[FavouriteMoviesManager shared] removeAllFavouriteMoviesOnSuccess:^{ resolve(@YES); } onError:^(NSError * _Nonnull error) { reject(@"remove_all_favourite_movies_error", error.localizedDescription, error); }]; }
Этот метод принимает два параметра: resolve
и reject
, так как в спецификации мы указали, что данный метод возвращает Promise. Когда вызывается removeAllFavouriteMovies
, он передает два блока — onSuccess
и onError
— в метод removeAllFavouriteMoviesOnSuccess
класса FavouriteMoviesManager. Если операция удаления всех избранных фильмов проходит успешно, вызывается блок onSuccess
, который активирует resolve
с параметром @YES
, завершая промис успешно. Если же происходит ошибка, вызывается блок onError
, который активирует reject
с описанием ошибки, что резолвит промис с ошибкой.
Остальные методы работают по схожему принципу, поэтому рассматривать их подробно смысла нет, так как ничего нового мы там не увидим.
Это основные моменты, касающиеся интеграции нативного модуля базы данных с использованием Turbo Modules в новой архитектуре React Native. Как я упоминал ранее, мы не будем углубляться в детали нативной реализации, поскольку основная цель статьи — показать процесс интеграции.
Итоги
Использование новой архитектуры для реализации нативных модулей, как показано на примере этого приложения, — это вполне выполнимая задача. Конечно, потребуется некоторое время, чтобы привыкнуть к синтаксису C++, разобраться в нюансах сборки и особенностях работы, особенно если у вас не было опыта с этим языком. Однако эти усилия оправданы. Новая архитектура предлагает множество преимуществ, особенно при передаче больших объёмов данных между JavaScript и нативным кодом. С турбомодулями мы можем использовать синхронные методы, например, для доступа к данным. Кроме того, новая архитектура позволяет эффективно применять нативные UI‑компоненты. Например, в моём случае список фильмов на SwiftUI работал гораздо лучше «из коробки», чем FlatList, встроенный в RN. Даже при том, что моя реализация далека от оптимальной, так как происходит достаточно много копирования и создания новых объектов, для того, чтобы конвертировать данные для работы со SwiftUI. Мы могли бы использовать UIKit и UITableView, что могло бы решить некоторые из проблем. Но это выходит за рамки данной статьи
Это всё, чем я хотел поделиться. Надеюсь, данная статья была вам полезна. Спасибо за внимание!
Ссылка на репозиторий: https://github.com/tikhonNikita/movieApp
ссылка на оригинал статьи https://habr.com/ru/articles/847768/
Добавить комментарий