【React Native + Expo】オフライン対応について振り返ってみた

はじめに

はじめまして、株式会社カミナシのエンジニア @MuiKnskです。

カミナシは3年目で最近はソフトウェアテスト、ソフトウェア品質に興味を持っています。
何故かと言うとバグはゼロに出来ないと思っていて、如何に減らしてサービスの品質を担保していくかに興味を持つようになりました。
この辺り知見を持ったエンジニアを個人的にジョインして欲しいな…なんて思ってます。

今回は『カミナシ』の課題「オフライン」について、何故オフラインで動作する必要があるのかと、これを解決するために選択した技術を振り返りながら書きたいと思います。
『カミナシ』はオフラインでどうやって動作を担保しているのか、悩んでいる方がいたら参考になれば嬉しいです。

開発環境

  • React Native (0.60.22) + Redux
  • Expo SDK 37
  • TypeScript (3.7.2)

課題

  • Wi-Fi環境がないお客様にサービスを提供する
  • オンラインと同等の機能をオフラインで実現する
なぜオフラインが必要なのか

カミナシは工場などのWi-Fi環境がないお客様にサービス提供しているケースが多々あります。
Wi-Fi環境があっても通信環境の問題で途切れたり、お問い合わせ頂いて打ち合わせしても、Wi-Fi環境ないので…。ちょっと無理ですね。ということがあります。
そうなるとサービスの良さを知ってもらう前に失注してしまうのでオフライン対応が必要になっています。
そのためカミナシでは創業当時からオフラインを想定したアプリを提供しています。『カミナシ』も初期段階から実装を行っています。

オフラインDBは何を使ったのか

選択肢の候補として上がったのが以下になります。

  • Realm
  • Expo内にあるSQLite
Realm

ピボットする前のプロダクトでRealmモバイルデータベースを使っていたので、その経験を生かしてReact Native (Expo)でも利用しようと思いました。

が…

f:id:kaminashi-developer:20200921220105p:plain

「サポートされていない!」

Realmに対する知見があったのでどうにか利用したかったのですが残念ながら採用できませんでした。Expo対応を望まれるコメントはあったのですが…未対応ですね。 expo.canny.io

SQLite

Realmが利用できないので、SQLiteを採用した理由は以下の点になります。

  • Expo側が提供している
  • ドキュメントが豊富にある
  • クエリライクに書けるので学習コストが低い

docs.expo.io

振り返ってみるとSQLite一択だったのかも知れません。

オフラインDBを使って何を提供するのか

オフライン対応は必須で、技術的にSQLiteを使うところまで決まりました。
あとはオフラインと同等の機能ってどこまでを担保するべきかをビジネスサイドと話し合いながら、いつまでにどの機能が必要かをscrapboxを使って詰めていました。 カミナシではビジネス側と議論する場合、scrapboxというツールを使っています。 f:id:kaminashi-developer:20200921220811p:plain

実装する前に利用者の立場になって、操作フローを想定して書き出しを行ったり、記録をどう見せるべきか等を考察した上で実装を行っていました。
この辺りはいきなり実装しなくてscrapboxに思考をまとめたりすると、あとで第三者に意見をもらって整理できるので良いです。
思考を整理したりする時間って意外と楽しいですよね。

実装方法

実際に『カミナシ』で実装されているオフライン周りの実装について、以下の手順で解説します。

  • SQLiteのDB操作
  • ネットワーク状態、アプリの状態監視してオフラインデータの同期
  • Reduxでオフライン時のAction分岐
SQLiteのDB操作

まずはExpo SQLiteをインストールしましょう。

import * as SQLite from 'expo-sqlite'

docs.expo.io

SQLiteにcreate, selectの関数はないため、全て生のクエリを書いてDB操作を行いました。
基本的な処理は以下の通りです。

export const insert = () => {
  const DB = SQLite.openDatabase('hoge')
  return new Promise<any>((resolve, reject) => {
    DB.transaction(
      (tx: any) => {
        tx.executeSql(
          `insert into test values(?, ?);`, 
          [...values],
          () => {
            // 成功時の処理
          },
          () => {
            // エラー時はロールバックする
            return true
          }
        )
      },
      () => reject(new Error('[insert] transaction failed')),
      () => resolve(true)
    )
  })
}

SQLiteのDB操作については難しいことはしていませんが、オフラインデータのテーブル構造はすべて同じにしています。 理由はDB操作を共通化して、テーブルが増えたときに対応しやすいようにしておきたいということです。

ネットワーク状態、アプリの状態監視してオフラインデータの同期

