Hello Tech

AutoReserve 等を開発する株式会社ハローのテックブログです。スタートアップの最前線から本質的な価値を届けるための技術を紹介します。

ファイルベースのルーティングによるReact Native開発の未来

javascripter です。ハローでは、プロダクトのローンチ前からAutoReserve の開発に関わっています。

ハローでは、開発効率の最大化のため継続的に新しい技術を取り入れています。 今回は、AutoReserveのReact Native製アプリにExpo Routerという、Next.jsのファイルベースのルーティングに似たルーターのライブラリを導入した事例を紹介します。

作業時点でのExpo Routerの最新版stableがv1だったため、解説もv1についてになります。現在はExpo Routerはv2がリリースされているため、最新版では一部、記事での解説と異なる可能性があります。

AutoReserveアプリの技術スタック

AutoReserveのネイティブアプリはReact Nativeで書かれており、またウェブ版は、Reactで書かれています。 今回はReact Native製のアプリについて詳しく説明します。

AutoReserveで主に使っている技術は

  • React Native
  • Expo(React Nativeのライブラリやクラウド上のマネージドなサービスを提供するライブラリ・フレームワーク)
    • 高品質で継続的にメンテナンスされているReact Nativeのライブラリ集
    • EASというアプリのビルド、証明書の管理からOTAによるコードの即時配信が可能なマネージドサービス
    • Expo Prebuildというコードによるネイティブコードの自動生成の仕組み
      • AutoReserveではネイティブコードの生成・設定から変更、管理までを全てJSのコードで行なっています。
    • などがあります。
    • その他、AutoReserveではExpoのエコシステムを最大限活用し常に最新のReact Nativeのバージョンに追随できるようにしています。
  • react-navigation

です。

react-navigationとは

AutoReserveではルーティングや画面遷移はreact-navigationを使用しています。

react-navigationのルーティングの定義はReact Routerなどに似ており、Reactのコンポーネントを使用して各画面を定義するようになっています。

ルーティング定義の例:

const Stack = createNativeStackNavigator();

function App() {
  return (
    <NavigationContainer>
      <Stack.Navigator>
        <Stack.Screen name="Home" component={HomeScreen} />
      </Stack.Navigator>
    </NavigationContainer>
  );
}

export default App;

Expo Routerとはなにか

Expo RouterはExpoが新しく出したreact-navigationをベースにしたルーティングライブラリで、主な特徴としてルーティングの定義を全てファイルベースで行い、対応するURLが自動で定義されるということが挙げられます。

URLベースのルーティングを用いる利点としては、下記があります。

  • URLベースのルーティング定義のため各画面への遷移を文字列のみで表現できる
    • 画面の定義がディープリンクと1:1で対応します。
    • 全ての画面遷移をURLとして表現できるため、テストなども非常に書きやすいです。通常の遷移もURLベースで行われるため、Deep LinkがSingle Source Truthとして機能し、テストと実際の使用時の乖離も発生しません。
  • 画面に関連したレイアウトもファイルベースで静的に予測可能な形で定義されているため、画面単位での自動でBundle Splittingを行うことができる(Async Routesと呼ばれる機能)
  • 画面遷移を型安全に行える
    • 次期バージョンのExpo Router v2からはファイル構造を元にリンクに自動で型がつくようになります(Typed Routesと呼ばれる機能)。
      • 存在しない画面への遷移などを静的に型検査できるようになります。

Expo Routerの概要

ファイル構造の例を挙げると、

src/
  app/
    index.tsx
    (auth)
      (tabs)
        (home, account)/
          index.tsx
          account.tsx
          users
            [userId].tsx.tsx
          _layout.tsx
        _layout.tsx
    sign_in/
      index.tsx
    _layout.tsx

このような形になり、_layout.tsxファイルでそのディレクトリで使用するレイアウト定義(スタック、タブなど)を行い、account.tsxなどそれ以外のファイル名で画面定義と同時にルーティング定義を行うことができます。

また、(auth)などと括弧の付いたディレクトリは、ディレクトリ名をURLに露出させずレイアウト定義を行いたい場合に使用します。

各画面にはDeep Link用のURLが自動で付加され

/
/account
/users/:userId
/sign_in

といった遷移先が自動で定義されます。

Next.jsのファイルベースのルーティングとよく似ていますが、ネイティブアプリ特有の要件として、同時にレンダリングされる画面が複数あるということが挙げられます。例えば、スタックベースのルーティングでは、前の画面とpushした先の次の画面がスタックされ、閉じた時も前の画面の入力状態・スクロール位置を維持したまま元の画面に戻ることが可能になっています。

画面遷移で複数画面が同時にメモリ上に存在する例

Expo Routerを導入する大きなメリットとして、全ての画面に対してDeep Linkを自動で生成できるということが挙げられます。

例えば、素のReact Navigationではlinkingというオブジェクトを用いて、画面のルーティングと別にDeep Linkを定義する必要があります。

例:

