iOSアプリにReactNativeを部分導入する方法

※ Retty Advent Calendar 2018 22日目の記事です。
昨日は @WooNoo さんの記事で、 [GraphQL でフロントに優しい API を作ろう](https://qiita.com/WooNoo/items/08e3ba1e19455c4a464a) でした。
ちょうど今、Retty Backend開発チームで新しいマイクロサービスの開発で絶賛構築しているBFF層もGraphQLを使っており、またどこかで運用経験も含めて記事が書かれると思います。

# はじめに
少し前に、Retty Advent Calendar 2018 8日目の記事でRettyのiOSアプリで [ReactNativeを利用したOTAの仕組みについて](https://qiita.com/PONTA-N/items/807f00f515e7b4084721) 紹介があったが、本日その記事の続きとして実際ReactNativeを部分導入した実装の話について書きたいと思います。

# フルReactNative vs 部分ReactNative
ReactNativeには興味があるけど、実際NativeアプリをReactNativeフルで実装しようとするとだいぶ不安がありますよね。なぜなら、未だに、[ReactNativeのバージョン](https://facebook.github.io/react-native/versions)がまだ **v0.57** です。
こちら、去年導入した時に書いた記事 - [How to build an "infinite" list in ReactNative](https://qiita.com/yongyu-li/items/cd64ac5201bbf2230b71)、ReactNativeで大きなリストビューを実装する際に、まだNativeの`UITableView`と`UICollectionView`をリプレースできるコンポーネントが存在します。
あと、アニメーションなどマルチスレード関連の機能も、JSスレッドでどうしても厳しかったりします。
個人的に、ReactNative **v1.0** がリリースされる(その日が来るのか？)前に、既存のNativeアプリへの部分導入をお勧めします。

# ReactNative部分導入
ReactNative公式のページにも紹介されているように - [Integration with Existing Apps](https://facebook.github.io/react-native/docs/integration-with-existing-apps)、Objective-C/Swiftで書かれているiOS NativeアプリにReactNativeを部分導入する方法があります。
RettyのiOSアプリは2017の秋にリニュアルされて、当時Swift 4.0がリリースされて、Swift 4.0を採用して開発を行なっていました。なので、この記事はObjective-Cではなく、NativeアプリをSwiftで書かれた時の話になります。

# SwiftからRCTRootViewを利用する2つの形式
### UIViewControllerのself.viewを丸ごとリプレースする
ReactNatieを導入する最初の頃、我々が考えたのは画面毎にReactNativeを採用するやり方です。いわゆるUIViewControllerのself.viewに一個のReactNativeビュー乗せることです。
実際のUIViewControllerのextensionとして実装しました。

```swift:UIViewController+ReactNative.swift
import UIKit
import React

private var rootReactViewAssociationKey = 0

extension UIViewController {
    var rootReactView: RCTRootView? {
        get {
            return objc_getAssociatedObject(self, &rootReactViewAssociationKey) as? RCTRootView
        }
        set {
            objc_setAssociatedObject(self, &rootReactViewAssociationKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
        }
    }

    func setReactParameter(parameter: [String: Any]) {
        let typeName = NSStringFromClass(type(of: self)).components(separatedBy: ".").last ?? ""
        let typeNameShort = typeName.replacingOccurrences(of: "ViewController", with: "")

        if let rootReactView = self.rootReactView {
            if rootReactView.bridge.isLoading {
                DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1) {
                    self.setReactParameter(parameter: parameter, on: view)
                }
                return
            }

            self.rootReactView.appProperties = parameter
        } else {
            let reactView: RCTRootView = RCTRootView(
                bundleURL: ReactNativeBundleManager.shared.bundleUrl,
                moduleName: typeNameShort,
                initialProperties: parameter as [NSObject: AnyObject],
                launchOptions: nil
            )

            self.view.addSubview(reactView)

            reactView.translatesAutoresizingMaskIntoConstraints = false
            NSLayoutConstraint.activate([
                reactView.topAnchor.constraint(equalTo: self.topLayoutGuide.bottomAnchor),
                reactView.bottomAnchor.constraint(equalTo: self.bottomLayoutGuide.topAnchor),
                reactView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
                reactView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor)
                ])

            self.rootReactView = reactView
        }
    }
}
```
ぱっと見複雑なので、少しコードを解説します。

1. UIViewControllerに`rootReactView`というAssociatedObjectを持たせます。
2. UIViewControllerの`setReactParameter`というメソッドがはじめて呼ばれる時に、`rootReactView`が存在しないため、`RCTRootView`を一個作って、self.viewに載せます。
    * RCTRootView: UIViewの拡張classでReactNativeのJSコンポーネントを読み込んでrenderしてくれます。
    * RCTRootViewのconstructorが `bundleURL` `moduleName` `initialProperties` `launchOptions` 4つのパラメーターがあります。
        * bundleURL: `ReactNativeBundleManager.shared.bundleUrl`を渡していますが、`ReactNativeBundleManager`はOTAの仕組みを実現しているclassで、自動的にReactNativeのjsbundleファイルの場所を返してくれます。(e.g. http://localhost:8080/index.ios.bundle?platform=ios)
        * moduleName: ReactNativeのjsbundleの中にあるmoduleの名前です。ここでUIViewControllerのclassName(コード中の`typeNameShort`)を使って自動的に決めています。(e.g. `HomeViewController`が自動的に`Home`というmoduleをロードします。)
        * initialProperties: ReactNativeのmoduleに渡すpropertiesです。Native → ReactNativeへコミュニケートするメインの手段になります。ここで`setReactParameter`のパラメーターを渡します。
        * launchOptions: 同じくpropertiesを渡したりするオプションです。`initialProperties`を利用しているため、nilを渡します。
    * 作ったRCTRootViewをself.viewのsubViewとして追加します。
    * self.viewと完全に被るようにLayout Constraintを設定します。
    * RCTRootViewを`rootReactView`に代入します。
3. `setReactParameter`が2回以降に呼ばれる時に、パラメーターを`rootReactView.appProperties`に直接代入します。RCTRootViewのappPropertiesが更新されると、ReactNative moduleの`componentWillReceiveProps`が呼ばれて、新しいpropertiesを受け取ることができます。(ReactNative側の話については、今回詳しく説明しません。)
    * ここで1つ注意する必要があるところは、RCTRootViewの`bridge.isLoading`というpropertyがtrueになっていると、moduleがまだローディング中で、新しいpropertiesを受け取ることができないため、`bridge.isLoading`がfalseになるまで少し待ってあげる必要があります。

### UIViewをContainerとしてRCTRootViewを載せる
実は、2つ目の形式として紹介するんですが、仕組みとしてはほぼ同じです。
RCTRootViewを普通のUIViewとしてNativeのUIViewのsubViewとして使うと、何が変わるかというと、
**Auto Layoutを利用して、subViewのサイズによて親ビューのサイズも変わるような場合、ReactNative側のコンポーネントのサイズによって、RCTRootViewのサイズを自動的に変わってくれません。いわゆるRCTRootViewのLayoutはAuto Layoutのシステムに自動的にのってくれないことです。**
ReactNative側のコンポーネントサイズの変化を検知する必要があるということで実装します。

```swift:ReactNativeContainerView.swift
import UIKit
import React

protocol ReactNativeContainerViewDelegate: class {
    func intrinsicInnerViewSizeDidChange(_ view: ReactNativeContainerView)
}

class ReactNativeContainerView: UIView {
    private var rootReactView: RCTRootView!
    weak var delegate: ReactNativeContainerViewDelegate?
    ...
    private func initializeRootReactView(parameter: [String: Any]) {
        let reactView: RCTRootView = RCTRootView(
            bundleURL: ReactNativeBundleManager.shared.bundleUrl,
            moduleName: self.componentName,
            initialProperties: parameter as [NSObject : AnyObject],
            launchOptions: nil
        )
        reactView.sizeFlexibility = .widthAndHeight
        reactView.delegate = self
        reactView.isHidden = true

        self.addSubview(reactView)

        reactView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            reactView.topAnchor.constraint(equalTo: self.topAnchor),
            reactView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
            reactView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
            reactView.trailingAnchor.constraint(equalTo: self.trailingAnchor)
            ])

        self.rootReactView = reactView
    }
    ...
}

extension ReactNativeContainerView: RCTRootViewDelegate {
    func rootViewDidChangeIntrinsicSize(_ rootView: RCTRootView!) {
        DispatchQueue.main.async {
            self.intrinsicInnerViewSize = rootView.intrinsicContentSize
            self.delegate?.intrinsicInnerViewSizeDidChange(self)
            self.rootReactView.isHidden = false
        }
    }
}
```
* ここで一番重要なものは`RCTRootViewDelegate`です。
RCTRootViewDelegateはRCTRootViewのdelegate protocolで、`rootViewDidChangeIntrinsicSize`という唯一のメソッドを持っています。
名前の通り、RCTRootViewの`IntrinsicSize`が変わる時に呼ばれるので、その中で、更新されたサイズをReactNativeContainerViewにお知らせすることができます。
* もう1つ概念として、RCTRootViewが`sizeFlexibility`というpropertyを持っています。このpropertyはRCTRootViewのIntrinsicSizeが変わる時に、rootViewDidChangeIntrinsicSizeが呼ばれるかどうかをコントロールすることができます。代入できる値は以下の4つになっています。
    * `.none`: サイズの変化を知らせない
    * `.width`: 横のサイズ変化だけを知らせる
    * `.height`: 縦のサイズ変化だけを知らせる
    * `.widthAndHeight`: 横、縦両方のサイズ変化を知らせる

    ここで、横、縦両方のサイズ変化を検知したいので.widthAndHeightにします。
* あと、サイズが決まるまでにRCTRootViewが見えると、ビューサイズが変わってる様子が見れてしまうので、`isHidden`propertyを使って、サイズが決まるまでにRCTRootViewを非表示にさせています。

ここまで紹介した2つの方法を利用すれば、Swiftコードから簡単にReactNativeのmoduleを使うことができます。

# ReactNativeからNativeのUIコンポーネントを使う
SwiftコードからRCTRootViewを使ってReactNativeのmoduleを使う方法がありましたが、自然に、ReactNativeからSwiftのUIコンポーネントを利用する方法が欲しくなりますよね。
オフィシャルサイド - [Native UI Components](https://facebook.github.io/react-native/docs/native-components-ios)の紹介がすでに物足りると思うので、冗長に説明しません。我々のアプリ中のサンプルコードだけ貼っておきます。

```swift:FollowButtonManager.swift
import UIKit
import React

@objc(FollowButtonManager)
class FollowButtonManager: RCTViewManager {
    override func view() -> UIView! {
        return FollowButton()
    }
}
```
RCTViewManagerを継承したmanager classを定義してObjective-Cへ名前をexportします。
RCTViewManagerの`view()`メソッドをoverrideし、NativeのUI classのinstanceを返します。

```objc:FollowButtonManagerBridge.m
#import <React/RCTBridgeModule.h>
#import <React/RCTViewManager.h>

@interface RCT_EXTERN_MODULE(FollowButtonManager, RCTViewManager)

RCT_EXPORT_VIEW_PROPERTY(userId, int)
RCT_EXPORT_VIEW_PROPERTY(hasFollow, BOOL)

@end
```
Objective-Cの.mファイルを作成し、ReactNativeへのbridge処理を書きます。

* RCT_EXTERN_MODULE: Objective-Cのmanager classをReactNativeにexportするmacroです。
* RCT_EXPORT_VIEW_PROPERTY: NativeのUI classのpropertiesをReactNativeにexportするmacroです。

```js:FollowButton.js
...
import { requireNativeComponent } from 'react-native';
...

export default function FollowButton(props) {
  return <NativeFollowButton {...props} />;
}

FollowButton.propTypes = {
  userId: PropTypes.number.isRequired,
  hasFollow: PropTypes.bool.isRequired,
  ...View.propTypes,
};

const NativeFollowButton = requireNativeComponent('FollowButton', FollowButton);
```
ReactNativeが提供する`requireNativeComponent`メソッドを使えば、Nativeコンポーネントをimportすることができます。そして、Objective-CでexportしたpropertiesをReactNativeのコンポーネントでも宣言してmappingさせておけば、あとで他のReactNativeコンポーネントで簡単に使えます。

```js:HogeCell.js
...
import FollowButton from './FollowButton';
...

class HogeCell extends React.Component {
  render() {
    ...
      <FollowButton userId={...} hasFollow={...} />
    ...
  }
}
```

Swiftで定義したUIコンポーネントでもとても簡単にReactNativeにexportして使えるでしょう。こうしてNativeとReactNativeで共通のUIパーツを使って、UIを統一することもできます。

# ReactNativeからNativeのメソッドを呼び出す
先ほどでSwiftからRCTRootViewにpropertiesを渡せる話しましたが、逆に方向で、ReactNativeのmoduleからSwift側のメソッドを呼び出す方法も紹介します。

### NativeModules
オフィシャルサイト - [NativeModules](https://facebook.github.io/react-native/docs/native-modules-ios)でも紹介していますが、ReactNativeがNativeModulesというモジュール提供しています。実際はObjective-Cで利用した場合の説明資料はネット上にたくさんある一方、Swiftで利用した際の資料はほとんど見つかりません。まあ、SwiftのclassをObjective-Cへexportする"簡単"な話だからですかね。

```swift:Localization.swift
import Foundation
import React

@objc(Localization)
class Localization: NSObject {
    @objc(localize:withArguments:resolve:reject:)
    func localize(text: String, args: [Any], resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
        resolve(String(format: text.localized(), arguments: args.map { $0 as? CVarArg ?? "" }))
    }
}
```

```objc:LocalizationBridge.m
#import <Foundation/Foundation.h>
#import <React/RCTBridgeModule.h>

@interface RCT_EXTERN_MODULE(Localization, NSObject)

RCT_EXTERN_METHOD(localize:(NSString *)text withArguments:(NSArray *)args resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)

@end
```
1. SwiftのNSObjectを継承したclassを作成してObjective-Cへ名前をexportします。
2. メソッドを定義して、同じくObjective-Cへexportします。
    * ここで`RCTPromiseResolveBlock`と`RCTPromiseRejectBlock`2つタイプのパラメーターがあるけど、NativeのメソッドからReactNativeに値を返すためのコールバックです。
        * RCTPromiseResolveBlock: 正常の値を返す。(あとでReactNative側の受け取り方についても説明があります。)
        * RCTPromiseRejectBlock: エラーが発生したら、エラーを返す。
3. .mファイルを作成して、ReactNativeへのbridge処理を書きます。
    * RCT_EXTERN_MODULE: Objective-CのobjectをReactNativeにexportするmacroです。
    * RCT_EXTERN_METHOD: Objective-C objectのメソッドをReactNativeにexportするmacroです。(exportしなければObjective-Cのobjectがメソッドを持っていてもReactNativeからは見えません。)

```js:localize.js
...
import { NativeModules } from 'react-native';
...

const { Localization } = NativeModules;

export default function localize(text, args) {
    ...
    Localization.localize(text, args)
        .then(localizedText => ...)
        .catch(err => ...);
    ...
}
```
上のコードを見るとわかるが、実際exportされたメソッドが返しているのはJS側のpromiseになっています。

* RCTPromiseResolveBlockに渡す値はthenブロックで受け取る
* RCTPromiseRejectBlockに渡すエラーはcatchブロックで受け取る

ここまでReactNativeからSwiftで定義したメソッドの呼び出し方について説明したが、かなりシンプリではあると思います。

# 最後に
ReactNativeのバージョンはまだv0.57ではあるが、我々今RettyのiOSアプリに導入して使っている分は全然問題なく動いてくれています。現在ReactNativeで作られたコンポーネントがOTAの仕組みによって、日々UI/UXの微調整ができたりして、より良いアプリを作り上げていっています。
そして、ReactNative v1.0版がリリースされる日が来るように願います。