オフラインデータ同期タイミングについては、以下の2つになります。

  • ネットワーク状態がオフラインからオンラインになったとき
  • アプリの状態がバックグラウンドからフォアグラウンドになったとき

これを実現するためにまず、ExpoのドキュメントにAppState, NetInfoを確認すると、どちらもaddEventListenerを使って状態を監視しています。その場合、Component単位で追加する必要があり、とても手間だと感じアプリ全体(グローバル)で監視できれば簡単になると思いました。

(この辺りはQuipperさんの記事がとても参考になりました!ありがとうございます!)

  • ネットワーク状態を監視してオフライン同期アクションを発火するようにしています。
export default (): StoreEnhancer => <S>(createStore: StoreEnhancerStoreCreator<S>): StoreEnhancerStoreCreator => (
  reducer,
  preloadedState
) => {
  const store = createStore(reducer, preloadedState)

  const handleConnectivityChange = () => {
    Network.getNetworkStateAsync().then((state: Network.NetworkState) => {
      if (state.isInternetReachable) {
        store.dispatch<any>('オフラインデータを同期するアクション')
      }
    })
  }

  NetInfo.addEventListener(handleConnectivityChange)
  return store
}
  • アプリの状態を監視してオフライン同期アクションを発火するようにしています。
export default (): StoreEnhancer => <S>(createStore: StoreEnhancerStoreCreator<S>): StoreEnhancerStoreCreator => (
  reducer,
  preloadedState
) => {
  const store = createStore(reducer, preloadedState)

  const handleAppStateChange = (nextState: AppStateStatus) => {
    if (nextState === 'active') {
      store.dispatch<any>('オフラインデータを同期するアクション')
    }
  }

  // アプリのActiveイベントをトリガーにバージョンチェック
  AppState.addEventListener('change', handleAppStateChange)

  return store
}

あとはcreateStore時にenhancerとして機能を追加すれば完成です。

  • store.ts
import { default as netInfoListener } from './netInfo'
import { default as appStateListener } from './appState'

const store = createStore(
  reducers,
  compose(netInfoListener(), appStateListener())
)

これでネットワーク状態、アプリの状態を監視してオフラインデータを同期するアクションが発火出来るようになりました。

Reduxでオフライン時のAction分岐

通常はReduxのActionを以下のように書くと思います。

  • action.ts
const requestHoge = (hoge: RequestHoge) => {
  return {
    type: 'REQUEST_HOGE',
    hoge
  }
}

これだとオフライン用のActionを用意する必要があり、とても無駄な処理が乱雑するので避けたい問題でした。 リクエストの度に毎回ネットワーク状況の確認をして、オフライン用のif文で処理を分岐させると思うと面倒ですよね。 そこで、Actionのパラメータにオフラインを判別するためのプロパティとActionを追加して、middlewareでオフライン状態をチェックする実装を追加しています。

  • action.ts
const requestHoge = (hoge: RequestHoge) => {
  return {
    type: 'REQUEST_HOGE',
    isOffline: {
      // isOfflineプロパティを追加して、オフラインのときに実行するActionを追加
      type: 'OFFLINE_REQUEST_HOGE', 
    },
    hoge
  }
}
  • store.ts
const offlineMiddleware: Middleware = ({ getState }: MiddlewareAPI) => (next: Dispatch) => async action => {
  if (action.isOffline) {
    const { netInfoState } = getState()
    if (netInfoState.netInfo.isConnected) {
      return next(action)
    } else {
      // ネットワーク状況がオフラインのとき、オンライン時に発火するアクションタイプをオフラインのアクションタイプに上書きしています
      return next({ ...action, type: action.isOffline.type })
    }
  }
  return next(action)
}

const middleware = applyMiddleware(offlineMiddleware)

const store = createStore(
  reducers,
  compose(middleware)
)

custom middlewareを追加することで、オフラインで利用したいActionに対して、isOfflineプロパティを追加するだけでオフライン用のActionを実行することが可能になり、スマートな実装になったと思います!

おわりに

当時のことを思い出しながら振り返ってみました。
オフラインでもオンライン同等の機能を提供するアプリは数少ないのではないでしょうか。
こういったカミナシならではのやりがいを感じれる部分がありますので、難しい課題に対してチャレンジできる環境で楽しんでいます!
私個人としては、誰もしないこと、実装面倒だなと思ってしまうことにやりがいを感じてしまうので、めちゃくちゃ良い環境です。

少しでも興味を持っていただけて、話を聞いてみたい、応募したいという方はwantedlyからお待ちしております!
TwitterのDMでも構いません。よろしくおねがいします!