const config = {
  screens: {
    Home: 'home',
    UserShow: 'users/:userId',
    SignIn: 'sign_in'
  },
};

const linking = {
  prefixes: ['https://example.com', 'example://'],
  config,
};

function App() {
  return (
    <NavigationContainer linking={linking} fallback={<Text>Loading...</Text>}>
      <Stack.Navigator>
        <Stack.Screen name="Home" component={HomeScreen} />
        <Stack.Screen name="UserShow" component={UserShowScreen} />
      </Stack.Navigator>
    </NavigationContainer>
  );
}

コンポーネントベースで定義したルーティングとは別にlinkingでそれぞれの画面に対応するURLを定義しないといけません。

この仕組みの問題点は下記のとおりです。

  • 画面を定義するたびにlinkingオブジェクトも同時に更新する必要があるため、メンテナンスが大変
  • 型安全にDeep Linkを定義できない
    • ルーティングの定義が動的になっているため、ディープリンクが正しい画面を指しているかなどを型的に安全に保証することが仕組み上難しい
  • スタックがネストした画面や、タブへの遷移の場合など、linkingオブジェクトが複雑
  • どのような遷移パターンで画面に到達することができるのかぱっと見で把握することが難しい

Expo Routerを用いると、ディレクトリを見るだけでどのような画面が定義されてるかわかるだけでなく、全ての画面にURLが割り振られるようになるため、Deep Linkの定義漏れがなくなります。

またWebと同様に、画面のパラメータをURLのパラメータとして付加することができます。各パラメータは文字列として渡ってくるため、Webアプリケーションとほとんど同様の仕組みになると思うとわかりやすいと思います。

const router = useRouter()
const params = useLocalSearchParams()

const userId = Numeber(params['userId'])

const onPressUserFollowing = (userId: number) => {
  router.push(`/users/${userId}/following`)
}

return <User userId={userId}>

AutoReserveへの導入

AutoReserveのコードベースはかなり大きく、画面は90個近くあります。 このため、Expo Routerの導入もかなり大規模になりました。

画面定義:

PR:

Expo Router導入のPR

移行作業

具体的に移行で必要になった作業は下記の通りです。

  • 各画面のURLの定義を行う
    • app/ディレクトリ以下に、アプリで使用している全ての画面定義のファイルを作成する。
      • 既存のDeep Linkの定義とのURLの互換性を保ったまま、Deep Linkとして定義されていなかった画面定義も全て行う。
  • レイアウトの定義を行う
    • スタックのレイアウト定義、タブの定義、モーダルの定義、各画面のタイトルの定義の移植などに加え、非認証時のログイン画面へのリダイレクトなどもURLベースへ書き換える。
      • (auth)以下のディレクトリ内で非認証状態であれば/sign_inにリダイレクトさせるなど。
  • 各画面への遷移の変更
    • navigationオブジェクトを用いて画面名を指定して遷移していた部分をURLベースに書き換える必要がある。
      • これが一番分量が多く大変。
navigation.navigate('UserShow', { userId })
// --> Expo Routerを導入後
router.push(`/users/${userId}`)

難しかった箇所

タブ内での画面遷移と、タブを非表示にして全画面で遷移する構造をExpo Routerで表現するのが特に難しかった箇所として挙げられます。

例えば、AutoReserveにはホームタブとアカウントタブなどがあり、ホームタブからレストラン詳細画面に遷移した場合は、タブは非表示になりレストラン詳細画面が全画面で表示されます。 一方で、アカウント詳細画面から予約一覧画面に遷移した場合など、タブが表示されたまま、タブ内で表示される画面があります。

タブ内の遷移

まず、一つ目の問題として、現在のタブ内で画面を開くことがExpo Routerでは簡単にできないという問題がありました。

/*
(tabs)/
  (home, account)/
    index.tsx
    account.tsx
    users/
      [userId].tsx
*/
// /(tabs)/(home)タブ内(ホーム画面)にいる場合:
// (tabs)/(home)/users/:userIdに遷移させたい
router.push(`/users/${userId}`)
// 一方、/(tabs)/(account)タブ内(アカウント画面)にいる場合、
// (tabs)/(account)/users/:userIdに遷移させたい
router.push(`/users/${userId}`)

AutoReserveではホーム画面内からも、各ユーザの画面に到達することができ、またアカウント画面からもユーザ画面にたどっていくことができます。

この場合、単純に/users/:userIdに遷移させると、Expo Routerでは最初にマッチしたルートに遷移する仕様なため、常にホームタブ内で開かれてしまいます。

/(tabs)/(home)/users/:userIdなど、括弧を含めたパスを指定することで指定したタブ内で開くことができますが、ユーザ画面はホーム画面、アカウント画面など複数のタブで再利用される画面のため、ハードコーディングすることができない、という問題がありました。

このため、AutoReserveでは、現在いるタブを元に自動でprefixをつけるという仕組みを導入しました。

