Expo でモバイル小売アプリをモダナイズする方法
bitglow が Expo Prebuild を使って DEPOT の React Native アプリをどうモダナイズし、アップグレード時間を 80% 短縮、パフォーマンススコアを 36 から 90 に引き上げたかを紹介します。
日本語
コピー

本記事は Jonathan Bones によるゲスト投稿です。彼は bitglow のシニアフロントエンドデベロッパーで、細部へのこだわり——アップグレード、最適化、デバッグ——を信条としています。彼は GitHub と Bluesky で見つけられます。
…
Expo と React Native を使えば、高速で美しいアプリを構築できる。だが細部に気を配らなければ、とりわけローエンド端末では、その約束はすぐに崩れる。開発者は苛立ち、ユーザーは離れていく。覚えがあるだろうか。まさにこれが、ドイツの大手家居装飾小売業者 DEPOT が直面していた課題だった。

Expo と Expo クラウドサービス の最新機能、そして bitglow チームの React Native に関する深い知見を組み合わせ、私たちは DEPOT の古びた React Native アーキテクチャを作り直し、長年積み上がった技術的負債を一掃し、将来に耐えるコードベースを築いた。
これがなぜあなたに関係あるのか。
技術的負債が開発チームの足を引っ張れば、どんなに優れたアイデアでもユーザーの元へ届かないからだ。私の話だけでは信じられないなら、私たちが手を付ける前のユーザーレビューを見てほしい。
★☆☆☆☆ 「クラッシュしなければ最高のアプリだった。いつも待たされるし、何もかも読み込みに時間がかかる。そもそも読み込めないこともある。」
この1年、アプリを段階的に作り直すにつれて、反応は変わり始めた。今では特定の機能やバグに意見が集中することもあるが、性能の話は明らかに減った。
★★★★★ 「素晴らしいアプリ。近くの店舗をお気に入り登録できて、その店舗に在庫があるかどうかをアプリが表示してくれたらもっといい。」
この変化は一夜にして起きたわけではない。まず既存のコードベースを監査して課題を洗い出し、基準を定めた。自分がどこに立っているかを把握することが、進む道を描く第一歩だ。
コードベースの監査
プロジェクトに加わったとき、監査で見えてきた全体像はこうだった。
-
eject 済みの Expo アプリ——React Native 0.63
-
React Navigation v5。非推奨の v4 互換レイヤーに大きく依存
-
状態管理は Redux、API 呼び出しなどの副作用は Sagas
-
TypeScript v4.4
最新の構成とは言えないが、土台としては十分に堅い。TypeScript の力があれば、大きなコードの塊を安心してリファクタリングしつつ、新たに混入する問題をほぼゼロに抑えられる。
分析の結果、取り組むべき方向は4つに絞られた。
-
Prebuild の採用による React Native のメンテナンス負担の軽減
-
アプリのパフォーマンス最適化
-
Expo のサービスによるビルドと提出の自動化
-
OTA デプロイによる迅速な更新
数週間から数日へ:Expo Prebuild でアップグレードサイクルを加速する
最大の難所は、古くて誰も面倒を見ていない React Native バージョンの維持と更新だった。アプリはまだ React Native 0.63 で動いており、最新の安定版からは何世代も遅れている。Google Play と Apple App Store は target SDK の更新をますます頻繁に求めてくるので、こうしたポリシーの期限に間に合わせるだけでも一苦労だ。
さらに、React Navigation v5 とその互換レイヤーへの依存——そもそも暫定的な措置にすぎない——が、コア依存関係のアップグレード時に紛れ込む厄介なバグのリスクを高めていた。
最初の何回かの React Native アップグレードは手作業で行った。React Native 開発者コミュニティは毎年の State of React Native 調査でこれを課題に挙げているが、Upgrade Helper が登場してからここ数年、アップグレード作業はずいぶん扱いやすくなっている。
注:Expo は最近、アプリのアップグレード用の Claude Code skill を公開した。私たちはまだ試していないが、役に立つと聞いている。
とはいえこうした改善があっても、チームは技術的な変更に数週間を費やし、ビルドを QA し、厄介なバグを潰し、リリースを準備しなければならなかった。この貴重な開発時間は、実質的にフレームワークが要求するネイティブコードの管理に消えていた。
スプリント期間中は、これが停止時間と見なされることも多い。機能開発を止めてアップグレードに集中するからだ。しかもアップグレードには通常、ビジネス目標や納期に悪影響が出ないよう、追加の調整とリスク管理が要る。手を抜かずにこのプロセスを速める方法はないものか。
Prebuild の採用でアップグレードを加速する
Expo が 2022 年の app.js conf で行った Continuous Native Generation に関する基調講演をきっかけに、クライアントプロジェクトで expo prebuild を採用することを真剣に検討し始めた。ios/ と android/ のネイティブプロジェクトを手作業で管理する代わりにネイティブのフレームワークコードを自動生成すれば、アップグレードはより短く、より予測可能になり、機能の「停止時間」も減る。
DEPOT へのこの提案を裏付けるため、私たちは過去のプロジェクトでの経験を踏まえ、手動更新にかかった時間、ネイティブと非ネイティブの依存関係の数、コードベース全体の規模といった要素から試算した。そこから2つの見積もりが導かれた。移行そのものに必要な工数と、将来のアップグレードで節約できる時間だ。
結論から言うと、移行にかかる時間はおおよそ React Native のアップグレード1回分に相当する。その見返りとして、今後のアップグレードは最速で従来の20%の時間で済み、アップグレード周期は数週間から数日に短縮される。
これまでに React Native CLI から Expo への移行を何度もこなし、成功させるために必要な細部まで把握している。Expo チームとコミュニティによる継続的な改善もあり、移行はますます予測可能で効率的になっている。実際、多くのプロジェクトにとって初期投資は React Native のアップグレード1回分とほぼ同じで、リターンはその後のアップグレードのたびに効いてくる。
移行と同時に複数の React Native バージョンを上げる場合は、なおさらだ。DEPOT がまさにそうだった。従来なら React Native を1バージョン上げるのにかかる程度の時間で移行全体を終え、しかも3バージョン分を上げた。結果として即座に時間を節約でき、それ以降のアップグレードも速く、気楽になった。
移行の準備
まず依存関係の監査から始めた。package.json の各項目をネイティブか純粋な JS かに分類し、unimported で未使用のライブラリを洗い出した——最初のラウンドで @react-native-voice/voice、isomorphic-fetch、traverse を削った。積み上がっていた技術的負債も返済した。長らく放置していた @react-navigation/compat レイヤーをついに削除し、StackActions と NavigationActions への参照を useNavigation hook を使う形にリファクタリングして、より新しい React Navigation へのアップグレードの道を開いた。
使用頻度の低い依存関係(参照数3以下)も洗い出し、いくつかを最小限の自作実装に置き換えた。たとえば react-native-multi-tap-component はネイティブの RN コンポーネントに置き換えた。依存関係をここまで絞り込んだことでネイティブ統合のリスクが大幅に下がり、DEPOT アプリを Prebuild 採用に向けて準備するうえで最も効果の大きい一手になった。
ビルド設定の構築
package.json を整理したあと、Expo config-plugins リポジトリを参考に、残ったネイティブパッケージが config plugin に対応しているか一つずつ確認した。ほとんどのネイティブ依存には公式またはコミュニティのプラグインがすでにあった——良い兆候だ。ないものは自分でカスタム config plugin を書く必要があったが、実際に書いてみると驚くほど簡単だった。
Expo のドキュメントはよく書かれていて、既存の config plugin も参考にしながら、数時間で残りのネイティブ SDK のサポートを用意できた。大きな障害になると思っていたが、あっさり乗り越えられ、先へ進む自信にもなった。
移行は段階を踏んで進めた。android/ と ios/ ディレクトリを .gitignore に追加し、アプリのエントリを最小限のレンダリングに簡素化した。こうすれば、TypeScript やビジネスロジックの問題に取り掛かる前に、ネイティブ統合の復旧に集中できる:
export default function App() {
return (
<View style={styles.container}>
<Text>If you can see this the app builds and starts</Text>
</View>
);
}
次に最新の Expo SDK をインストールし、expo-dev-client を追加して、npx expo install expo --fix を実行し、ネイティブライブラリを更新して設定をインストール済みの SDK バージョンに合わせた。app.json は app.config.ts にリネームし、APP_VARIANT 環境変数に応じて異なるビルドフレーバーを有効にできるようにした:
import { ExpoConfig } from "expo/config";
const name = {
development: "DEPOT (dev)",
preview: "DEPOT (preview)",
production: "DEPOT",
}[process.env.APP_VARIANT ?? "development"];
export default (): ExpoConfig => ({
name: name!,
version: "6.1.0",
// ...other properties
});
最初の iOS ビルドは成功した。🎉 Android ビルドでは Gradle のエラーがいくつか出た。スタックトレースを追うと、原因はすぐに判明した。古い Emarsys SDK のバージョンが、生成されたネイティブプロジェクトと互換性がなかったのだ。対処は単純で、SDK をアップグレードし、ビルド成果物とキャッシュを消して再ビルドするだけだった。
アプリ機能の復旧
ネイティブ統合が整ったら、次は元のアプリコードを戻す番だったが、ここでブロッカーが見つかった。プロフィールタブへ遷移すると、トランジションが明らかにカクつき、そのままクラッシュする。
スタックトレースも再現した挙動も、アプリのロジックではなくナビゲーション層を指していた。そこで選択を迫られた。メンテナンスされていない v5 にパッチを当てる時間を取るか、技術的負債と向き合って最新のサポート対象バージョン(v7)へ上げるか。非推奨の内部実装にパッチを当て続ける長期的なコストを考え、アップグレードを選んだ。これだけでブログ1本書く価値がある。
アプリのアップグレード ROI
未使用で壊れやすい依存を削り、ナビゲーションと SDK のバージョンを上げ、Prebuild でネイティブプロジェクトファイルを自動生成するようにしたことで、リスクが高く手間のかかるワークフローを、再現可能で自動化されたプロセスに変えられた。
効果はすでに出ている。アップグレードにかかる時間(TTU)は短くなり、開発者の進みも明らかに速くなった。Expo と React Native のアップグレードは以前の20%の労力で済むようになり、チームはネイティブの問題に追われるのではなくプロダクトのタスクに集中できる。運用面でも、Target API level の要件に関するメールが以前ほど緊張の種ではなくなった。
パフォーマンスのボトルネックを解消する
ネイティブ依存の統合が片付いたら、今度は JS 側のパフォーマンス問題が残っていた。着手前に、進捗を測る信頼できる基準が要る。そこで Maestro で典型的なユーザーフローを一通り作り、Flashlight を使って Lighthouse ベースのパフォーマンススコアを取得し、JS スレッドとネイティブスレッドの使われ方をカバーした。以下は、カテゴリ詳細ページへ遷移して結果をページングする Maestro スクリプトの抜粋だ:
- launchApp
- assertVisible: "Entdecke dein DEPOT"
- tapOn: "Deko & Wohnen"
- tapOn: "Kerzen & Lichtobjekte"
- tapOn: "Kerzen"
- tapOn: "Stumpenkerzen"
- waitForAnimationToEnd
# Title of first product in "Stumpenkerzen" PLP
- assertVisible: ${PRODUCT_TITLE}
# Imitates a fast scroll to bottom of listing page
- swipe:
start: 50%, 75%
end: 50%, 25%
duration: 40
- assertVisible: "Mehr Produkte sehen"
- tapOn:
text: "Mehr Produkte sehen"
index: 1
- waitForAnimationToEnd
- scroll
- scroll
- scroll
代表的なエントリーモデル(Xiaomi Redmi 9、Android 12)でベースラインを計測したところ、初期スコアは36だった。改善の余地は明らかにあるが、重要なのはこれが私たちの観察結果と、顧客フィードバックにあった商品一覧ページのスクロール問題を裏づけたことだ。

