React(Web)とReact Native(with expo)の同じところ違うところ

これはアソビュー Advent Calendar 2019の10日目です。

テックリード/フロントエンドエンジニアの井上(@ashimon83)です。
相変わらずスマブラはネスしか使いません。相変わらず非VIPです。。

今日はReact Native絡みで記事を書いてみようと思います。

アソビューとReact Native

アソビューのサービスでReact Native使ってるの?
はい、実は使ってます。

どこで使ってるの?

弊社の運営するアソビュー!では様々な遊びの施設のチケットをお得に購入することができるのですが、決済は事前に行い、一部のチケットに関してはQRコードを提示するだけで簡単に入場することが出来ます。 *1

その際に施設のスタッフの方がゲスト(ユーザー)がスマホなどで提示するQRコードを読み取って着券する(チケットを使用済みにする)際に使うアプリをReact Nativeで開発しました。

f:id:masino83:20191208003336j:plain:w300
今回開発したQR着券アプリFast-Inの画面キャプチャ

React Nativeを採用した理由

弊社ではスピード感を持ってゲスト(ユーザー)へ便利でお得な体験を届けるため、企画が決まってから開発、リリースするまでのスピードをなるべく早くすることを心がけています。

今回このQRコード着券についても当然ながら早くゲストに届けたい!
という思いはあり、要件的にネイティブアプリでの提供が必須ながらもその時点ではネイティブアプリのエンジニアや体制がありませんでした。

一方弊社ではWebアプリケーションのフロントエンドに関してはReactをメインで開発しており、そのノウハウを活用して既存の体制でも開発ができるということでReact Nativeで作ることを決めました。

React Native自体の特徴やメリット・デメリットなどは世の中に情報が出回ってると思いますのでここでは改めて詳細には書きません!
弊社としては下記メリットが大きいです。

  • Webフロントエンジニアが実装できる
  • リリースがWebと同じ感覚でできる(一度リリースすればその後の細かいアップデートで審査不要)
  • AndroidとiOS向けに同時に開発できる
  • expoを使えばローカルの開発もWebと同じ感覚でできる(ホットデプロイ)

結果としてWebのReactに関してはそれなりに多くのノウハウがありつつもReact Native自体はチュートリアル程度の知識しか無かった私が3週間程度で一気に開発して無事リリースすることができました。
とはいえやはり普通にWebのReact開発とは違う点なども多くあり、それなりにハマったところもあったので、今回はそれらのWebとNativeの同じところ、違うところについてまとめてみます。
※なお今回expoを採用してますのでその前提です。

React(Web)とReact Native(with expo)の同じところ違うところ

Index

  1. View
  2. routing
  3. state management
  4. local storage
  5. API mock
  6. ローカル開発
  7. リリース

1. View

レイアウト周りには特に苦戦させられます。

同じところ

  • 基本的にはPresentational Componentに関しては大きな違いはありません。JSX含めWebと同じ感覚で書けます。
  • hooksもReact Native v0.59から導入されました。(今回のリリースが6月半ばだったのですがそれを含むexpo v33がテスト直前の6/6にリリースされるという。。)
    f:id:masino83:20191208010517p:plain:w300
    ギリギリで直すPR上げるの図

違うところ

  • 細かな点でdivが使えないレイアウトはViewタグ、文字列はTextタグで囲う必要がある、など。webのReactと平行で開発してると間違えてしまう。
  • stylingについて、まずinline styleとほぼ等しい書き方になります。Webでは非推奨とされてるやり方ですね。。
function LoadingIndicator ({ message }: { message?: string }) {
  return (
    <View style={styles.loadingIndicator}>
      <ActivityIndicator size='large' />
      {message && <Text style={styles.loadingText}>{message}</Text>}
    </View>
  )
}

const styles = StyleSheet.create<Styles>({
  loadingIndicator: {
    alignItems: 'center',
    flex: 1,
    justifyContent: 'center'
  },
  loadingText: {
    fontSize: 20,
    marginTop: 30
  }
})
  • CSSのプロパティがほぼそのまま使えますが(camelCaseに直す必要はある)、デフォルトでdisplay: flex&flex-direction: colomnが効いています。
  • Textが改行すると高さの計算がうまく行かないのか隠れてしまったりなど微妙に普通にWebと挙動が違うところがちらほらあります。
  • Android、iOSでそれぞれ対応しているものしていないUIのコンポーネントがあるので使い分ける必要がある

例)トーストのUIがiOS側で無かったのでAlertで代替してます。

import { ToastAndroid, Platform, Alert } from 'react-native'

export const showAlert = (message: string) => {
  if (Platform.OS === 'ios') {
    Alert.alert(message)
  } else {
    ToastAndroid.showWithGravity(
      message,
      ToastAndroid.SHORT,
      ToastAndroid.CENTER
    )
  }
}

2. Routing

Routingを管理するためには何かしらライブラリを用いる必要があります。
公式でも推奨されてますし、expoを利用してることもあり、ネイティブのAPIを直接使うライブラリは使えないためほぼ唯一の選択肢としてReact Navigationを採用しています。

