Как стать автором
Обновить
СберМаркет
Кодим будущее доставки товаров

Как создавать нативные компоненты и модули в React Native с помощью Swift

Блог компании СберМаркет Разработка под iOS *Разработка мобильных приложений *


Привет! Меня зовут Георгий Мишин, я мобильный разработчик в СберМаркете. Хочу рассказать, как подключить Swift в React Native-проект. В рамках статьи создадим простенький нативный модуль и компонент, обсудим layout в React Native-приложении и поговорим, что же происходит в shadow thread.


Это текстовая версия моего выступления на Android Meetup | СберМаркет Tech.


Как использовать Swift в React Native-приложении


Для того чтобы Swift заработал в React Native-проекте, нужно создать bridge-файл. XCode предложит сделать это автоматически при создании первого swift-файла.


При разработке каждый экспортируемый в JavaScript элемент должен быть помечен атрибутом objc, чтобы его можно было использовать в Objective C runtime.


Из чего состоит нативный модуль


Нативный модуль состоит из реализации на Swift и файла имплементации на Objective-C. На Swift пишем всю логику, а на Objective-C её имплементируем с помощью макросов из React Native, чтобы она подхватилась в JS.


import Foundation
import React

@objc(SimpleModule)
class SimpleModule: NSObject (
  @objc var bridge: RCTBridge!

  @objc func log() {
    print("Called from native")
  }

  @objc static func requiresMainQueueSetup() -> Bool {
    return false
  }
}

#import <Foundation/Foundation.h>
#import <React/RCTBridgeModule.h>

@interface RCT_EXTERN_MODULE(SimpleModule, NSObject)
  RCT_EXTERN_METHOD(log)
@end

Простой пример нативного модуля


Каждый идентификатор должен быть помечен атрибутом objc. Это класс, который будет экземпляром модуля и метод, который можно будет вызывать из JS. В файле имплементации указываем, какой модуль описываем, от какого класса наследуется и что он будет передавать.


Можно обратить внимание на статический метод requiresMainQueueSetup. Он возвращает булевое значение, которое идентифицирует, где должен инициализироваться метод. Если возвращается «true», метод инициализируется до JavaScript, если «false» — во время или после.


Документация говорит, что, переопределяя метод constantsToExport (для передачи каких-либо констант из React Native в JS), следует также определить requiresMainQueueSetup. Нужно вернуть «true», так как ваши определенные константы могут быть использованы в глобальной области, но разработчику виднее.


Итак, есть нативный модуль с одним методом log. Чтобы использовать его на стороне React Native, импортируем из React Native объект NativeModules. В NativeModules, если все сделано правильно, будет объект с нашим модулем — название будет то, которое мы указали в RCTExternModule. Дальше можно обращаться ко всем сущностям этого модуля.


import React from 'react';
import {View, Button, NativeModules} from 'react-native';

const {SimpleModule} = NativeModules;

const App = () => (
  <View style={{flex: 1, justifyContent: 'center', backgroundColor: 'gray'}}>
    <Button onPress={() => SimpleModule.log()} title={'Execute'} />
  </View>
);

export default App;

Передача аргументов в нативный модуль


Механизм передачи аргументов стандартный: у метода есть сигнатура, которая включает в себя различные входные и выходные параметры:


@objc(SimpleModule)
class SimpleModule: NSObject {
  @objc var bridge: RCTBridge!

  @objc func log(_ contentToLog: NSString) {
    print(contentToLog)
  }

  @objc static func requiresMainQueueSetup() -> Bool {
    return false
  }
}

Важно учесть, что в Swift и Objective-C в сигнатуре метода присутствует ярлык аргумента — префикс к входной переменной. Сейчас там пропуск, рассмотрим этот момент дальше отдельно.


В методы можно передавать сериализуемые объекты и коллбэки. Доступные типы указаны в документации React Native. Можно передать как один параметр, так и несколько — так же, как с любым методом:


@objc(SimpleModule)
class SimpleModule: NSObject {
  @objc var bridge: RCTBridge!

  @objc func log(_ contentToLog: NSString, _ contentToLog1: NSString, _ contentToLog2: NSString) {
    print(contentToLog)
  }

  @objc static func requiresMainQueueSetup() -> Bool {
    return false
  }
}

Теперь на стороне JS можно передавать параметры как обычные аргументы функции. Все типы будут приведены автоматически.


import React from 'react';
import {View, Button, NativeModules} from 'react-native';

const {SimpleModule} = NativeModules;