次は進捗を測る目標スコアを決める番だ。現実的なラインとして85を選んだ。これ以上を狙うと労力が倍増するわりに、ユーザーが体感できる差はごくわずかだからだ。ベースラインが固まったので、主要な領域のボトルネックを体系的に洗い出して潰していった。
画像の最適化
蓋を開けてみれば、ここが最も効果の大きい最適化のひとつだった。商品一覧ページでは大きすぎる画像を描画していて、ダウンロード時間を伸ばすだけでなく、メインスレッドでのデコードとリサイズがメモリを圧迫していた。これがCPU負荷に直結し、商品一覧ページのTime to Interactive(TTI)を押し下げていた。
そこでメディアURLの画像リサイズパラメータを使い、デバイスが適切なサイズの画像をダウンロードするようにした。処理を前段に寄せたことで、UIスレッドのデコードコストが大きく下がり、CPU負荷もそれに追随して下がった。

ネットワーク効率
アプリはリストに表示する商品ごとに詳細を取得し、商品詳細ページ(PDP)へ遷移するともう一度同じデータを取りに行っていた。背景には3つの問題がある。
リクエストのバッチ処理がない → 商品一覧ページのマウント時に、集約された1本のリクエストではなく、N本の独立したリクエストが一斉に飛ぶ
クライアント側のキャッシュが弱い → すでにメモリ上にあるデータを繰り返し取得する
永続化のレイテンシが高い → redux-persist 経由の読み書きを AsyncStorage が支えており、JSスレッドに余計な負荷がかかる
これらの問題に対しては、商品APIのリクエストをバッチ化し、Redux Saga + Redux PersistからTanStack Queryへの移行を始めた。後者は強力なキャッシュ層をすぐに使える形で提供してくれる。これで重複した商品取得は即座になくなり、ネットワーク負荷は目に見えて減り、商品一覧をスクロールしているときのページ遷移も格段に滑らかになった。
レンダリング性能
プロファイリングの結果、JSスレッドの時間の大部分が、リストコンポーネントで繰り返される不要な再レンダリングに食われていることがわかった。これらのレンダリングのかなりの割合は、不安定なpropsと、レンダリングループの中で直接行われていた高コストなデータ操作が引き起こしていた。
対処として、高コストな操作をデータ取得層へ移し、レンダリングループにはそのまま表示できるデータだけが渡るようにした。あわせてピンポイントのmemoizationを行い、安定したプリミティブ型のpropsを使うよう促した。たとえば商品カードにはproductオブジェクト全体ではなく、安定したproduct IDを渡す。
さらにコードベースをモダナイズし、残っていたclassコンポーネントを関数コンポーネントへ移行した。ボイラープレートが減って一貫性が増すだけでなく、React Hooksが使えるようになり、よりきめ細かな状態管理、更新範囲のより精密な制御、そして保守コストの削減につながった。
これらの変更が積み重なり、JSスレッドの負荷は大きく下がり、各PLPのスクロール性能が改善した。
パフォーマンス最適化の成果
上記の最適化をひとつずつ段階的に入れていくことで、Flashlightスコアは90に達した。平均CPU使用率は48%改善、高CPU使用時間に至っては91%という大幅な改善だ。結果として、商品カタログを閲覧するときのスクロールは明らかに滑らかで、反応も良くなった。