同じところ

  • あまりないと言えばないし、あると言えばあるという感じですが、SPAの画面遷移に概念としては似ていますかね。。
  • (俗っぽい言い方だと)アプリっぽUIのWebとかネイティブアプリと同じUXを提供する場合は似たような作りや考え方になります。

違うところ

  • タブナビゲーションによる遷移なのか、モーダルで出したいのか、でrouting部分の構築の仕方が変わってきます。アプリでよくある標準的なタブナビゲーションや全画面モーダル(っぽい動き)関してはreact-navigationのライブラリで賄ってくれます。WebのSPAだとreact-routerを使ってJSXでroutingを書いていきます

webのrouting

export default function App() {
  return (
    <Router>
      <div>
        <nav>
          <ul>
            <li>
              <Link to="/">Home</Link>
            </li>
            <li>
              <Link to="/about">About</Link>
            </li>
            <li>
              <Link to="/users">Users</Link>
            </li>
          </ul>
        </nav>

        {/* A <Switch> looks through its children <Route>s and
            renders the first one that matches the current URL. */}
        <Switch>
          <Route path="/about">
            <About />
          </Route>
          <Route path="/users">
            <Users />
          </Route>
          <Route path="/">
            <Home />
          </Route>
        </Switch>
      </div>
    </Router>
  );
}

React Nativeの場合、例えば下にタブナビゲーションが有って上部が切り替わっていく+特定の画面については全画面モーダル(ヘッダーや)の場合下記のように階層構造を記載していくことになります。

AppNavigator.tsx(rootのroute定義例)

import { createAppContainer } from 'react-navigation'
import { createStackNavigator } from 'react-navigation-stack'
import MainTabNavigator from './MainTabNavigator'
import Modal1 from '../screens/Modal1'
import Modal2 from '../screens/Modal2'

const RootStack = createStackNavigator(
  {
    // ボトムタブナビゲーションのルーティングの設定
    Main: {
      screen: MainTabNavigator
    },
    // 全画面モーダルのルーティング
    Modal1: {
      screen: Modal1
    },
    Modal2: {
      screen: Modal2
    }
  },
  {
    mode: 'modal',
    headerMode: 'none'
  }
)

export default createAppContainer(RootStack)

MainTabNavigator(ボトムタブナビゲーションのroute定義例)

import { createBottomTabNavigator } from 'react-navigation-tabs'
import { createStackNavigator } from 'react-navigation-stack'
import Menu1 from '../screens/Menu1'
import Menu2 from '../screens/Menu2'

