4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

同一ソースツリー上でReactNativeでWeb&iOS&Androidのハイブリッドアプリを開発するために使った3つのテクニック

Posted at

今回、弊社の開発・運営する日報共有アプリ「gamba!」をReact Nativeで全面的に作り直しました。

gamba!は約2年前に、ちょうど話題になりかけていたReact(0.14ベース)で全面的に作り直していたのですが、当時はReduxも生まれておらず、技術的にはまだまだ未成熟な状況でした。

この2年間のうちに、さまざまな問題を抱えることになりました。

  • コンポーネントの中にビューとロジックが混在
  • プログラムの見通しが非常に悪い
  • ロジックのテストが十分にできない

現状のコードのままではこれ以上のサービスの改善は難しいと判断し、今回全面的に作り直すことになりました。

今回の開発では、このような目標を立てて開発を進めてきました。

  • ビューとロジックを完全に分離し(redux)、
  • ロジックについてはすべてテストコードを用意する(jest)
  • ReactによるWebアプリと、React Nativeによるスマホアプリ(iOS/Android)を提供し、
  • Webアプリとスマホアプリでロジック部分のコードを完全に共有する

ようやく先日、React Native版のスマホアプリのリリースが完了したところですが、今回の開発を振り返ってみて、プロジェクト内で私たちが編み出した3つのテクニックについて紹介します。

package.jsonをWebアプリ用とNativeアプリ用に分離する

ReactとReact Nativeのバージョン問題

ReactのみのWebアプリとReact Nativeのスマホアプリのコードを一つのソースツリー上で開発する上で、まず最初につまずいたのが、本家ReactとReact Nativeのバージョンの違いでした。

今回の開発を始めた2017年7月ごろの状況では、このような依存関係になっていました。

  • Webアプリで採用した本家Reactのバージョン: 15.6.0
  • React Nativeのバージョン: 0.45.1
  • React Nativeが要求するReactのバージョン: 16.0.0.alpha-12

React 16.0.0.alpha-12というバージョンは多くの不具合があり、そのまま採用するのは難しい代物です。ですが、React Nativeとコードを共通化するにはどうしても、Reactのバージョンを揃える必要があります。

package.jsonを切り替える

そこでとったのが、React Nativeにバージョンを合わせたpackage.jsonと、Webアプリ用のpackage.jsonをプロジェクト内に両方用意し、シェルスクリプトで切り替える、という手法です。

この方法であれば、WebアプリとReact Nativeアプリで異なるバージョンのパッケージを混在させることができ、バージョンのやっかいなコンフリクトを回避することができます。

私たちが使ったのは、こんなスクリプトです。

switch-package.sh
#!/bin/sh

# package.jsonとnode_modulesをWeb版とNative版で切り替えて使用する
#  usage:
#    bin/switch-package native : Native版のpackage.jsonを有効にする
#    bin/switch-package web :    Web版のpackage.jsonを有効にする

CHECK_DIFF_FILE="package.json"
SWITCH_FILES="package.json yarn.lock package-lock.json"
SWITCH_DIRS="node_modules"

if [ $1 = 'native' ]; then
  FILE_LABEL="PACKAGE-NATIVE"
  OLD_FILE_LABEL="PACKAGE-WEB"
  if [ -f $FILE_LABEL ]; then
    echo 'すでにnative版になっています。'
    exit
  fi
  CURRENT="web"
  CHANGE_TO="native"
else
  FILE_LABEL="PACKAGE-WEB"
  OLD_FILE_LABEL="PACKAGE-NATIVE"
  if [ -f $FILE_LABEL ]; then
    echo 'すでにweb版になっています。'
    exit
  fi
  CURRENT="native"
  CHANGE_TO="web"
fi

if [ ! -f $CHECK_DIFF_FILE ]; then
  cp $CHECK_DIFF_FILE-$CURRENT $CHECK_DIFF_FILE
fi

if diff $CHECK_DIFF_FILE $CHECK_DIFF_FILE-$CURRENT > /dev/null; then
  rm -f $OLD_FILE_LABEL
  for f in $SWITCH_DIRS; do
    if [ -d $f-$CHANGE_TO ]; then
      mv $f $f-$CURRENT
      mv $f-$CHANGE_TO $f
    else
      if [ -d $f ]; then
        mv $f $f-$CURRENT
      fi
    fi
  done
  for f in $SWITCH_FILES; do
    if [ -f $f-$CHANGE_TO ]; then
      cp $f-$CHANGE_TO $f
    fi
  done
  yarn install
  touch $FILE_LABEL
  echo $CHANGE_TO 版の package.json に切り替えました。
