Как использовать все возможности мобильной ОС в React Native
На рынке есть несколько кроссплатформенных решений: Cordova, Xamarin, React Native и другие, менее известные. Многие мобильные разработчики считают, что кроссплатформенные решения никогда не позволят делать то, что могут нативные приложения.
В статье я развенчаю этот миф и расскажу о механизме в React Native, который позволяет сделать все, на что способно нативное приложение. Этот механизм — нативные модули. Под катом — подробное описание, как создавать нативные модули для Android и iOS.
Нативные модули в кроссплатформенной разработке под мобильники помогают сделать несколько вещей:
- Предоставить доступ к возможностям платформы, почитать из контент-провайдеров на Android или адресную книгу на iOS
- Обернуть стороннюю библиотеку для вызова в js
- Обернуть уже существующий код при добавлении в приложение частей на React Native
- Реализовать части, критические к производительности (н-р шифрование)
Примерная схема приложения на React Native
В операционной системе запущено нативное приложение. В нем на низком уровне работают рантайм React Native и код нативных модулей, созданных разработчиком приложения (или автором библиотек для React Native). Выше уровнем работает React Native Bridge — промежуточное звено между нативным кодом и js. Сам js исполняется внутри JS VM, чью роль исполняет JavaScriptCore. На iOS она предоставляется системой, на Android же приложение тащит ее в виде библиотеки.
Пишем нативный модуль
Под Android
План такой:
- Зарегистрировать пакет в ReactNativeHost
- Создать пакет
- Создать модуль
- Зарегистрировать модуль в пакете
- Создать метод в модуле
Лирическое отступление 1 — Компоненты Android
Если ваш основной бэкграунд — Android, это отступление можно пропустить. Для разработчиков с основным опытом iOS или React JS нужно узнать, что приложение под Android может содержать следующие компоненты:
- Activity
- BroadcastReceiver
- Service
- ContentProvider
- Application
В этом контексте (кхе-кхе) нас, конечно, интересует только Application. Напомню, что этот компонент и есть обьект самого приложения. Вы можете (а для React Native приложения и должны) реализовать свой класс приложения и реализовать этим классом интерфейс ReactApplication:
package com.facebook.react;
public interface ReactApplication {
ReactNativeHost getReactNativeHost();
}
Нужно это, чтобы ReactNative узнал о тех нативных пакетах, которые вы хотите использовать. Для этого наш Application должен вернуть экземпляр ReactNativeHost, в котором перечислить список пакетов:
class MainApplication : Application(), ReactApplication {
private val mReactNativeHost = object : ReactNativeHost(this) {
override fun getPackages(): List {
return Arrays.asList(
MainReactPackage(),
NativeLoggerPackage()
)
}
override fun getReactNativeHost(): ReactNativeHost {
return mReactNativeHost
}
}
NativeLoggerPackage — тот пакет, который мы будем с вами писать. Он будет только логировать переданные в него значения, а мы сконцентрируемся на процессе создания нативного модуля вместо фактической функциональности.
Зачем нужно, чтобы Application реализовывал ReactApplication? Потому что внутри React Native есть вот такой веселый код:
public class ReactActivityDelegate {
protected ReactNativeHost getReactNativeHost() {
return ((ReactApplication) getPlainActivity().getApplication())
.getReactNativeHost();
}
}
Теперь реализуем NativeLoggerPackage:
class NativeLoggerPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List
{
return Arrays.asList(NativeLoggerModule())
}
override fun createViewManagers(reactContext: ReactApplicationContext): List>
{
return emptyList>()
}
}
Метод createViewManagers мы опустим, нам он в этой статье неважен. А важен метод createNativeModules, который должен вернуть список созданных модулей. Модули — это классы, содержащие методы, которые можно вызвать из js. Давайте создадим NativeLoggerModule:
class NativeLoggerModule : BaseJavaModule() {
override fun getName(): String {
return "NativeLogger"
}
}
Модуль должен наследоваться как минимум от BaseJavaModule, если вам не нужен доступ к контексту Android. Если же в нем есть нужда, необходимо использовать другой базовый класс:
class NativeLoggerModule(context : ReactApplicationContext)
: ReactContextBaseJavaModule(context) {
override fun getName(): String {
return "NativeLogger"
}
}
В любом случае, необходимо определить метод getName (), который вернет имя, под которым ваш модуль будет доступен в js, мы увидим это чуть позже.
Теперь давайте наконец создадим метод для js. Делается это с помощью аннотации ReactMethod:
class NativeLoggerModule : BaseJavaModule() {
override fun getName(): String {
return "NativeLogger"
}
@ReactMethod
fun logTheObject() {
Log.d(name, "Method called”)
}
}
Здесь метод logTheObject становится доступен для вызова из js. Но вряд ли мы хотим просто вызывать методы без параметров, которые ничего не возвращают. Давайте разбираться с аргументами (слева java-типы, справа js):
Boolean → Bool
Integer → Number
Double → Number
Float → Number
String → String
Callback → function
ReadableMap → Object
ReadableArray → Array
Предположим, что в нативный метод мы хотим передать js-обьект. В java будет приходить ReadableMap:
@ReactMethod
fun logTheObject(map: ReadableMap) {
val value = map.getString("key1")
Log.d(name, "key1 = " + value)
}
В случае массива будет передаваться ReadableArray, итерация по которому не составляет проблем:
@ReactMethod
fun logTheArray(array: ReadableArray) {
val size = array.size()
for (index in 0 until size) {
val value = array.getInt(index)
Log.d(name, "array[$index] = $value")
}
}
Впрочем, если вы хотите передать первым аргументом обьект, а вторым массив, то тут тоже без сюрпризов:
@ReactMethod
fun logTheMapAndArray(map: ReadableMap, array: ReadableArray): Boolean {
logTheObject(map)
logTheArray(array)
return true
}
Как же это добро вызывать из javascript? Нет ничего проще. Первым делом нужно импортнуть NativeModules из корневой библиотеки react-native:
import { NativeModules } from 'react-native';
А затем заимпортить наш модуль (помните, мы назвали его NativeLogger?):
import { NativeModules } from 'react-native';
const nativeModule = NativeModules.NativeLogger;
Теперь можно вызывать метод:
import { NativeModules } from 'react-native';
const nativeModule = NativeModules.NativeLogger;
export const log = () => {
nativeModule.logTheMapAndArray(
{ key1: 'value1' },
['1', '2', '3']
);
};
Работает! Но постойте, хочется же знать, все ли в порядке, удалось ли записать то, что мы хотели записать. Как насчет возвращаемых значений?
А вот возвращаемых значений у функций из нативных модулей и нет. Придется выкручиваться, передавая функцию:
@ReactMethod
fun logWithCallback(map: ReadableMap, array: ReadableArray, callback: Callback) {
logTheObject(map)
logTheArray(array)
callback.invoke("Logged")
}
В нативный код будет приходить интерфейс Callback с единственным методом invoke (Object… args). Со стороны js — это просто функция:
import { NativeModules } from 'react-native';
const nativeModule = NativeModules.NativeLogger;
export const log = () => {
const result = nativeModule.logWithCallback(
{ key1: 'value1' },
[1, 2, 3],
(message) => { console.log(`[NativeLogger] message = ${message}`) }
);
};
К сожалению, в compile-time нет инструментов сверить параметры коллбэка из нативного кода и функции в js, будьте внимательны.
К счастью, можно пользоваться механизмом промисов, которые в нативном коде поддерживаются интерфейсом Promise:
@ReactMethod
fun logAsync(value: String, promise: Promise) {
Log.d(name, "Logging value: " + value)
promise.resolve("Promise done")
}
Тогда вызывать этот код можно используя async/await:
import { NativeModules } from 'react-native';
const nativeModule = NativeModules.NativeLogger;
export const log = async () => {
const result = await nativeModule.logAsync('Logged value');
console.log(`[NativeModule] results = ${result}`);
};
На этом работа по выставлению нативного метода в js в Android завершена. Смотрим на iOS.
Создание нативного модуля в iOS
Первым делом создаем модуль NativeLogger.h:
#import
#import
@interface NativeLogger : NSObject
@end
и его реализацию NativeLogger.m:
#import
#import "NativeLogger.h"
@implementation NativeLogger {
}
RCT_EXPORT_MODULE();
RCT_EXPORT_MODULE — это макрос, который регистрирует наш модуль в ReactNative под именем файла, в котором обьявлен. Если это имя в js для вас не очень подходит, вы можете его поменять:
@implementation NativeLogger {
}
RCT_EXPORT_MODULE(NativeLogger);
Теперь давайте реализуем методы, которые делали для Android. Для этого нам понадобятся параметры.
string -> (NSString*)
number -> (NSInteger*, float, double, CGFloat*, NSNumber*)
boolean -> (BOOL, NSNumber*)
array -> (NSArray*)
object -> (NSDictionary*)
function -> (RCTResponseSenderBlock)
Для обьявления метода можно использовать макрос RCT_EXPORT_METHOD:
RCT_EXPORT_METHOD(logTheObject:(NSDictionary*) map)
{
NSString *value = map[@"key1"];
NSLog(@"[NativeModule] %@", value);
}
RCT_EXPORT_METHOD(logTheArray:(NSArray*) array)
{
for (id record in array) {
NSLog(@"[NativeModule] %@", record);
}
}
RCT_EXPORT_METHOD(log:(NSDictionary*) map
withArray:(NSArray*)array
andCallback:(RCTResponseSenderBlock)block)
{
NSLog(@"Got the log");
NSArray* events = @[@"Logged"];
block(@[[NSNull null], events]);
}
Самое интересное тут — поддержка промисов. Для этого придется воспользоваться другим макросом RCT_REMAP_METHOD, который первым аргументом принимает имя метода для js, а вторым и последующими — уже сигнатуру метода в objective-c.
Вместо интерфейса тут передаются два аргумента, RCTPromiseResolveBlock для резолва промиса и RCTPromiseRejectBlock для реджекта:
RCT_REMAP_METHOD(logAsync,
logAsyncWith:(NSString*)value
withResolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
{
NSLog(@"[NativeModule] %@", value);
NSArray* events = @[@"Logged"];
resolve(events);
}
На этом все. Механизм передачи событий из нативных модулей в js мы рассмотрим в отдельной статье.
Нюансы
Помните, что основная идея нативных модулей — абстракция операционной системы для кроссплатформенного кода. Это значит, что интерфейс модуля должен быть согласован между Android и iOS. Автоматических средств, как это контролировать я, к сожалению, не знаю.
- Помните, что по отдельности js и нативный код работают быстро. Бриджинг же между ними относительно медленный. Не стоит писать js циклы, в которых вызывать нативный модуль — перенесите цикл в натив.
Полезные ссылки
- Официальный туториал по модулям
- Тул для генерации бойлерплейта при создании модуля
- Задача в гитхабе про производительность React Native Bridge