Rork アプリの Expo SDK を毎年安全に上げ続ける保守設計
去年の年末、私は古い Expo SDK のまま放置していた個人開発アプリのひとつを、二世代ぶんまとめて上げようとして半日を溶かしました。expo-notifications の API が静かに変わり、react-native-reanimated のバージョン整合が崩れ、ビルドは通るのに実機で通知が一切届かない。原因の切り分けに時間を取られ、その日はリリースを諦めました。
そのとき痛感したのは、Expo SDK のアップグレードは「いつかやる大掃除」ではなく、「毎年の定期点検」として運用に組み込むべきだということです。Rork が生成するアプリは Expo(React Native) を土台にしています。生成直後はきれいに動きますが、半年も経てば SDK は新しいメジャーへ進み、放置した差分は静かに積み上がります。
放置せず、年次のアップグレードを淡々と回し続けるための保守設計を、ここでまとめます。私が個人開発のアプリ運用で実際に使っている固定方針・手順・安全網を、そのまま共有します。
なぜ「まとめて上げる」と必ず痛い目を見るのか
二世代ぶんを一度に上げると、変更点が掛け算で効いてきます。SDK 本体・Reanimated・Gesture Handler・各種ネイティブモジュールが、それぞれ別の破壊的変更を抱えたまま同時に動くからです。
差分が大きいほど、失敗したときの切り分け範囲も広がります。1メジャーずつなら「どのモジュールが原因か」を線形に追えますが、2メジャー同時だと組み合わせ爆発で原因が特定しづらくなります。
私の結論はシンプルです。アップグレードは小さく、定期的に、決まったリズムで。これを仕組みにするのがリリーストレインの考え方です。
リリーストレイン: アップグレードを「列車の時刻表」にする
リリーストレインとは、アップグレードを個別の決断ではなく、決まった時刻に発車する列車として扱う運用です。乗り遅れた変更は次の便に回し、無理に今日の便へ詰め込みません。
私は3週間を1スプリントとして、以下のリズムで回しています。
週 やること 狙い
第1週 差分調査・破壊的変更のトリアージ・branch 作成 影響範囲を先に確定させる
第2週 依存更新・コード修正・スモークテスト 壊れる箇所を内部で潰す
第3週 内部配信・実機確認・ストア審査提出 本番影響を最小化して出す
重要なのは、1便で上げる SDK は原則1メジャーまでと決めることです。年に2便動かせば2メジャー、最低でも1便で1メジャーは追従できます。これだけで「二世代まとめて」の地獄を構造的に避けられます。
依存の固定方針: どこを固定し、どこを Expo に委ねるか
Rork が吐き出した package.json をそのまま放置すると、ライブラリのバージョンが SDK の想定とずれていきます。ここでの判断軸は「Expo が整合性を保証する範囲は Expo に委ね、それ以外を明示的に固定する」です。
具体的には、Expo 管理下のパッケージは expo install 経由で入れ、SDK が要求する範囲に自動で合わせます。逆に、自前で足したサードパーティ(分析 SDK・課金 SDK など)は厳密にバージョンを固定します。
{
"dependencies" : {
"expo" : "~52.0.0" ,
"expo-notifications" : "~0.29.0" ,
"react-native" : "0.76.5" ,
"react-native-reanimated" : "~3.16.0" ,
"react-native-purchases" : "8.2.1" ,
"@sentry/react-native" : "6.3.0"
}
}
ポイントは2つです。第1に、Expo 管轄の expo-* と react-native-reanimated のような連携パッケージは ~(チルダ)で SDK 推奨レンジに追従させること。第2に、課金や監視のように壊れると収益と障害検知に直結するパッケージは、^ を使わず完全固定にして、上げるタイミングを自分で握ることです。
整合性は毎回コマンドで機械的に確認します。
# SDK が要求するバージョンとのズレを検出(修正はしない)
npx expo install --check
# 検出されたズレを SDK 推奨へ自動修正
npx expo install --fix
--check を CI に入れておくと、誰か(あるいは AI)がうっかり非推奨バージョンを足したときに、マージ前に気づけます。
破壊的変更のトリアージ: 全部読まず、効くものだけ拾う
メジャーアップグレードの changelog はしばしば長大です。全部を平等に読むと時間が溶けるので、私は自分のアプリが触っている API だけに絞って読みます。
手順はこうしています。
現行コードで実際に import しているモジュール一覧を機械的に抽出する
SDK のアップグレードガイドから、その一覧に該当する破壊的変更だけを拾う
拾った変更を「即修正」「動作確認のみ」「無視可」の3段階に分類する
import の抽出はワンライナーで足ります。
# expo-* と react-native-* の実利用モジュールを列挙
grep -rhoE "from '(expo[-/][^']+|react-native[-/][^']+)'" src/ \
| sed "s/from '//;s/'//" | sort -u
ここで出てきたモジュールだけを changelog と突き合わせれば、読む量は体感で半分以下になります。私はこの「使っている API だけ読む」やり方に切り替えてから、調査週の負担がはっきり軽くなりました。
回帰の安全網: 壊れたことに本番で気づかないために
冒頭の通知トラブルがまさにそうでしたが、アップグレードの怖さは「ビルドは通るのに挙動だけ静かに壊れる」ことにあります。これを止めるには、起動経路と主要機能を毎回自動で叩くスモークテストが要ります。
重厚なテストは要りません。アプリが起動し、主要画面が描画され、課金復元と通知登録が例外を投げないこと。この最小限を毎回確認するだけで、致命傷の多くは事前に止まります。
// __tests__/smoke.test.ts — アップグレード直後に必ず回す最小スモーク
import { render, screen } from '@testing-library/react-native' ;
import App from '../App' ;
describe ( 'upgrade smoke' , () => {
it ( 'アプリのルートが例外なく描画される' , () => {
render (< App />);
expect (screen. getByTestId ( 'app-root' )). toBeTruthy ();
});
it ( '課金モジュールが初期化で throw しない' , async () => {
const Purchases = require ( 'react-native-purchases' ).default;
await expect (
Purchases. configure ({ apiKey: 'test_key' })
).resolves.not. toThrow ();
});
});
実機でしか出ない不具合(通知・カメラ・課金の実フロー)は、内部配信ビルドで人手確認するチェックリストに落とします。私は通知の到達・購入復元・ディープリンクの3点を、アップグレード便ごとに必ず実機で踏むようにしています。
OTA とストア配信の切り分け: SDK 更新は必ずストアから出す
ここを取り違えると本番事故になります。EAS Update(OTA) で配れるのは JavaScript と資産だけで、ネイティブ層を含む SDK のメジャーアップグレードは OTA で配ってはいけません。ランタイムが食い違い、起動クラッシュを撒くことになります。
判断はランタイムバージョンで分けます。
変更の種類 配信経路 理由
文言・軽微な UI・ロジック修正 EAS Update(OTA) JS のみ。即時に届けられる
Expo SDK メジャー / ネイティブ依存追加 ストア配信(EAS Build) ランタイムが変わる。OTA は不可
緊急のロジック巻き戻し EAS Update(OTA) 前チャンネルへ即ロールバック
eas.json ではチャンネルを分け、SDK 更新時には新しいランタイムバージョンを切ります。
{
"build" : {
"production" : {
"channel" : "production" ,
"autoIncrement" : true
}
}
}
OTA は「すでにストアに出ているネイティブと同じランタイム」の中だけで配る。SDK を上げたら必ずストアから出す。この一線を守るだけで、アップグレード起因のクラッシュは大きく減ります。
アップグレード当日の安全網と撤退条件
最後に、発車当日に握っておく安全網です。私はアップグレード版を一気に100%へ出さず、ストアの段階的リリースで小さく出し、Sentry でリリース別のクラッシュ率を見ます。
撤退条件は事前に数字で決めておきます。曖昧な「様子を見る」が一番危険だからです。
// リリース別のクラッシュ計測を Sentry に紐づける
import * as Sentry from '@sentry/react-native' ;
Sentry. init ({
dsn: 'YOUR_SENTRY_DSN' ,
release: 'app@2.4.0+sdk52' , // SDK バージョンを release 名に含める
tracesSampleRate: 0.2 ,
});
私が使っている撤退条件はこうです。
段階リリースのクラッシュフリー率が前バージョン比で1ポイント以上落ちたら、配信を即停止する
通知・課金のいずれかで実ユーザーのエラーが観測されたら、ロジック起因なら OTA で巻き戻す
ネイティブ起因と切り分けられたら、前バージョンのビルドへストアロールバックを申請する
release 名に SDK バージョンを埋め込んでおくと、「どの SDK で何が増えたか」を Sentry 上で一目で追えます。これは個人開発で監視に割ける時間が限られている自分にとって、もっとも効いている工夫のひとつです。
実際に運用してみると、撤退条件を先に数字で決めておくことの効き目は想像以上でした。段階リリースの最中は、どうしても「もう少し見れば落ち着くかもしれない」という気持ちが働きます。けれど、しきい値を超えたら止めると先に決めておけば、迷っている間に被害が広がることを防げます。AdMob の広告表示や課金復元のように収益に直結する経路ほど、この機械的な線引きが効いてきます。
私はアップグレード便ごとに、撤退判断のログを短く残すようにしています。「どの SDK で、どの撤退条件に何回近づいたか」を書き留めておくと、次の便でどこに注意すべきかが自然と見えてきます。App Store と Google Play では段階リリースの粒度も挙動も違うため、ストアごとの癖もこのログに溜めています。
まず動かす一歩
もし今、SDK が数世代遅れたまま止まっているなら、いきなり最新へ飛ばさないでください。次の1便で1メジャーだけ上げる branch を切り、npx expo install --check で現状のズレを可視化するところから始めるのが安全です。
アップグレードは一度仕組みにしてしまえば、毎回の負担は驚くほど小さくなります。私自身、リリーストレインに切り替えてからは、年末にまとめて半日溶かすこともなくなりました。同じように長く一人でアプリを運用している方の、定期点検の設計図になれば幸いです。