React NativeとReduxで野球スコアリング


野球スコアリング機能のリリース

今から1年以上も前になるが自社プロダクトで野球スコアリング機能をリリースした。おかげさまで、リリース以降多くのアマチュア野球チームで利用されるようになり、今ではアプリの目玉機能の1つになっている。私はiOS/Androidアプリ側の開発を主に担当していたので、その実装の大枠を紹介する。

APIとアプリの責務の分離

アプリとAPIは明確に役割が分かれている。おかげでお互いに自分の責務に集中して開発することができた。

アプリでやること

  • ユーザーが野球のプレイを入力するためのUIを提供する。
  • ユーザーが入力したプレイ(打撃、投球、走塁、守備)をAPIへ送信する。
  • APIから返却されたスコア(得点、アウトカウント、塁上の走者、成績、etc)をアプリで表示する。

APIでやること

  • アプリから送信されたプレイをもとにスコア(得点、アウトカウント、塁上の走者、成績、etc)を計算する。
  • アプリに、スコアの計算結果を返却する。

GOで書かれたAPIはRESTで設計されている。レスポンスはJSON形式で返却される。APIの開発は弊社CTOが1人で担当していて、実装がどうなっているか私も知らない。

React Native

アプリはReact Nativeで実装していて、ES6で書かれたソースコードの95%はiOS/Androidで共用している。当初、野球の混みいったUIをReact Nativeでどこまで表現できるのかは、未知の領域だったが、アニメーション、ドラッグ&ドロップ、処理速度も問題なく実装できた。React Nativeでのアニメーションやドラッグ&ドロップの処理については、すでにいろんな記事があるのでここでは説明しない。

Redux

Stateの管理はReduxを採用していて、この野球スコアリング機能は、ただひたすらにReactとReduxのサイクルに準拠した繰り返し作業の結果である。

1. Componentはpropsとして渡ってくるstateをみて表示の出し分けをする。
2. Componentから発火するActionを通じreducerでstateに変更を加える。(APIへの通信も含む)
3. 1.に戻る  

野球は複雑なルールのため、stateはかなり巨大になってしまっているが、stateの変更は必ずActionを通じて行われるというReduxの制約によって、stateの変化をActionのログから1つ1つ追えることができて安心感があった。

Component構成

野球のスコアリング機能の特徴は1球ごとの詳細なプレイ(スポーツの世界ではPlay-by-Playと呼ばれる)を記録できること。画面遷移は、ナビゲーションライブラリなどは使わず、ベースコンポーネント上に重なった画面レイヤーをpropsの値をみて出し分けることで実現している。画面レイヤーはReact Componentのことだが、画面上に重なるイメージでそう呼んでいる。


画面レイヤーの重なりのイメージ。この他にもたくさんのレイヤー、モーダルがベースコンポーネント上にのっかっている。

ベースコンポーネント

プレイを記録する操作を開始する基盤となり、各種モーダル、レイヤーの表示/非表示を制御するコンポーネント。表示/非表示の判断はpropsから渡ってくるstateを参照して判断。ベースは"基本"という意味で、野球の塁のことではない。

// ベースコンポーネント
class BaseballPlayByPlayScore extends Component {
  render() {
    const {
      fieldingLayer,
      runnerLayer,
      selectPlayModalVisible,
    } = this.props;

    return (
     {/* 野球グラウンドの背景画像 */}
     <Image source={groundImg}  ... />

     {/* 守備/打者/投手選手の表示 */}
     <Player position={1} ... />
     <Player position={2} ... />

     {/* 投球ボタン */}
     <PitchButton ... />

     {/* Undo/Redoボタン */}
     <Undo ... />
     <Redo ... />

     {/* 守備記録レイヤー */}
     {fieldingLayer
       && (
         <Fielding ... />
       )
     }

     {/* 走塁プレイ記録レイヤー */}
     {runnerLayer
       && (
         <Runner ... />
       )
     }

     {/* プレイ選択モーダル */}
     <SelectPlayModal isOpen={selectPlayModalVisible} ... />
    );
  }
}

走塁記録レイヤー


走者をドラッグし、ベース付近にドロップするとセーフかアウトかを選択できるUIを持ったレイヤー。

コンポーネント分割するとドラッグが速くなった

当初、走塁記録レイヤーはコンポーネントとしては独立しておらず、ベースコンポーネントの中に直接書いていたのだが、リファクタリングのため、コンポーネントとして分割すると、ドラッグの操作が体感的にもわかるぐらい速くなった。

走者をドラッグするたびに、走者の座標(x,y)を取得し this.setState() を実行して、座標データを this.state に保持していたので、その都度 render が走り、大きなベースコンポーネントの中でDOMの差分更新が行われて処理が重くなっていたのだと思われる。コンポーネントを分割して、小さくすることでドラッグが速くなった。これを契機に、コンポーネントとして分割できるものはどんどんしていった。

// ベースコンポーネント
class BaseballPlayByPlayScore extends Component {
  componentWillMount() {
    // ドラッグの処理をゴニョゴニョ
  }
  render() {
     {/* 走塁プレイ記録 */}
     {/* 最初は直接走者アイコン直接ベースコンポーネントに記述 */}
     {runnerLayer
       && (
         <View>
           <RunnerIcon ... />
           <RunnerIcon ... />
           <RunnerIcon ... />
         </View>
       )
     }
  }
}

↓コンポーネントに分割するとドラッグが爆速!

// ベースコンポーネント
class BaseballPlayByPlayScore extends Component {
  render() {
     {/* 走塁プレイ記録レイヤー */}
     {/* コンポーネントに分割。ドラッグ処理もコンポーネントの中に内包 */}
     {runnerLayer
       && (
         <Runner ... />
       )
     }
  }
}

巨大なStateとReducer

野球のプレイパターンを全て網羅しているため、Reducerでのプレイごとの処理分岐の数がかなり多い。
さらに野球のスコアを記録するUIを構築する上で状態遷移が多岐にわたるため、Stateが持つプロパティも多い。
ここらへんは、もう少し見通しの良いコードに改善できる余地はあるかもしれない。
とはいえ、前述した通りReduxのデータフローにのっとっているので、根幹となる構造は非常にシンプルなものになっていると思う。