else
  echo $CHECK_DIFF_FILE は変更されています。変更点を $CHECK_DIFF_FILE-$CURRENT に反映してから切り替えてください。
fi

あとは、開発対象をWebとReact Nativeの間で切り替える時に、以下のコマンドでpackage.jsonnode_modulesを切り替えます。

$ switch-package web # Webに切り替え

今後、Reactはバージョン16が正式リリースされ、そうなればReact自体をWebとReact Nativeで共有することができるようになると思います。ただ、WebとReact Nativeでは使うパッケージがまったく異なるため、package.jsonをそれぞれに分離しておくのは得策だと思っています。

ReduxでLifecycleメソッドを共通化する

Reactでのアプリ開発ではほぼ、Reduxがデファクトになりつつあり、Reduxを使うことでストアやアクションの処理を見通しよく実装することができます。

Reduxだけでは不十分

しかし従来のReduxだけでは、コンポーネントのLifecycleメソッドのロジックを組むことができません。

たとえば、このような処理をWebアプリとReact Nativeアプリで同じロジックで実装する場合、どうしたらいいでしょうか。

  • コンポーネントがMountされたら、APIを叩いてデータをフェッチする
  • コンポーネントのpropsの特定の値が変化したらAPIで値をサーバに書き込む
  • コンポーネントがUnmountするときに、ストアをクリアする

WebとReact Nativeアプリで同じコードをそれぞれのコンポーネントに実装するのは、コードの共通化にはなりません。また、コンポーネントの中に書かれたLifecycleメソッドの動作をJestでテストするのは、いろいろと面倒です。

LifecycleメソッドをHoCでコンポーネントの外に出す

そこでとったのが、以下のようなHoC(Higher order Component)を使って、コンポーネントの外に出すという手法です。

lifecycle.js
import React from 'react'

export default function(Component) {
  class LifecycleComponent extends React.Component {
    componentWillMount() {
      this.props.willMount && this.props.willMount(this.props)
    }
    componentWillUpdate(nextProps) {
      this.props.willUpdate && this.props.willUpdate(this.props, nextProps)
    }
    componentWillUnmount() {
      this.props.willUnmount && this.props.willUnmount(this.props)
    }
    render() {
      return <Component {...this.props} />
    }
  }
  return LifecycleComponent
}

これを使うことで、コンポーネントは以下のように定義することができます。

some-component-connect.js
import { connect } from 'redux'

function mapStateToProps(state) {
  ...
}

function mapDispatchToProps(dispatch) {
  return {
    willMount: (props)=> {
      /* called when the component will mount */
      /*   props: component's this.props */
    },
    willUnmount: (props)=> {
      /* called when the component will un-mount */
      /*   props: component's this.props */
    },
    willUpdate: (props, nextProps)=> {
      /* called when the component will update */
      /*   props: component's this.props */
      /*   nextProps: up-coming props */
    },
  }
}

export default connect(mapStateToProps, mapDispatchToProps)
some-component.jsx
import React from 'react'
import connect from './some-component-connect'
import lifecycle from './lifecycle'

class SomeComponent extends React.Component {
  render() {
    return (...) // ここにはJSXのみを記述する
  }
}

export default connect(lifecycle(SomeComponent))

LifecycleメソッドをJestでテストする

このようにしておけば、WebアプリもReact Nativeのスマホアプリも同じコードを共有することができます。
また、lifecycleメソッドを、Jestで単体テストを行うのも簡単です。

some-component-connect-test.js
import { mapDispatchToProps } from '../some-component-connect'

var dispatchProps
var dispatch

describe('mapDispatchToProps', ()=> {
  beforeEach(()=> {
    dispatch = jest.fn().mockImplementation((r)=> r)
    dispatchProps = mapDispatchProps(dispatch)
  })

  it('willMount', ()=> {
    expect(dispatchProps.willMount({ some props... })).toEqual({ ...results })
  })
})

こんな感じで、テストすることができます。

コンポーネントのstateの代わりに、Redux-uiを使う

最後に紹介するテクニックがこちらです。

コンポーネントのstateの問題点

従来のReactでは、UIの状態や内部の変数は、通常コンポーネントのthis.stateに保持していると思います。

しかし、この方法だと、stateの値に依存する処理はコンポーネントの内部に書く必要があり、WebとReact Nativeアプリでコードを共有することが難しくなります。

