料金ページを開いたまま、三十分ほど手が止まっておりました。月額の差は目の前にあるのに、どちらを選べばいいのかが決まらないのです。
理由は途中で分かりました。私は Rork Max の値段を測りかねていたのではなく、自分のアプリが OS に何を要求しているのかを、そもそも把握していなかったのです。必要かどうかを判断する材料が、比較記事の側ではなく手元のプロジェクトの中にありました。
選べなかったのは価格ではなく、手元の解像度でした
最初のうち、私は比較記事を読み漁りました。結果は芳しくありませんでした。どの記事も「ネイティブ機能が必要なら Max」と書いてあり、その一文は正しいのですが、自分のアプリにネイティブ機能が要るかどうかには誰も答えてくれません。
そこで向きを変えました。判断の入口を製品側ではなく、app.json と Info.plist に移したのです。アプリが要求している権限、バックグラウンドモード、entitlements は、すでにそこへ書き出されています。読むべき資料は最初から手元にありました。
選ぶ前に数えます。数えるのは料金ではなく、アプリが OS に要求している機能です。 この順番だけは、迷った日ほど守るようにしております。
抜き出した項目を三つの列へ仕分けます
仕分けの基準はシンプルにしました。列は三つだけです。
| 列 | 意味 | 例 |
| A | React Native / Expo の標準機能だけで届く | カメラ、写真保存、ローカル通知、生体認証、Sign in with Apple |
| B | config plugin か少量のネイティブ設定で届く | バックグラウンド再生、BGTaskScheduler、Universal Links |
| C | ネイティブ Swift が要る(標準の Rork では届かない) | HealthKit、HomeKit、Core NFC、Widget、Live Activity、Screen Time API |
この三列にした理由は、B の存在を見落としやすいからです。「設定を書けば届くもの」を C に混ぜてしまうと、必要のない移行を自分で正当化してしまいます。私はここで一度、判断を誤りかけました。
判断の規則も一つに絞りました。C 列に一つでも項目があるなら Max を検討します。C 列が空なら、値段の話は機能の話ではなくなります。
棚卸しを手作業でやらないための小さなスクリプト
項目を目で拾うと必ず漏れます。app.json を読んで三列へ仕分けるところまでを、短い Node スクリプトに任せました。プロジェクト直下に置いて実行します。
#!/usr/bin/env node
// capability-inventory.mjs
// Expo プロジェクトが実際に要求している OS 機能を一覧にし、A/B/C の三列へ仕分けます。
// 使い方: node capability-inventory.mjs [プロジェクトのルート]
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
const root = process.argv[2] ?? ".";
// 仕分けの規則。C 列には「Expo の外へ出ないと実装できないもの」だけを入れます
const RULES = [
["UIBackgroundModes:audio", "B", "expo-av / expo-audio + background mode の宣言で届きます"],
["UIBackgroundModes:remote-notification", "A", "expo-notifications の範囲です"],
["UIBackgroundModes:processing", "B", "expo-background-task から BGTaskScheduler を使います"],
["UIBackgroundModes:location", "B", "expo-location のバックグラウンド許可が要ります"],
["NSMicrophoneUsageDescription", "A", "expo-av / expo-audio で録音できます"],
["NSCameraUsageDescription", "A", "expo-camera の範囲です"],
["NSPhotoLibraryAddUsageDescription", "A", "expo-media-library で保存できます"],
["NSPhotoLibraryUsageDescription", "A", "expo-media-library の範囲です"],
["NSLocationWhenInUseUsageDescription", "A", "expo-location の範囲です"],
["NSFaceIDUsageDescription", "A", "expo-local-authentication の範囲です"],
["NSHealthShareUsageDescription", "C", "HealthKit は Expo の外です"],
["NSHomeKitUsageDescription", "C", "HomeKit は Expo の外です"],
["NFCReaderUsageDescription", "C", "Core NFC は Expo の外です"],
["LSApplicationQueriesSchemes", "A", "Linking.canOpenURL の宣言です"],
["aps-environment", "A", "プッシュ通知の証明書設定です"],
["com.apple.developer.applesignin", "A", "expo-apple-authentication の範囲です"],
["com.apple.developer.associated-domains", "B", "config plugin で書けます"],
["com.apple.developer.healthkit", "C", "HealthKit は Expo の外です"],
["com.apple.developer.homekit", "C", "HomeKit は Expo の外です"],
["com.apple.developer.usernotifications.time-sensitive", "B", "設定で届きます"],
["com.apple.developer.group-session", "C", "SharePlay は Expo の外です"],
["com.apple.developer.family-controls", "C", "Screen Time API は Expo の外です"],
["widget", "C", "Widget / Live Activity は別ターゲットが要ります"],
["ActivityKit", "C", "Live Activity は別ターゲットが要ります"],
];
function classify(key) {
const hit = RULES.find((r) => key.toLowerCase().includes(r[0].toLowerCase()));
return hit
? { col: hit[1], note: hit[2] }
: { col: "?", note: "規則が未登録です。手で判断してください" };
}
function readJson(path) {
if (!existsSync(path)) return null;
try {
return JSON.parse(readFileSync(path, "utf8"));
} catch (e) {
console.error(`x ${path} を読めませんでした: ${e.message}`);
return null;
}
}
const appJson =
readJson(join(root, "app.json")) ?? readJson(join(root, "app.config.json"));
const pkg = readJson(join(root, "package.json"));
if (!appJson) {
console.error("app.json が見つかりません。app.config.js を使っている場合は");
console.error(" npx expo config --type public --json > app.json.tmp");
console.error("で書き出してから、その一時ファイルを渡してください。");
process.exit(1);
}
const expo = appJson.expo ?? appJson;
const info = expo.ios?.infoPlist ?? {};
const ent = expo.ios?.entitlements ?? {};
const rows = [];
// (1) Info.plist の権限文字列。値が空のものは「宣言だけ残っている」候補です
for (const [k, v] of Object.entries(info)) {
if (k === "UIBackgroundModes") {
for (const mode of v ?? [])
rows.push({ key: `UIBackgroundModes:${mode}`, source: "ios.infoPlist", empty: false });
continue;
}
const empty =
typeof v === "string" ? v.trim() === "" : Array.isArray(v) ? v.length === 0 : false;
rows.push({ key: k, source: "ios.infoPlist", empty });
}
// (2) entitlements
for (const k of Object.keys(ent)) {
rows.push({ key: k, source: "ios.entitlements", empty: false });
}
// (3) Android のパーミッション(iOS 側の宣言との突き合わせに使います)
for (const p of expo.android?.permissions ?? []) {
rows.push({
key: p.replace("android.permission.", "ANDROID:"),
source: "android.permissions",
empty: false,
});
}
const bucket = { A: [], B: [], C: [], "?": [] };
for (const r of rows) {
const { col, note } =
r.source === "android.permissions"
? { col: "A", note: "Android 側の宣言です" }
: classify(r.key);
bucket[col].push({ ...r, note });
}
console.log(`# capability inventory — ${expo.name ?? "(name 未設定)"}`);
console.log(
`# 抽出 ${rows.length} 件 / A ${bucket.A.length} · B ${bucket.B.length} · C ${bucket.C.length} · 未分類 ${bucket["?"].length}\n`
);
for (const col of ["C", "B", "A", "?"]) {
if (bucket[col].length === 0) continue;
console.log(`[${col}]`);
for (const r of bucket[col]) {
const flag = r.empty ? " <- 値が空です。使っていないなら削除の候補" : "";
console.log(` ${r.key} (${r.source}) ${r.note}${flag}`);
}
console.log("");
}
if (pkg) {
const natives = Object.keys(pkg.dependencies ?? {}).filter(
(d) => d.startsWith("expo-") || d.startsWith("react-native-") || d === "react-native"
);
console.log(`[参考] ネイティブを含みうる依存 ${natives.length} 件`);
console.log(" " + natives.join(", "));
}
// C が一件でもあれば終了コード 2。CI で気づけるようにしています
process.exit(bucket.C.length > 0 ? 2 : 0);
規則表は完全ではありません。私も未登録のキーが出るたびに一行ずつ足しております。むしろ ? 列に落ちた項目こそ、自分の手で調べる価値のある項目です。
実行してみた結果
音源を扱う小さなアプリを想定した app.json に対して走らせると、こう出ました。
# capability inventory — AmbientSleep
# 抽出 10 件 / A 9 · B 1 · C 0 · 未分類 0
[B]
UIBackgroundModes:audio (ios.infoPlist) expo-av / expo-audio + background mode の宣言で届きます
[A]
UIBackgroundModes:remote-notification (ios.infoPlist) expo-notifications の範囲です
NSMicrophoneUsageDescription (ios.infoPlist) expo-av / expo-audio で録音できます
NSPhotoLibraryAddUsageDescription (ios.infoPlist) expo-media-library で保存できます
NSCameraUsageDescription (ios.infoPlist) expo-camera の範囲です <- 値が空です。使っていないなら削除の候補
LSApplicationQueriesSchemes (ios.infoPlist) Linking.canOpenURL の宣言です
com.apple.developer.applesignin (ios.entitlements) expo-apple-authentication の範囲です
aps-environment (ios.entitlements) プッシュ通知の証明書設定です
ANDROID:POST_NOTIFICATIONS (android.permissions) Android 側の宣言です
ANDROID:RECORD_AUDIO (android.permissions) Android 側の宣言です
[参考] ネイティブを含みうる依存 5 件
expo-av, expo-notifications, react-native, react-native-google-mobile-ads, react-native-purchases
C 列は空で、終了コードは 0 でした。バックグラウンド再生という、いちばんネイティブ寄りに見えた項目が B に落ちています。
同じスクリプトを、睡眠の記録を扱う想定のプロジェクトへ向けると出力が変わります。
# capability inventory — SleepCoach
# 抽出 7 件 / A 2 · B 3 · C 2 · 未分類 0
[C]
NSHealthShareUsageDescription (ios.infoPlist) HealthKit は Expo の外です
com.apple.developer.healthkit (ios.entitlements) HealthKit は Expo の外です
[B]
UIBackgroundModes:audio (ios.infoPlist) expo-av / expo-audio + background mode の宣言で届きます
UIBackgroundModes:processing (ios.infoPlist) expo-background-task から BGTaskScheduler を使います
com.apple.developer.associated-domains (ios.entitlements) config plugin で書けます
終了コードは 2 です。ここで初めて、料金の話をする資格が出ます。
運用しているアプリで仕分けたとき、C 列は空のままでした
私自身、個人開発で壁紙のアプリとヒーリング音源のアプリ、それに願望実現を支えるアプリを長く運用しております。同じ手順で棚卸しをしてみました。
出てきたのは、写真の保存、ローカル通知、バックグラウンド再生、課金、広告の識別子まわりです。ほぼ A に落ち、バックグラウンド再生だけが B に残りました。C 列は空のままでした。ネイティブでなければ届かないと思い込んでいた機能が、実は設定の側にあったのです。
もう一つ、予想と逆の結果が出ました。私が長く詰まってきたのは、機能そのものではなくその機能を何に使うのかという文面のほうだったのです。写真の保存でも録音でも、実装は動くのに提出でつまずきます。詳しくはApple の自動解析は、purpose string が書いてあるかではなく何に使うかを読んでいますへ書き残しました。生成先の言語を変えても、この壁は一ミリも動きません。
そして棚卸しでいちばん多く出てきたのは、使っていないのに残っている宣言でした。過去に試したライブラリが置いていったキーが、空の値のまま生き残っています。スクリプトが「値が空です」と印を付けるのは、そのためです。
C 列が空なら、月額は機能ではなく速度に払うお金になります
ここで判断が分岐します。
C 列が一つでも埋まっているなら、選択肢は「Max へ移る」か「その機能を諦める」か「その部分だけ別の手段で作る」の三つです。この場合は値段が比較の対象になりますので、月額の差を作業量の差として見積もり直すことをお勧めします。
私がここで長く迷っていたのは、値段そのものより「必要かどうかを自分で決められない状態」だったのかもしれません。
C 列が空なら、Max に払うのは機能ではありません。手元に Mac を用意せずに済むこと、シミュレータと提出までを一本の流れに置けること、つまり速度と段取りに払うお金です。それに価値を感じるかどうかは、機能表では決められません。私はここで、比較表を閉じました。
B 列は無料ではありません。ここでつまずきました
三列に分けたあと、私は B を軽く見すぎました。「設定で届く」と書いた項目にも、実際には手間が乗ります。ここが最初の落とし穴でした。
B に落ちた項目を実装するとき、多くの場合は npx expo prebuild で ios/ と android/ を生やすことになります。生成物をリポジトリへ入れるか、毎回捨てて作り直すかで、その後の運用が変わります。私は捨てて作り直す側を選び、必要な設定はすべて config plugin へ寄せる方針に落ち着きました。手で Info.plist を編集すると、次の prebuild で消えてしまうためです。
回避のしかたは単純です。app.json に書けるものは app.json へ、書けないものは小さな config plugin へ。この二段だけで、たいていの B は吸収できます。
// plugins/with-background-audio.js
// prebuild で消えない形で UIBackgroundModes を足す最小の config plugin です
const { withInfoPlist } = require("expo/config-plugins");
module.exports = function withBackgroundAudio(config) {
return withInfoPlist(config, (cfg) => {
const modes = new Set(cfg.modResults.UIBackgroundModes ?? []);
modes.add("audio");
cfg.modResults.UIBackgroundModes = [...modes];
return cfg;
});
};
// app.json 側の宣言。配列に足すだけです
{
"expo": {
"plugins": ["./plugins/with-background-audio"]
}
}
本番のビルドで初めてエラーになる種類の問題なので、eas build --profile preview を一度通してから判断することをお勧めします。B の見積もりが甘いまま「これなら Max は要らない」と結論を出すと、あとで作業量に驚くことになります。私はここで一度、対処に半日を使いました。
それでも Max を選ぶ条件を三つに絞りました
自分のための線引きとして、三つだけ残しております。
- C 列に項目が残ります。特に HealthKit、Core NFC、Screen Time API のように、代替の効かないものです
- Widget や Live Activity のような別ターゲットが、おまけではなく製品の中心にあります
- 手元に Mac がなく、ビルド環境ごと預けたい事情があります
三つとも当たらないときは、標準の Rork で作り、届かない一点が出てから考え直します。先に大きい方を選んでおく判断を、私は取らなくなりました。
判断そのものを一枚に残します
この手の判断は、三か月経つと理由を忘れます。棚卸しの結果と結論を、リポジトリへ一緒に置くようにしました。
# docs/decisions/2026-09-08-rork-max.yaml
date: 2026-09-08
question: Rork Max へ移るか、標準の Rork のままか
inventory:
a: 9
b: 1
c: 0
unknown: 0
c_items: []
decision: 標準の Rork のまま進める
reason: |
C 列が空。バックグラウンド再生は B(config plugin と background mode の宣言)で届いた。
月額の差は機能ではなくビルド環境の外注費に相当するため、いまは自前で持つ。
revisit_when:
- Widget もしくは Live Activity を製品の中心に据えると決めたとき
- HealthKit 連携の要望が実際のレビューから複数届いたとき
revisit_when を書いておくと、次に迷ったときに一から考え直さずに済みます。私はここを一行だけ丁寧に書くようにしております。
つまずいた点をいくつか
価格は二次情報として扱います。 標準版の階層も Max の月額も、まとめ記事やレビューサイト経由の数字が多く出回っています。私が確認した限りでも時点によって記述が揺れました。判断に使う前に、必ず rork.com の料金ページで突き合わせてください。無料枠でどこまで試せるかはRork の無料枠は月35クレジットですが、実際にぶつかるのは1日5という上限のほうですにまとめてあります。
課金の単位がメッセージ数である点も、棚卸しと同じくらい効いてきます。 トークン量ではなく、AI へ送るプロンプト一回が一つ減ります。細かい手直しを何度も投げる進め方だと、機能の重さと関係なく残量が減っていきます。プラン間の考え方はRorkの料金プランを正直に比較する:無料・Pro・Maxの違いと個人開発者が損しない選び方が詳しいです。
app.config.js を使っている場合は、そのままでは読めません。 npx expo config --type public --json で一度書き出してから渡してください。動的に組み立てている設定ほど、書き出した結果に自分でも驚くことがあります。
棚卸しは提出のたびに古くなります。 ライブラリを一つ足しただけで宣言が増えます。私は終了コードを見るだけの一行を CI に置き、C 列が生えた日に気づけるようにしました。
まとめ
まずは自分のプロジェクトの直下でスクリプトを一度走らせ、? と「値が空です」の行だけを眺めてみてください。料金の判断より先に、消せる宣言が二つ三つ見つかるはずです。私も、そこから始めました。
最後まで読んでくださり、ありがとうございました。