const Menu1 = createStackNavigator(
  {
    Menu1Stack: {
      screen: Menu1
  }
)

const Menu1 = createStackNavigator(
  {
    Menu2Stack: {
      screen: Menu2
  }
)


// 一部省略

const MainTabNavigator = createBottomTabNavigator(
  {
    Menu1Stack,
    Menu2Stack
  }
)

export default MainTabNavigator
  • reduxでrouting(navigation)の情報が取れない。これがかなり悩ましかったです。Webの場合、connected-react-routerを活用することでreduxの中でpathを取得したり、LOCATION_CHANGEのタイミングをアクションで捉えることが出来ましたが、そちらはできない(出来なくはないが推奨されない)です。Error出た際にエラーモーダルにnavigation遷移させたりしたかったのですがこういった事情のため諦めました。。

3. state management

reduxを活用してますがこちらに関しては大きな違いは無いと思いました。mobxでも何でも使えると思います。やろうと思えばWebと共有するなんてこともできるかも、、(多分辛い)

同じところ

  • redux、react-redux、redux-thunk含め動きます。
  • まだHOCのconnectから書き換えられてないのですがredux-hooksも動くはず

違うところ

  • ここに関してはあまりないと思います。

4. Localstorage

同じところ

  • Web標準のLocalStorage的なシンプルなkey-valueストレージとしてAsyncStorageを使います。
  • 使い勝手はほぼ同じです。key-valueでsetItem、getItemします。

違うところ

  • AsyncStorageにはmultiGet、getAllKeysなどより便利に使える機能があります。これは後述のラッパーを書いて拡張する際に活用できます。
  • アプリなのでオフラインでも使いたい、key-valueよりは少し複雑なデータ管理をしたい(SQLiteのようにローカルDBとして使いたい)という需要が当然ながら出てきますが、そうなるとAsyncStorageの素の機能だけだと少し足りません。それを解決するためのライブラリとしてはいくつかあり、特にrealmがmongoDBライクでKVSもRDBもサポートしており、ドキュメントも充実しており素晴らしいのですが、悲しいかなexpoはサポートしてません!(expoあるある)
  • ということで今回は結局AsyncStorageをreact-native-storageのような(このライブラリ自体もlinkが必要なのでexpo環境では使えません)IFで使えるようなラッパーを書きました。

5. API mock

ここは標準というよりはうちではこうやってます、というところの紹介になります。

同じところ

  • SPAでもアプリでもAPIと平行してクライアントサイド開発していると、APIがまだ出来上がってないのでIFだけ認識合わせてモックを利用して実装するケースがあると思います。Consumer Driven Contructまでは出来てませんが、とりあえず固定のJSON用意しておいて画面表示できるようにするな手段をとります。

違うところ

  • Webではwebpack-dev-serverのbeforeでAPIへのリクエストをproxyしてjsonファイルの内容に書き換えることでモックしています。
  • React Nativeの場合、上記の方法はできないためFirebase functionsで簡易なAPIサーバーを立てています。pathと同じディレクトリにJSONファイルを配置してfirebase deployを叩く感じです。

firebase functionsに立てた簡易なサーバー

import * as util from 'util'
import * as fs from 'fs'
import * as functions from 'firebase-functions'
import * as glob from 'glob'
import * as express from 'express'

const app = express()
app.all('*', async (req, res) => {
  try {
    const globPromise = util.promisify(glob)
    const paths = await globPromise(`api${req.path}.json`)
    const matchPath = paths[0]
    const file = fs.readFileSync(matchPath, 'utf8')
    res.setHeader('content-type', 'application/json')
    res.send(file)
  } catch (err) {
    const errObj = {
      error: {
        code: "400",
        message: err || 'エラーが発生しました',
      }
   }
    res.send(JSON.stringify(errObj))
  }
})

exports.apiProxy = functions.https.onRequest(app)

6. ローカル開発

同じところ

  • Webならwebpack-dev-serverでローカルサーバー起動、React Native(expo環境)ならexpo startでローカルサーバー起動して手元の端末のexpoアプリでQRを読み込んで直接配信することができ、ホットリロードも効くのでほとんど同じ感覚で開発できます。ネイティブのビルドも不要なので、もうこれだけでexpo縛りで開発する強い動機になりますね。。
  • デバッグについてもexpoアプリを使えばできますWebと同様にChrome Devtoolsでbreak point置いたりできます。また、スタンドアロンのReact Native Debuggerを使うとredux devtoolsと同様にstateの遷移をトレースすることも可能です。

違うところ

  • expoならWebと同じ感覚でローカル開発できます。そう、expoならね。・・基本は。
  • expoアプリで表示してるときに端末をフリフリするとJSデバッグモード選択できるのですが、このモードになると動作が重くなり、QRコード読み取りはもはやできなくなります。。なのでそこのデバッグはconsole.logで頑張るしか無いという。。

7. リリース

同じところ

  • expoのOTAアップデート機能を使ってストアでのアップデートをせずにバンドルするJSを配信して実質アプリのアップデートができます。そのためアプリのリリースのように即時配信することが可能です。

違うところ

  • app.jsonを書き換えるような大きなアップデート(SDKのバージョンを上げるなど)についてはビルドし直す必要があり、こちらはストアの更新が必要になります。
  • expo buildのコマンドオプションでrelease channelという形で本番とstagingなどのビルドを分けることが出来ますので、staging用はstagingのrelease channelでビルドしたapk(androidの場合)を落としてきて使います。
  • iOSの場合は少し手間で、staging用のものを別のアプリとして作ってtest flightで配信する必要があります。(こっちは実際のストアのアップはしない)
  • 配信についてはexpo publishコマンドで実施します。ここでビルドに使ったrelease channelを指定することでそれぞれのビルドのアプリに対してのみ配信できます。
  • circleciと連携して、github上でdevelopにマージしたらstagingアプリにpublishして配信、masterにマージしたら本番アプリにpublishして配信するように設定しました。

まとめ

  • 振り返りながら書き始めたら、色々つまずいた所など思い出してきて長くなってしまいました。もっと有ったのですが、一旦今回はここまでにします。
  • 同じReactとはいえ、やはり違う部分やハマる部分が多くそれなりに苦労します。が、React Nativeでできる範囲の機能仕様、UIのアプリをネイティブアプリエンジニアがいない状態で素早く作るのであれば十分アリな選択です。
  • ただ、React Nativeもexpoも世の中にベストプラクティスが固まってなかったり思わぬ不具合に当たったりするのでそういった場合はググったり色々試したりする必要があります。expoやReact Nativeのフォーラムやstackoverflowなどを色々見て自分の置かれた環境や状況なりの解決策を見出していくような感じでしょうか。
  • expo環境で何度も涙を飲んだのが、ネイティブのAPIを使うようなライブラリ( =react-native linkコマンドを叩く必要がある)が一切使えないというところです。
  • ただ、ローカル開発の部分のメリットを味わってしまうとexpoを使わない選択肢はなかなか出てこないと思います。今回のアプリくらい(ネイティブのAPIはカメラのQR読み取りくらいしか使わない)であればejectせずに走りきれます。
  • 頑張って環境作りきってしまえば、エンハンス改修などはJSベースで出来ますので、初期コストは見込んだ上でReact Nativeを使う判断をする必要はあると思います。
  • また、書ききれなかったですが、ストアに上げて公開するところもWebエンジニアには馴染みのない所で色々と苦労しました。(特にiOS。。)

長くなりましたが、以上です。 ここまで読んでいただきありがとうございました! 少しでも誰かの参考になれば。。

*1:それとは別に擬似的なチケット半券もぎりなどで入場できるものもあります