たとえば、<input>で入力した値をSubmitボタンでAPIに渡してサーバに送信するようなケースを考えてみてください。

  • API呼び出しを行うSubmitボタンのイベントハンドラは、そのコンポーネントのmapDispatchToProps内で定義したい。
  • Submitで送信するデータ自体はコンポーネントのstateの中に入っていて、mapDispatchToPropsからは操作できない。

Redux-uiはstateの代わりに使える

Redux-uiは、コンポーネントのstateとほぼ同一の機能をRedux上で実装したものです。

使い方は簡単。

some-component.jsx
import React from 'react'
import ui from 'redux-ui'
import connect from './some-component-connect'

class SomeComponent extends React.Component {
  render() {
    return(
      <div>
        <input onChange={(ev)=> this.props.updateUI({ inputValue: ev.target.value })}
           value={this.props.ui.inputValue } />
        <Button onClick={()=> this.props.handleSubmit(this.props)}>
          Submit
        </Button>
      </div>
    )
  }
}

export default connect(ui({ state: { inputValue: undefined }})(SomeComponent))

このようにすることで、SomeComponentのthis.propsには以下のプロパティが渡されます。

  • this.props.updateUI: 従来のthis.setState相当のメソッド
  • this.props.ui: 従来のthis.state相当のオブジェクト

上記の例に書いたように、従来this.setState/this.stateで書いていたもの置き換えれば、そのままRedux-uiになります。

mapDispatchToPropsからstateを操作する

mapDispatchToPropsには、以下のようにsubmitのハンドラーを定義することができます。

some-component-connect.js
import { connect } from 'redux'

function mapDispatchProps(dispatch) {
  return {
    handleSubmit: (props)=> {
      dispatch(action.submit(props.ui.inputValue))  // APIにinputValueを渡す
      props.updateUI({ inputValue: undefined }) // inputValueの値をハンドラから操作する
    }
  }
}

function mapStateToProps(state) {
  ...
}

export default connect(mapStateToProps, mapDispatchProps)

このようにすることで、コンポーネント内のstateを操作する部分をmapDispatchToPropsの内部に持ってくることができます。よりコードの共通化を進めることができます。

おまけ

React+Reduxで開発を進めると、HoCを多用することになります。
今回の私たちの開発の中では、このようなHoCが使われています。

  • reduxのconnect()
  • redux-uiのui()
  • 先に紹介したlifecycle()
  • I18n対応で使用したreact-i18nexttranslate()
  • などなど

これら複数のHoCを一つのコンポーネントに適用するのは、実は地味に面倒です。

export default connect(mapStateToProps, mapDispatchToProps)(
  ui({ state: { someState: undefined })(
    lifecycle(
      translate()(Component)
    )
  )
)

カッコがたくさんありすぎて、結構、書くのが辛いです。

そこで、私たちはこんなコードを用意しました。

combine.js
import _ from 'lodash'

export default function () {
  var outer_args = arguments

  return function(Component) {
    return _.reduceRight(outer_args, (result, func)=> {
      return func(result)
    }, Component)
  }
}

この関数にHoCを引数に渡すと、引数の右から順番にHoCを適用したHoCを戻り値として返してきます。

これを使えば、先ほどのコードはこのようになります。

import combine from './combine'

export default combine(
  connect(mapStateToProps, mapDispatchToProps),
  ui({ state: { someState: undefined }),
  lifecycle,
  translate(),
)(Component)

少し、見やすくなりますよね。

まとめ

同一ソースツリー上でReactNativeでWeb&iOS&Androidのハイブリッドアプリを開発するために、私たちが使ったテクニックはいかがだったでしょうか?

Webとスマホのアプリが一つのソースツリーで構築できるというのは、実際にやってみるととても効率的です。同じ機能を同じタイミングですべての端末にリリースすることができます。しかも、同じ一人のエンジニアで全ての面倒を見ることができます。iPhoneはSwift使いの彼に、AndroidはJava使いの彼女に相談する、みたいなことがないのはとても助かります。

React Nativeはまだまだ日本語の情報が少なく、使いこなせているエンジニアがとても少ないのが現状です。
これを機に、もっともっと多くのエンジニアがReactの資産を生かしつつ、React Nativeでの開発に参加してくれるとうれしいですね。

ちなみに、弊社では開発エンジニアを募集しています。もしこの記事を読んで弊社の開発に興味を持ってくださったら、ぜひ、弊社お問い合わせページよりご応募ください。

では。

4
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?