スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

React Nativeハイブリッドアプリへの挑戦 ~ Part3: 振り返り/今後 ~

本エントリは3部作のPart3となっております。


モバイルエンジニアの@hotchemiです。

Part1、2では実際にインテグレーションを進めてきた中で得られた知見を公開してきましたが、今回は半年程の運用を経て我々は当初の目的を達成できているのか、という事に関しての振り返りと今後について共有できればと思います。

振り返り

現状を軽く復習しておくと私達は今年の初頭からハイブリッドスタイルの開発を初め、現在のコード比率はNative75%, React Native 25%程となっています。

Good

まず、Part1で宣言した3つの目標に関して振り返ってみます。

  • モバイルエンジニア不足の解消(◎)
    • 達成する事ができました。一例を紹介すると現在Quipperには3名のiOSエンジニアがいますが、そのうち1名+2名のReactエンジニアでスタディサプリ本体アプリの開発、残り2名は新規事業や技術的負債解消のプロジェクトをリードしています。組織全体としてモバイル開発をスケールさせる事ができました。
  • 開発とリリースの高速化(△)
    • こちらは達成できたともできなかったとも言えます。Live/Hot reload機能を用いてUI開発の生産性を上げる事ができましたが、Part2で紹介したようなBridgeの実装をする際にはNativeモジュールのコンパイルが必要な為、100%React Nativeで開発している時よりは若干の待ち時間が発生してしまっています。
    • また、以前ご紹介したCodePushに関してはハイブリッドアプリでは使用していません。導入自体は完了していますが運用フローが煩雑になる為一旦pendingの状態となっています。
  • Webフロントエンドとの設計の統一、コードの共用(○)
    • ほぼ達成する事ができました。基本的にWebフロントエンドチームのReactプロジェクトと設計思想や利用しているライブラリを合わせている為、フロントエンドエンジニアが開発に参画しやすい土壌が整ったと思います。また、ActionCreatorやロジック層に関しては共通のコードがかなり存在しており現在どう共有していくのが良いか検討しています。

また、上記に挙げた点以外にも以下のメリットを享受する事ができています。

  • テストの書きやすさ
    • JavaScriptという言語自体のmockのやりやすさ、Reduxの設計が疎結合になっているのでActionCreator、Reducerのテストが書きやすい事、React+Enzyme+JestでUIロジックのテストやViewのsnapshotを網羅できしかもNode.js上で高速に実行できる事は非常に快適だと感じています。iOS/AndroidのNative実装でも頑張れば同じ事は勿論できますが手軽に高速に実行できるという点でテスタビリティには一日の長があると感じています。
  • パフォーマンス(主にUI描画のFPS)
    • 意外に思われるかもしれませんがReact Nativeはデフォルトで全ての動作をJS専用のバックグラウンドスレッドで実行しUI更新の命令だけをenqueueしメインスレッドに送りつけるというアーキテクチャになっている為メインスレッドでintensiveな処理を実行するという事は原則できないようになっています。私達のアプリには一部メインスレッドで重い処理を実行してしまっているレガシーコードがありそれと比べるとパフォーマンスがむしろ良いという事もあります。
    • よく問題になるのはbundleされたJSを読み込む際の初期化時間ですが、こちらは私達のケースでは未だ問題になっていません。

Bad

私達は数多くのメリットをReact Nativeから享受してきましたが、当然うまくいかなかった事もあります。

一例を紹介します。

  • Androidでのみ発生する不具合や問題に苦しめられた
    • こちらについては後述します。
  • クロスプラットフォーム開発自体が難しい
    • React Nativeそれ自体とは関係なく、クロスプラットフォーム開発そのものが難しいという事に気付きました。具体的にはUIの変更を検証する為に常に複数のプラットフォームでテストをしなければならない事、各OSにとって最適なコンポーネントは何かを開発者が知っていなければならない事など、発生するコストは無視できない程に存在している、という結論に至りました。
    • 両OS用のBridgeを書けるエンジニアは殆どいないのでBridgeエンジニアの作業がボトルネック化するという事態が発生しました。
  • Reactを知らないNativeエンジニアにとっては学習コストがある
    • こちらは現在どうしようかと考えている課題で、Reactは触った事はないがiOS/Androidは書けるというエンジニアもいる為チームとしてどうfollow upしていこうかという事を考えています。私見ですが、クライアントサイド開発をしていくに辺りReactの概念を学んでおく事は例えWebフロント専業エンジニアでなくても価値があると考えている為チーム内で得意分野を教えあっていければと思っています。