ExpoのサービスでCI/CDをシンプルにする
モダンなモバイルアプリ開発は、コードを書くだけでは終わらない。速く、確実で、再現性のあるデリバリーが求められる。だが、ビルド、署名、バイナリ配布のためにローカルのツールチェーンを手作業でこねくり回すと、あらゆる工程に摩擦と複雑さとリスクが持ち込まれる。私たちの答えは、Expoのクラウドサービスを土台にした完全自動のビルド・デプロイパイプラインをGitLab CIに組み込むことだった。
流れはこうだ。コードが保護ブランチ(main または release/)にマージされれば、ワンクリックでiOSまたはAndroidのプレビュービルドを起動できる。ローカル環境のセットアップは不要で、各種クレデンシャルの管理も要らない。
これでビルドの完了を待つ手間、環境まわりのトラブル対応、バイナリの手動共有がなくなる。パイプラインがビルドマニフェストをExpoにアップロードし、URLをTeamsに共有すれば、あとはExpoが引き受けてくれる。デザイナー、QA、プロダクトチームは最新のプレビューをすぐに手にでき、開発者は邪魔されず次のタスクに進める。

App StoreやGoogle Playへのリリースも同じように滑らかだ。Gitでreleaseにタグを打てば本番ビルドが自動で走り、Expoの自動提出機能によってビルド済みバイナリがそのまま各ストアへ提出される。ここにも手作業は一切ない。以下はiOS本番ビルドのjobだ。