function useRouter() {
  const segments = useSegments() // ['(tabs)', '(account)', 'account']など表示している画面のパスが配列として返ってくる
  const router = useExpoRouter()
  return {
    push: (href) => {
      // hrefにオブジェクトが渡ってくる場合などの対応は省略
      let prefix = ''
      // ホームタブ内にいる場合は自動でprefixをつける
      if (segments.include('(home)')) {
        prefix = '/(tabs)/(home)`
      } // ...その他のタブの場合
      router.push(`${prefix}/${href}`)
    },
    // ...
  }
}

タブ内全画面の遷移

元々、素のReact Navigationではタブを非表示にし全画面で表示する画面は、タブの外側に別のNavigatorとして定義していました。

function Top() {
  return <Stack.Navigator>
    {/* ... */}
  </Stack.Navigator>
}

function Tabs() {
  return <Tab.Navigator>
      {/* ... */}
  </Tab.Navigator>
}

function App() {
  return <Stack>
    <Stack.Screen component={Top} />
    <Stack.Screen component={Tabs} />
  </Stack>
}

同じ構造をexpo-routerで表現した場合:

(top)/
  restaurants/
    [restaurantId].tsx
(tabs)/
  (home,account)
    users/
      [userId].tsx

expo-routerを用いた場合も同様の構造として表現しようとしたのですが、この構造をexpo-routerで用いた場合、いくつか問題が発生しました。 まず、(top)以下と、/(tabs)/(home)/restaurants/:restaurantIdの両方に同時にルートを定義する方法がない点。(home, account)などとしてhome, accountの両方に同時にルートを定義することはできますが、階層の異なったルートに同時に定義する方法は見当たりませんでした。

このため、この構造で書く場合、top以下とtabs以下の両方に大量に画面の定義を重複して書かないといけないことになります。 また、もう一つ大きな問題として、パフォーマンスの問題がありました。

上記構造ではタブからtopに遷移して戻った場合などにStack全体の再レンダリングが走り、非常に重くなるという問題が発生しました。 Expo RouterのissueやDiscussionなども一通り検索し関連したissueなども見つけましたが、現状、解決されていないようです。 このため、AutoReserveでは別の方法を採用しました。具体的には、下記のように、全ての画面定義をタブ内に定義する構造にしました。

(tabs)/
  (home, account)/
    restaurants/
      [restaurantId].tsx
    users/
      [userId].tsx

全画面の画面表示は、下記の方法で実現しています。

// /(tabs)/_layout.tsx
export default TabsLayout() {
  const params = useGlobalSearchParams()
  return <Tabs
    tabBar={() => {
      // 画面のパラメータに____tabBarHiddenがついている場合はタブを隠す
      if (params.__tabBarHidden) return null
      return <CustomTabBar />
    }}
  />
}

// 画面
router.push({
  pathname: `/restaurants/${restaurantId}`,
  params: {
    __tabBarHidden: '1'
  }
})

実際には、全画面の画面表示から遷移する先は常に全画面として表示する必要があるため、アプリ内に定義したuseRouter()のラッパー内で、全画面の場合のパラメータを引き継ぐなどの処理を追加しています。

まとめと今後の展望

AutoReserveではExpo Routerを導入することで、アプリの遷移をNext.jsのようにファイルベースで定義し、全画面にディープリンクを自動で持たせることができるようになりました。 改修自体は合計300ファイル以上、+7035行 / -5448行にわたる変更で、コミット数も154と比較的大規模なリファクタリングとなりました。

今回の改修は機能開発を止めることなく、エンジニア1人で数週間かけて行いました。 今回のExpo Routerの導入の意図としては、開発上のメリットもありますが、プロダクト面での改善も目的としています。

AutoReserveはアプリ版とウェブ版があり、ウェブとReact Nativeアプリのコード共通化による同時展開 という記事で紹介したとおり、大部分の実装の共通化を行なっています。 アプリのDeep Linkのメンテナンスが大変・ウェブとアプリで同一の機能を実現できていないケースがあるなどの理由から、Universal Link(iOSなどで、ウェブ版にアクセスしたときに自動で対応する画面がアプリで開く機能)の導入をやめたという経緯があります。

Expo Routerを導入することで、アプリの実装がウェブに近づき、全ての画面にURL経由でアクセスできるようになります。これにより、Universal Linkの導入など、アプリ・ウェブでのシームレスな切り替えなどユーザ体験の向上が容易に実現できるようになります。

今後の展望としては

  • アプリとウェブのURLを完全に互換性を持たせ、実際にUniversal Linkを有効にする。
  • ウェブ版は現在React Routerを使用しているが、さらに実装の共通化を進めていき、ウェブ版とアプリ版での機能差をなくす といったことが挙げられます。

また、現時点ではまだ可能性の一つでしかありませんが、今後Expo Routerの機能が充実することによって、アプリとウェブ版を完全統合できるようになるかもしれないと考えています。 ハローでは、高速なプロダクト開発と開発体験の追求を愛する本物のエンジニアを募集しています。 少しでも気になる方は気軽に javascripter にDMでお声がけください!

採用情報 - 株式会社ハロー