※ Retty Advent Calendar 2018 22日目の記事です。
昨日は @WooNoo さんの記事で、 GraphQL でフロントに優しい API を作ろう でした。
ちょうど今、Retty Backend開発チームで新しいマイクロサービスの開発で絶賛構築しているBFF層もGraphQLを使っており、またどこかで運用経験も含めて記事が書かれると思います。
はじめに
少し前に、Retty Advent Calendar 2018 8日目の記事でRettyのiOSアプリで ReactNativeを利用したOTAの仕組みについて 紹介があったが、本日その記事の続きとして実際ReactNativeを部分導入した実装の話について書きたいと思います。
フルReactNative vs 部分ReactNative
ReactNativeには興味があるけど、実際NativeアプリをReactNativeフルで実装しようとするとだいぶ不安がありますよね。なぜなら、未だに、ReactNativeのバージョンがまだ v0.57 です。
こちら、去年導入した時に書いた記事 - How to build an "infinite" list in ReactNative、ReactNativeで大きなリストビューを実装する際に、まだNativeのUITableView
とUICollectionView
をリプレースできるコンポーネントが存在します。
あと、アニメーションなどマルチスレード関連の機能も、JSスレッドでどうしても厳しかったりします。
個人的に、ReactNative v1.0 がリリースされる(その日が来るのか?)前に、既存のNativeアプリへの部分導入をお勧めします。
ReactNative部分導入
ReactNative公式のページにも紹介されているように - 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として実装しました。
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
}
}
}
ぱっと見複雑なので、少しコードを解説します。
- UIViewControllerに
rootReactView
というAssociatedObjectを持たせます。 - 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を渡します。
- bundleURL:
- 作ったRCTRootViewをself.viewのsubViewとして追加します。
- self.viewと完全に被るようにLayout Constraintを設定します。
- RCTRootViewを
rootReactView
に代入します。
-
setReactParameter
が2回以降に呼ばれる時に、パラメーターをrootReactView.appProperties
に直接代入します。RCTRootViewのappPropertiesが更新されると、ReactNative moduleのcomponentWillReceiveProps
が呼ばれて、新しいpropertiesを受け取ることができます。(ReactNative側の話については、今回詳しく説明しません。)- ここで1つ注意する必要があるところは、RCTRootViewの
bridge.isLoading
というpropertyがtrueになっていると、moduleがまだローディング中で、新しいpropertiesを受け取ることができないため、bridge.isLoading
がfalseになるまで少し待ってあげる必要があります。
- ここで1つ注意する必要があるところは、RCTRootViewの
UIViewをContainerとしてRCTRootViewを載せる
実は、2つ目の形式として紹介するんですが、仕組みとしてはほぼ同じです。
RCTRootViewを普通のUIViewとしてNativeのUIViewのsubViewとして使うと、何が変わるかというと、
Auto Layoutを利用して、subViewのサイズによて親ビューのサイズも変わるような場合、ReactNative側のコンポーネントのサイズによって、RCTRootViewのサイズを自動的に変わってくれません。いわゆるRCTRootViewのLayoutはAuto Layoutのシステムに自動的にのってくれないことです。
ReactNative側のコンポーネントサイズの変化を検知する必要があるということで実装します。
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の紹介がすでに物足りると思うので、冗長に説明しません。我々のアプリ中のサンプルコードだけ貼っておきます。
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を返します。
#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です。
...
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コンポーネントで簡単に使えます。
...
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でも紹介していますが、ReactNativeがNativeModulesというモジュール提供しています。実際はObjective-Cで利用した場合の説明資料はネット上にたくさんある一方、Swiftで利用した際の資料はほとんど見つかりません。まあ、SwiftのclassをObjective-Cへexportする"簡単"な話だからですかね。
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 ?? "" }))
}
}
#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
- SwiftのNSObjectを継承したclassを作成してObjective-Cへ名前をexportします。
- メソッドを定義して、同じくObjective-Cへexportします。
- ここで
RCTPromiseResolveBlock
とRCTPromiseRejectBlock
2つタイプのパラメーターがあるけど、NativeのメソッドからReactNativeに値を返すためのコールバックです。- RCTPromiseResolveBlock: 正常の値を返す。(あとでReactNative側の受け取り方についても説明があります。)
- RCTPromiseRejectBlock: エラーが発生したら、エラーを返す。
- ここで
- .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からは見えません。)
...
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版がリリースされる日が来るように願います。