const App = () => (
  <View style={{flex: 1, justifyContent: 'center'}}>
    <Button onPress={() => SimpleModule.log('1', '2', '3')} title={'Execute'} />
  </View>
);

export default App;

Взаимодействие нативных модулей и JavaScript


Возвращать из метода значение не рекомендуется. Поэтому основной способ взаимодействия JS и Native — коллбэки и промисы. Возвращать значения можно, такой метод будет называться синхронным, но документация не рекомендует использовать такой подход.


Коллбэк — аргумент, который передаём так же, как и остальные аргументы. Его можем вызывать после какого-либо функционала.


import Foundation
import React

@objc(SimpleModule)
class SimpleModule: NSObject {
  @objc var bridge: RCTBridge!

  @objc func logWithCallback(_ contentToLog: NSString, _ cb: RCTResponseSenderBLock) {
    print(contentToLog)
    cb(nil)
  }

  @objc static func requiresMainQueueSetup() -> Bool {
    return true
  }
}

#import <Foundation/Foundation.h>
#import <React/RCTBridgeModule.h>

@interface RCT_EXTERN_MODULE(SimpleModule, NSObject)
RCT_EXTERN_METHOD(logWithCallback :(NSString *)contentToLog
                                  :(RCTResponseSenderBLock *) cb)
@end

В этом случае коллбэк выполнится ровно после того, как информация попадёт на консоль. В качестве имплементации то же самое — просто указываем параметр, который будем использовать.


Чтобы метод возвращал промис (Promise) и его можно было использовать с конструкцией async/await, в качестве двух последних аргументов метода нужно передать RCTPromiseResolveBlock и RCTPromiseRejectBlock. По сути, это те же самые коллбэки.


import Foundation
import React

@objc(SimpleModule)
class SimpleModule: NSObject {
  @objc var bridge: RCTBridge!

  @objc func logWithPromise(_ contentToLog: NSString,
                            resolver resolve: RCTPromiseResolveBlock,
                            rejecter reject: RCTPromiseRejectBlock) {
    print(contentToLog)
    resolve(nil)
  }

  @objc static func requiresMainQueueSetup() -> Bool {
    return true
  }
}

@interface RCT_EXTERN_MODULE(SimpleModule, NSObject)
RCT_EXTERN_METHOD(logWithPromise :(NSString *)contentToLog
                  resolver :(RCTPromiseResolveBlock) resolve
                  rejecter :(RCTPromiseRejectBlock) reject)
@end

Пример нативного модуля


Итак, мы разобрались, что:


  • каждый атрибут модуля должен быть помечен атрибутом objc;
  • в методы можно передавать параметры;
  • с нативными методами можно взаимодействовать через коллбэки и промисы.

В качестве примера разберём простенький модуль выбора изображения из галереи. Это будет обычный PHImagePicker, который открывается из React Native. Он позволит выбрать одну фотографию, передать её по ссылке в файловой системе конкретному JS и отобразить в приложении. Сначала опишем, как это будет выглядеть на стороне JavaScript.


import React from 'react';
import {View, Button, NativeModules, Image} from 'react-native';

const {ImagePicker} = NativeModules;

const App = () => {
  const [image, setImage] = React.useState(null);

  const selectImage = React.useCallback(() => {
    ImagePicker.selectPhoto()
      .then(newImage => {
        setImage(newImage);
      })
      .catch(ex => console.warn(ex));
  }, []);

Есть нативный модуль ImagePicker с одним методом — selectPhoto. Этот метод асинхронный и качестве resolve-параметра будет возвращать ссылку на фотографию, который мы сохраним в temp directory.


return (
  <View style={{flex: 1, justifyContent: 'center', alignItems: 'center'}}>
    <Button onPress={selectImage} title={'Выбрать фото'} />
    <Image
      key={image}
      source={{uri: image}}
      style={{width: '100%', aspectRatio: 1}}
    />
  </View>
);

В UI будет рисоваться кнопка, которая откроет пикер и само изображение, если оно будет в стейте:


#import <Foundation/Foundation.h>
#import <React/RCTBridgeModule.h>

@interface RCT_EXTERN_MODULE(ImagePicker, NSObject)
RCT_EXTERN_METHOD(selectPhoto :(RCTPromiseResolveBlock) resolve
                  rejecter :(RCTPromiseRejectBlock) reject)
@end

Сам нативный модуль выглядит следующим образом:


  @objc func selectPhoto(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
    self.resolve = resolve
    self.reject = reject

    guard let rootControlller = RCTPresentedViewController() else {
        return
    }
    var config = PHPickerConfiguration()
    config.selectionLimit = 1
    config.filter = .images
    let pickerController = PHPickerViewController(configuration: config)
    pickerController.delegate = self

    rootControlller.present(pickerController, animated: true, completion: nil)
  }

  @objc static func requiresMainQueueSetup() -> Bool {
    return true
  }
}