効果はどうか。専用のビルドハードウェアは不要になり、人為的ミスのリスクが下がり、貴重な開発時間をより多く確保できるようになった。
リリースフローは予測可能で再現性があり、ビルド成果物は関係者やストアへ直接届く。DEPOTにとってこれはそのまま、運用コストの削減、市場投入までの時間短縮、そしてローカルのビルドツールチェーンと格闘するのではなく機能開発に集中できる開発チームを意味する。
OTAアップデートで売上を守る
モバイルからの収益がある事業にとって、OTA(over-the-air)アップデートがいかにゲームチェンジャーかを見事に示す実例がある。それは金曜の夕方、DEPOTのコンテンツ管理チームが、大きな売上増が見込める週末の大型セールを詰めているときのことだった。そこへ深刻な問題が飛び込んできた。アプリ内のcampaign keyの設定ミスで、セール開始の数時間前にプロモーション全体が台無しになりかねない状況だった。
従来のやり方なら、これは危機になっていた。チームは、イベントを延期して週末のセールを逃すか、リスクのある場当たり的な修正を無理やり出すか、あるいは週末じゅう緊張しながら App Store の審査がホットフィックスを承認するのを待つか、厳しい選択を迫られていた。どの道を選んでも、売上の損失、ステークホルダーの不満、そして神経をすり減らしたチームが残るだけだ。
しかし今回は、すでに OTA 更新の仕組みを導入していたおかげで、問題を即座に診断して解決できた。数分以内に修正がそのままユーザーの手元へ届いた。アプリストアへの再申請も、待ち時間も、顧客体験への支障もない(短い「重要な更新」のバナーが一瞬表示されただけだ)。まさにこれが React Native の真価である。バグは JavaScript 層に閉じ込められていたため、ユーザーがアプリを再起動しさえすれば、すべての端末にシームレスに更新を届けられる。マーケティングキャンペーンは予定どおり公開され、チームはストレスのない週末を過ごし、そして何より、DEPOT は見込んでいた収入を一切逃さなかった。
これは技術的な勝利にとどまらず、ビジネスの保険でもある。技術的な障害が売上目標の達成を妨げうることを、今回の経験ははっきりと示している。EAS Update のような OTA 更新システムは戦略的な資産であり、市場の要求に即座に対応する機敏さをビジネスに与え、重要な問題をいつでも修正し、収入を守ることができる。しかもそのすべてが、アプリストアの審査サイクルに縛られることなく実現する。変化の速い現代の小売業において、これは単なる技術的な優位性ではなく、競争力を維持するための必須条件だ。
まとめ
DEPOT との取り組みは、React Native が適切な技術的専門性と組み合わされば、低スペック端末であっても優れたパフォーマンスと安定性をもたらすことを証明している。技術的負債を体系的に解消し、アーキテクチャをモダナイズし、EAS で重要なワークフローを自動化することで、私たちは脆いレガシーコードベースを、堅牢でスケーラブルな将来性のあるアプリへと変えた。
成果は明らかだ。リリースサイクルの短縮、開発者の生産性向上、そして肯定的なフィードバックを少しずつ増やし、ユーザーの信頼を取り戻しつつあるユーザー体験。何より、こうした改善によって DEPOT は、需要に合わせて拡張できない技術スタックに足を引っ張られることなく、ビジネス上の要請により迅速に応え、新しい機会をつかめるようになった。
同じような課題を抱えているチームや、モバイルのコードベースを将来に備えさせたいと考えているなら、ぜひ力になりたい。この記事は React Native と Expo の専門家、bitglow がお届けしました 🩵