Как использовать все возможности мобильной ОС в React Native

На рынке есть несколько кроссплатформенных решений: Cordova, Xamarin, React Native и другие, менее известные. Многие мобильные разработчики считают, что кроссплатформенные решения никогда не позволят делать то, что могут нативные приложения.


В статье я развенчаю этот миф и расскажу о механизме в React Native, который позволяет сделать все, на что способно нативное приложение. Этот механизм — нативные модули. Под катом — подробное описание, как создавать нативные модули для Android и iOS.


image


Нативные модули в кроссплатформенной разработке под мобильники помогают сделать несколько вещей:


  • Предоставить доступ к возможностям платформы, почитать из контент-провайдеров на Android или адресную книгу на iOS
  • Обернуть стороннюю библиотеку для вызова в js
  • Обернуть уже существующий код при добавлении в приложение частей на React Native
  • Реализовать части, критические к производительности (н-р шифрование)


Примерная схема приложения на React Native


В операционной системе запущено нативное приложение. В нем на низком уровне работают рантайм React Native и код нативных модулей, созданных разработчиком приложения (или автором библиотек для React Native). Выше уровнем работает React Native Bridge — промежуточное звено между нативным кодом и js. Сам js исполняется внутри JS VM, чью роль исполняет JavaScriptCore. На iOS она предоставляется системой, на Android же приложение тащит ее в виде библиотеки.


image


Пишем нативный модуль


Под 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();
  }

}


image


Теперь реализуем 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']
    );

};


Работает! Но постойте, хочется же знать, все ли в порядке, удалось ли записать то, что мы хотели записать. Как насчет возвращаемых значений?


image


А вот возвращаемых значений у функций из нативных модулей и нет. Придется выкручиваться, передавая функцию:


@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.


image


Создание нативного модуля в 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

© Habrahabr.ru