Нативный метод selectPhoto принимает resolve и reject. Тут можно обратить внимание на @escaping. Это означает, что передаваемые коллбэки могут «убежать» — вызваться не сразу в теле метода, а позже, либо не вызваться вообще. Подробнее можно прочитать в документации по Swift 5.7.


Теперь мы сохраняем resolver и rejecter, чтобы вызвать в момент выбора фото. Получаем rootController — основной UIViewController, в котором «рисуется» всё React Native приложение. Создаём и настраиваем конфиг для PHPickerViewController и инициализируем его. А в конце устанавливаем delegate в pickerController (для этого модуль реализует протокол ФPHPickerViewControllerDelegate) и открываем его поверх основного.


Реализованный протокол предоставляет нам метод func picker (PHPickerViewController, didFinishPicking: [PHPickerResult]), который вызовется с результатом, так как мы определили делегат. Далее в методе prepare мы обрабатываем этот результат и в случае успеха вызываем resolve (или reject в случае какой-либо ошибки).


func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
  picker.dismiss(animated: true, completion: nil)

  guard let resolve = resolve, let reject = reject else {
    return
  }

  if results.isEmpty {
    resolve(nil)
    return
  }

  prepare(results.first!) { url in
    resolve(url)
  } _: { code, message, error in
    reject(code.rawValue, message, error)
  }
}