Androidで発生した問題

先程Androidでのみ発生する不具合や問題に苦しめられた、と書きましたが我々が実際に直面した問題に関して共有できればと思います。

  • アーキテクチャが抱える根本的な問題
    • Android用のReact NativeにはWebKitとFlexboxの実装であるYogaがNDKとして含まれるため、ビルド時の煩雑性の増加やバイナリサイズ増加を0にする事ができません。また、こちらのissueにある通り現時点では64bit向けのAPKを作成する事ができません。
  • Fragment上で発生する問題
    • Part2で説明した通り、私達は部分的に導入を進めていった為iOSではUITabbarControllerの1UIViewController、AndroidではBottomNavigation上に乗っている1Fragmentという単位で導入していきました。その際に発生した問題は(実装の仕方に勿論依存しますが)Navigationの切り替え時にFragmentが再生成される度にReact Componentも再生成され、componentDidMountが毎回呼ばれ無駄なAPI呼び出しが発生する、Componentが再生成される為ReactView自体の描画コストがかかるというものでした。
    • またBottomNavigation上に乗っているFragmentの上でViewを描画した場合、FlatListが最後までスクロールしない、という問題がありました。こちらは恐らくReact Native本体のバグだと思いますが、根本原因解明まで至らず下記の様にスクロール領域を自前で計算するwrapperを利用していました。
import React, { PureComponent } from 'react';
import { Dimensions, View } from 'react-native';

interface Props {
  children: React.ReactNode;
}

interface State {
  height?: number;
  diff?: number;
}

// There're some cases cannot scroll down to bottom "0" only on Android.
// It might be a bug of RN but we can deal with the problem by surrounding a view that has a tangible height.
// this view leverages nativeEvent height and calculates diff and store it for device orientation.
//
// Got a hint from https://github.com/wix/react-native-navigation/issues/2214#issuecomment-347325418
// ref: https://github.com/facebook/react-native/issues/15707
// ref: https://facebook.github.io/react-native/docs/view.html#onlayout
// onLayout: http://matthewsessions.com/2017/06/27/react-native-on-layout.html
export default class AndroidScrollableWrapper extends PureComponent<Props, State> {
  private deviceOrientationChangeHandler = this.adjustHeight();
   constructor(props) {
    super(props);
    this.state = {
      height: undefined,
      diff: undefined,
    };
  }
  adjustHeight() {
    return () => {
      if (!this.state.diff) {
        return;
      }
      this.setState({
        height: Dimensions.get('window').height - this.state.diff,
      });
    };
  }
  componentDidMount() {
    Dimensions.addEventListener('change', this.deviceOrientationChangeHandler);
  }
  componentWillUnmount() {
    Dimensions.removeEventListener(
      'change',
      this.deviceOrientationChangeHandler,
    );
  }
  render() {
    return (
      <View
        onLayout={event => {
          const { height } = event.nativeEvent.layout;
          this.setState({
            height,
            diff: Dimensions.get('window').height - height,
          });
        }}
        style={{
          width: Dimensions.get('window').width,
          height: this.state.height,
        }}
      >
        {this.props.children}
      </View>
    );
  }
}

今後

上記の問題を踏まえて、我々はiOSのみReact Nativeを使い続けていくという決定をしました。既にAndroidからはdependencyをrevertしています。

iOSのみReact Nativeを使っていくという手法はDiscordでも採用されており、我々のケースでもうまくworkしています。

React Nativeに限らずあらゆる技術は銀の弾丸ではないので、チームのスキルセットや状況に応じて適切に使い分けていく事が重要だと考えています。今回のケースでいうと、部分的に導入→振り返り→意思決定というサイクルを回す事でチームにとって最適なツールの使い方を最小限のコストで見つけ出す事ができました。また今回はReact Nativeの話でしたがKotlinやSwiftでの開発もしっかりやっていますしFlutter等新たなフレームワークに関しても積極的に調査、検討をしていきたいと考えています。

「React Nativeハイブリッドアプリへの挑戦」は以上で終了となります。長々とお付き合い頂きありがとうございました。

AppIndex