こちらはReact Nativeアドベントカレンダー 19日目の記事になります。
ここ1、2年でReact Nativeによるアプリ開発はますます盛んになっていますが、一方でNativeと組み合わせたとハイブリッドアプリケーション開発はまだ発展途上です。 React Nativeの公式ドキュメントにもIntegrating with existing appという項目がありますが、あっさりと書かれている上に鮮度がお世辞にも高くありません。
しかしながら、FacebookやAirbnbなど大企業がハイブリッドアプリケーションを積極的に導入していることや、Nativeアプリを部分的にリプレイスできる利便性から、今後も採用が増える分野と考えられます。本記事ではハイブリッドアプリを開発した自分の経験から、プロコンや実装の基本についてまとめました。
ハイブリッドアプリケーションの良い点/難しい点
そもそもハイブリッドアプリケーション開発のメリットはなんでしょうか?個人的には次にあげる点がReact Nativeのみのアプリより優れていると考えています。
機能の特徴に応じてNativeとReact Nativeを使い分けができる
例えば単純なリストの表示等であれば、マルチプラットフォームかつビルドの待ち時間ほぼ0で実装できるReact Nativeは圧倒的に速く実装できます。 一方で複雑なUIや、カメラ等プラットフォームにより内部実装が異なる機能を自前で実装する場合はNativeに寄せたほうが最終的には高い品質でリリースができます。Frontend, Native開発者が共同で開発できる
ハイブリッドアプリケーションは最初の設定部分こそNativeの知識が要求されますが、一旦仕組みが整えばReact開発経験のあるFrontendエンジニアも容易に開発に参加できます。 Native寄りの機能はNativeエンジニア、UI寄りの機能はFrontendエンジニアという形で開発体制が組めれば圧倒的スピードで開発できることが期待できます。
一方でハイブリッドアプリケーションの難しい点は、個人的な経験では次のようなものが挙げられます。
よりプラットフォームの知識が求められる
create-react-native-app AwesomeProject
の1行で最低限の開発環境が整うReactNativeアプリケーションと異なり、自前でReactNativeを設定する必要があります。 少なからずAndroid JavaやSwift(と、まさかのObject-C)の実装が求められます。トラッキングやABテストが煩雑になる
これは設計次第でありますが、トラッキングやABテストを行う場合、コア機能をどこに置き、どこで処理するかというのは悩みどころです。 例えばFirebaseでトラッキングする場合、SDKはNativeとReact Native向けがあります。 React Nativeで完結させる場合その分Bundle JSファイルが大きくなりますし、Native側に渡して処理する場合はブリッジを作る必要があるので、Android、iOS両プラットフォームでの開発が必要になります。
ReactNativeのスピード感とNativeの安定感を両立させつつ、開発難易度が少なからず上がる点をどう攻略してくかがハイブリッドアプリのキモですね。
ハイブリッドアプリで考慮すべき点
React Nativeのみのアプリとハイブリッドアプリで大きく異なる点は2つあります。
Nativeをまたがる画面遷移
通常のReact Nativeアプリの場合、ひとつのView上でReactInstanceとReactViewを管理し、全ての画面遷移はReact Native上で発生します。 一方、ハイブリッドアプリの場合、状況に応じてReact Native上で遷移させるか、はたまたNativeのView上で遷移させるかを判断しなくてはなりません。 ReactInstanceのコールバックやライフサイクル管理等も含めて検討が必要になります。React Native - Native間のデータの受け渡し頻度
例えばReact Native起動画面、必要なパラメタ、永続化したい情報などNative間とやりとりは頻繁に発生し、これらをうまく管理することが重要になります。
それでは上記の考慮すべき点を踏まえ、ハイブリッドアプリをどのように実装していくか具体的に見ていきます。
ハイブリッドアプリ実装の基本
今回は例として、以前 ReactNativeで理解しておくと良いReduxとMiddlewareのフローを理解する という記事で作ったChuck Norrisの名言を検索できるアプリChuck Norris Viewerを利用します。今回はAndroidでの実装1を扱います。
前提条件として、React Nativeにおける画面遷移はreact-native-router-fluxを利用します。React Native側の画面遷移の実装を抜粋すると以下になります。
// scenes/index.js const RouterWithRedux = connect()(Router); const Scenes = (props, context) => ( <RouterWithRedux> <Scene key="root"> { map(scenes, (component, name) => ( <Scene key={name} title={name} component={component} /> ))} </Scene> </RouterWithRedux>
これをベースに
Nativeでボタン押下 -> ReactNative画面 -> ReactNative画面へ遷移 -> Nativeに戻る
という次の図のようなフローをもったハイブリッドアプリを作っていきます。
React Nativeの起動
まずはReact Native用のActivityを用意します。AndroidはIntegration with Existing Appsを参考すれば問題ない2です。target SDK等は適宜最新の設定に変えます。
さて、ひとまずReact Nativeの画面が起動できるようになりました。ここで、起動する画面を指定したいので、ReactInstanceManager
の引数にinitialScene
というパラメタを渡してあげます。
// MyReactActivity.java @Override protected void onCreate(Bundle savedInstanceState) { ... reactRootView = findViewById(R.id.rootView); Bundle bundle = new Bundle(); bundle.putString("initialScene", "Search"); // 開きたいComponent名 ... reactRootView.startReactApplication(reactInstanceManager, "ChuckNorrisViewer", bundle); }
React Nativeではこの値はエントリポイントのindex.js
のprops
として受け取ることが可能です。このデータを子ビューコンポーネントで使いたい場合はgetChildContext()
を使います。
// index.js class ChuckNorrisViewer extends Component { getChildContext() { const { initialScene, data } = this.props; if (data && typeof (data) === 'object') { return { data, initialScene }; } } render() { return ( <Provider store={store}> <App/> </Provider> ); } }
こうすることで、scene/index.js
で受け取れるようになります。以下のようにinitialScene
とcomponentのname
が一致した場合、initial
をtrue
にしてあげれば、その画面が起動するようになるわけです。
// scenes/index.js const initialSceneProps = { initial: true }; // 起動する画面 const defaultSceneProps = { initial: false }; const Scenes = (props, context) => ( <RouterWithRedux> <Scene key="root"> { map(scenes, (component, name) => ( <Scene ... component={component} hideNavBar={false} {...(context.initialScene === name ? initialSceneProps : defaultSceneProps)} /> ))} </Scene> </RouterWithRedux> ); Scenes.contextTypes = { initialScene: PropTypes.string.isRequired, };
バックアローをつける
さて、これで起動してみると、最初に開くReact Nativeの上部バーにバックアローがないことに気づきます。
Nativeの画面に戻るためには、バックアローが必要です。routerにはrenderBackButton
renderLeftButton
というパラメタがあるので、それを利用します。BackButton
はReact NativeのHeaderBackButton
をカスタマイズしたものを使います。
// scenes/index.js const initialSceneProps = { initial: true, renderLeftButton: () => <BackButton backTitle='back' onPress={ } />, }; const defaultSceneProps = { initial: false, renderLeftButton: () => {null}, }; <Scene ... renderBackButton={() => <BackButton backTitle='back' onPress={() => Actions.pop() }/>} {...(context.initialScene === name ? initialSceneProps : defaultSceneProps)} />
これでバックアローが表示されるようになりました。
バック機能を実装する
このバックアロー、実際にタップしても何も起きません。押されたことをNative側に伝えて画面を閉じる処理が必要になります。
ここでReact NativeとNativeを橋渡しするNativeModuleの登場です。
NativeModuleについては公式ドキュメントをご覧ください。
ここではEventHook
というNativeModuleを作ります。
// ReactEventHook.java public class ReactEventHook extends ReactContextBaseJavaModule { private final ReactEventCallback callback; public ReactEventHook(ReactApplicationContext reactContext, ReactEventCallback callback) { super(reactContext); this.callback = callback; } @Override public String getName() { return "EventHook"; } @ReactMethod public void sendEvent(String name, @Nullable ReadableMap data) { // receive parameters from react module callback.onEventSent(name, data); } }
React Native側ではEventHook.sendEvent()
と書くことでNative側にeventを送ることができます。
// eventHook.js import { NativeModules } from 'react-native'; export const nativeBack = () => { NativeModules.EventHook.sendEvent('nativeBack', {}); };
// scenes/index.js const initialSceneProps = { initial: true, renderLeftButton: () => <BackButton backTitle='back' onPress={ nativeBack } />, };
Native側ではcallbackを受け取ってハンドルしてあげます。
// MyReactActivity.java @Override protected void onCreate(Bundle savedInstanceState) { ... reactEventCallback = this::handleEvent; reactInstanceManager = ReactInstanceManager.builder()... .addPackage(new MyReactPackage(reactEventCallback))... } private void handleEvent(String name, @Nullable ReadableMap map) { switch (name) { case ReactConst.NATIVE_BACK: finish(); break; } }
// Eventhook.js export const itemSelected = (item) => { NativeModules.EventHook.sendEvent('selected', { item }); }
// MyReactActivity.java private void handleEvent(String name, @Nullable ReadableMap map) { switch (name) { case ReactConst.SELECTED: Intent data = new Intent(); if(map != null) { //Mapファイルから受け取ったデータを取り出す data.putExtra(ReactConst.ITEM_SELECTED, map.getString(ReactConst.ITEM)); } setResult(RESULT_OK, data); finish(); // 閉じる break; } }
まとめ
簡単な画面遷移を題材にハイブリッドアプリの実装を見てみました。一つ一つの技術要素はすでに公式ドキュメントにある内容で実装可能であるということがわかったのではないでしょうか。
さらに発展的なテーマとして
- React Native関連クラスのモジュール化
- Javascript Bundleファイルの管理方法
- React Nativeのアップグレード
などがありますが、今後機会があればまとめたいです。以下のスライドではCI等も含めた全体的な設計を説明しているので、参考になれば幸いです。