private func prepare(_ result: PHPickerResult, _ success: @escaping (String) -> Void, _ failure: @escaping (Code, String, NSError?) -> Void) {
  let itemProvider = result.itemProvider

  let typeId = itemProvider.registeredTypeIdentifiers.last(where: {
    return itemProvider.hasRepresentationConforming(toTypeIdentifier: $0, fileOptions: .init())
  })

  guard let identifier = typeId else {
    failure(Code.NotJPEG, "Can not represent selected media", nil)
    return
  }

  itemProvider.loadDataRepresentation(forTypeIdentifier: identifier) { data, error in
    let error = ErrorPointer(nilLiteral: ())

  guard let tmpFile = RCTTempFilePath("jpg", error), let inputData = data, let uiImage = UIImage(data: inputData) else {
    failure(Code.TmpCreationError, "Can not create intermediate file", error?.pointee)
    return
  }

  let url = URL(fileURLWithPath: tmpFile)

  do {
    try uiImage.jpegData(compressionQuality: 1)?.write(to: url)
  } catch {
    failure(Code.TmpWriteError, "Can not write to intermediate file", nil)
    return
  }

|||


Попробуем запустить. Если нажать на единственную кнопку «Выбрать фото», то получим ошибку. Она означает, что мы попытались вызвать функционал, который не должен вызываться в этом потоке. Нам нужно понимать, где исполняется наш метод.


В данном случае мы используем компоненты UIKit (UIViewController), поэтому код должен исполняться в MainThread:


@objc func selectPhoto(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
  self.resolve = resolve
  self.reject = reject

  guard let rootControlller = RCTPresentedViewController() else {
    return
  }
  var config = PHPickerConfiguration()
  config.selectionLimit = 1
  config.filter = .images
  let pickerController = PHPickerViewController(configuration: config)
  pickerController.delegate = self

  rootControlller.present(pickerController, animated: true, completion: nil)
}

Чтобы запустить функционал в MainThread, у React Native есть метод RCTExecuteOnMainQueue. В него нужно поместить блок кода, который нужно выполнить в MainThread:


RCTExecuteOnMainQueue {
  guard let rootControlller = RCTPresentedViewController() else {
    return
  }
  var config = PHPickerConfiguration()
  config.selectionLimit = 1
  config.filter = .images
  let pickerController = PHPickerViewController(configuration: config)
  pickerController.delegate = self

  rootControlller.present(pickerController, animated: true, completion: nil)

}

Тогда код будет работать отлично: откроется пикер, мы увидим фото в приложении. По нему можно кликать, и никакой проблемы нет.


Для одного метода всё хорошо. Но если их много, зачем каждый раз писать RCTExecuteOnMainQueue? У React Native есть альтернатива — возможность установить поток исполнения для всего нативного модуля, определив метод methodQueue:


  guard let rootControlller = RCTPresentedViewController() else {
    return
  }
  var config = PHPickerConfiguration()
  config.selectionLimit = 1
  config.filter = .images
  let pickerController = PHPickerViewController(configuration: config)
  pickerController.delegate = self

  rootControlller.present(pickerController, animated: true, completion: nil)
}

@objc func methodQueue() -> DispatchQueue {
  return .main
}

Этот метод должен быть в инстансе нативного модуля и возвращать значение DispatchQueue. Теперь все наши нативные модули будут выполняться строго MainQueue.


Структура нативного компонента и Shadow thread


Теперь перейдём к реализации и архитектуре нативных компонентов. Сначала давайте посмотрим на архитектуру React Native-приложения:



Есть бридж, который гоняет JSON из JS в native и обратно. И Shadow thread, который строит весь layout — размеры и позиционирование относительно других элементов. Тут React Native о нас позаботился: не нужно отдельно устанавливать layout-параметры (высота, ширина, flex-свойства, paddings и так далее). Просто пишем компонент, описываем идеальные условия — и всё работает. Но такая схема вызывает некоторые сложности.


Нативный компонент в первую очередь состоит из ViewManager — фабрики, которая при необходимости инициализирует UIView и ShadowView. По сути, ViewManager — тот же нативный модуль, но с другим базовым классом.


Напишем простой компонент без ShadowView


Давайте напишем простой лейбл и посмотрим, как это всё будет работать и какие проблемы могут возникнуть при разработке своего нативного компонента.


import Foundation

class SimpleTextViewManager: RCTViewManager {
  override static func requiresMainQueueSetup() -> Bool {
    return true
  }

  override func view() -> UIView! {
    return SimpeTextView()
  }
}

#import <React/RCTViewManager.h>

@interface RCT_EXTERN_MODULE(SimpleTextViewManager, RCTViewManager)
@end

import Foundation

class SimpeTextView : UILabel {

}

Сначала опишем класс SimpleTextViewManager и переопределим у него метод view, который должен возвращать UIView (из UIKit). Этот UIView и будет отображаться из RN. Далее создадим класс SimpleTextView, который будет наследоваться от UILabel (а он, в свою очередь, от UIView) и вернем его экземпляр из метода view() в SimpleViewManager.


Добавим в SimpleTextView свойство content, которое будет устанавливать текст через React props. Такие параметры, как font, alignment, numberOfLines, захардкодим, но их тоже можно будет добавить.


Чтобы свойство можно было контролировать через React props, как и с нативными модулями, каждое свойство в UIView нужно будет помечать атрибутом objc и зарегистрировать в файле имплементации UIManager.


import Foundation

class SimpleTextView: UILabel {
  override init(frame: CGRect) {
    super.init(frame: frame)

    setupView()
  }

  required init?(coder: NSCoder) {
    super.init(coder: coder)

    setupView()
  }

  @objc var content: String = "" {
    didSet {
      self.text = content
    }
  }

  func setupView() {
    font = .systemFont(ofSize: 14)
    textAlignment = .center
    numberOfLines = 0
  }
}

#import <React/RCTViewManager.h>

@interface RCT_EXTERN_MODULE(SimpleTextViewManager, RCTViewManager)
  RCT_EXPORT_VIEW_PROPERTY(content, NSString)
@end

Переходим на сторону React Native:


import React from 'react';
import {View, requireNativeComponent} from 'react-native';

const SimpleTextView = requireNativeComponent('SimpleTextView');

const App = () => (
  <View style={{flex: 1, justifyContent: 'center'}}>
    <SimpleTextView style={{flex:1}} content={'Hello world'} />
  </View>
);

export default App;

Чтобы подключить простой нативный компонент, у React Native существует функция requireNativeComponent. Она принимает в себя название компонента — в нашем случае это SimpleTextView.


Получается вьюшка, у которой высота и ширина максимальные, а весь контент располагается по центру. И нативная вьюшка, которая растягивается по всему родительскому компоненту. Контент обычный, «Hello world». Давайте посмотрим, как это будет выглядеть на экране:



В инспекторе видим, что вьюшка занимает всё свободное место. Это логично: мы указали ей flex = 1. Но хотим ли мы, чтобы лейбл так себя вёл? Нет. А если уберём параметр flex, на экране просто ничего не будет, потому что Yoga посчитает высоту и ширину компонента, равную 0.


Решить проблему, описанную выше, нам поможет ShadowView. Это класс, который содержит все необходимые свойства для Yoga engine и запускает функционал расчёта всего Layout. Задавать ему свойства можно через props компонента.


Для решения задачи нам нужно создать класс, наследуемый от ShadowView, в который будем передавать параметры для расчёта layout. ShadowView — простой класс, у которого уже есть свойства для подсчёта layout: flex, width, height и другие.


import React

class SimpleTextShadowView: RCTShadowView {
  @objc var content: String = ""
}

Чтобы высота и ширина нашего лейбла считались в зависимости от передаваемых туда параметров, нам пока нужно только свойство content.


Наследуется он от класса RCTShadowView и принимает в себя только параметр content, по дефолту пустой. Теперь у ShadowView достаточно параметров, чтобы рассчитывать layout в зависимости от входящих props.


Класс ShadowView содержит свойство yogaNode, которое и участвует в расчёте всего layout. Ему можно задавать различные свойства типа flex, flexShrink, flexBasis, height и другие.


Но что делать, если высота и ширина контента неизвестна и должна зависеть от какого-либо параметра? Для этого в Yoga можно задать различные функции, например measureFunc. Она принимает YogaNode вместе с минимальной и максимальной допустимой высотой и шириной. А возвращает YGSize — итоговую высоту и ширину. В нашем случае ShadowView будет выглядеть следующим образом:


class SimpleTextShadowView: RCTShadowView {
  static let measure: YGMeasureFunc = {node, width, widthNode, height, heightNode in
    guard let context = YGNodeGetContext(node) else {
      return YGSize(width: 0, height: 0)
    }

    let instance = Unmanaged<SimpleTextShadowView>.fromOpaque(context).takeRetainedValue()
    let height = getHeight(
      for: NSAttributedString(string: instance.content),
      font: .systemFont(ofSize: 14),
      width: width)

    return YGSize(width: width, height: height)

  }

  @objc var content: String = ""

  override init() {
    super.init()

    YGNodeSetMeasureFunc(self.yogaNode!, SimpleTextShadowView.measure)
    YGNodeSetContext(self.yogaNode!, Unmanaged.passRetained(self).toOpaque())
  }

  override func layout(with layoutMetrics: RCTLayoutMetrics, layoutContext: RCTLayoutContext) {
    super.layout(with: layoutMetrics, layoutContext: layoutContext)
  }
}

Здесь мы:


  1. Создали класс, который наследуется от RCTShadowView, — базовый класс для ShadowView.
  2. Для yogaNode установили функцию measure.
  3. Для yogaNode установили context — ссылку на объект нашего SimpleTextShadowView, чтобы в measureFunc получить содержимое значения content.

Теперь давайте посмотрим на нашу функцию measure. В ней мы:


  1. Получаем ссылку на наш объект, который мы запаковали в конструкторе. Это нужно, чтобы получить свойство content.
  2. Рассчитываем высоту для нашего текста относительно максимально допустимой ширины. Функцию getHeight я взял из первого запроса в Google, выглядит она так:
    func getHeight(for attributedString: NSAttributedString, font: UIFont, width: Float) -> Float {
      let textStorage = NSTextStorage(attributedString: attributedString)
      let textContainter = NSTextContainer(size: CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude))
      let layoutManager = NSLayoutManager()
      layoutManager.addTextContainer(textContainter)
      textStorage.addLayoutManager(layoutManager)
      textStorage.addAttribute(NSAttributedString.Key.font, value: font, range: NSMakeRange(0, textStorage.length))
      textContainter.maximumNumberOfLines = 0
      textContainter.lineFragmentPadding = 0.0
      layoutManager.glyphRange(for: textContainter)
      return Float(layoutManager.usedRect(for: textContainter).size.height)
    }

Давайте теперь запустим пример, убрав все style props в нашем компоненте, и посмотрим в layout-инспекторе, что получилось:



Высота получилась 51 поинт, а ширина — максимальная родительская. Можно добавить пэддинги у родителя, и при запуске они сработают.


Вот и вся история — можно брать и применять на практике. Если остались вопросы или появились какие-то мысли, приходите в комментарии, пообщаемся.


Мы завели соцсети с новостями и анонсами Tech-команды. Если хотите узнать, что под капотом высоконагруженного e-commerce, следите за нами там, где вам удобнее всего: Telegram, VK.

Теги:
Хабы:
Всего голосов 3: ↑3 и ↓0 +3
Просмотры 949
Комментарии Комментарии 1

Информация

Дата основания
Местоположение
Россия
Сайт
sbermarket.ru
Численность
501–1 000 человек
Дата регистрации
Представитель
Sbermarket