広告の表示回数を増やした週、収益は確かに伸びました。翌々週、サブスクリプションの新規登録が目に見えて落ちました。
合計すると、ほとんど増えていない。
数字を並べ直して、ようやく腑に落ちたことがあります。広告と課金は別々の収益源ではなく、同じ一人のユーザーの同じ一瞬を奪い合っている。片方を押し込めば、もう片方が黙って引き下がる。
収益化の設計とは、モデルを選ぶ作業ではありません。二つが食い合う地点を先に見つけて、そこに線を引く作業です。
Rork はアプリの骨格を数十分で出してくれます。だからこそ、骨格が出た直後にこの線を引けるかどうかで、その後の数ヶ月が変わります。
収益モデルは「選ぶ」のではなく「重ねる」
「広告かサブスクか」という二択で考えている限り、収益は片肺のままです。実際に機能するアプリは、ほぼ例外なく複数のモデルを重ねています。
重ねるとき、それぞれのモデルが誰から何を受け取るのかを整理しておく必要があります。
| モデル |
誰から受け取るか |
何と引き換えか |
強い場面 |
弱い場面 |
| 広告(AdMob) |
課金しない大多数 |
注意と時間 |
起動頻度が高い・セッションが短い |
集中を要する作業アプリ |
| サブスクリプション |
継続利用する少数 |
継続的な価値 |
コンテンツが増える・同期がある |
一度使えば用の済むツール |
| 単発課金 |
特定の不満を持つ層 |
その不満の解消 |
広告除去・機能解放が明確 |
継続収益にならない |
この表で見えてくるのは、三つのモデルが別々の人を相手にしているということです。広告は課金しない人から、サブスクは継続する人から、単発課金は「今この不満を消したい」人から受け取ります。
重ねる設計とは、この三者を混ぜないことに他なりません。
私が個人開発で繰り返し確かめてきたのは、一人のユーザーに二つのモデルを同時に当てると、どちらの収益も下がるという事実です。広告を見せながら課金も迫るアプリは、広告収益もサブスク収益も取り逃がします。
単発課金を「広告除去」だけに閉じ込めない
「広告除去 ¥250」は確かに売れます。ただし、これはサブスクリプションの最大の敵でもあります。
一度 ¥250 を払った人は、月額 ¥200 のプレミアムには二度と入りません。広告という不満がもう存在しないからです。
単発課金を置くなら、サブスクの下位互換ではなく別軸の価値にしてください。広告除去は単発ではなくサブスクの特典に含め、単発課金は「このカテゴリのコンテンツだけ買い切る」といった、継続価値と競合しない位置に置く。
この配置を後から変えるのは、既存購入者の権利が絡むためかなり厄介です。骨格の段階で決めておく価値があります。
食い合う地点を「価値密度」で見つける
では、線はどこに引けばいいのか。
私は「価値密度」という言い方で考えています。ユーザーがその画面で受け取っている価値が、単位時間あたりどれだけ濃いか。
- 価値密度が低い画面(一覧をスクロールしている、カテゴリを選んでいる)→ 広告の置き場所
- 価値密度が高い画面(探していた壁紙を見つけた、記録を書き終えた)→ 課金の置き場所
この二つを逆にすると、両方が壊れます。
価値密度が高い瞬間に広告を割り込ませると、ユーザーは「一番いいところで邪魔された」と受け取ります。そのユーザーはもう課金候補ではありません。
逆に、価値密度が低い瞬間にペイウォールを出すと、「まだ良さが分かっていないのに金の話をされた」となります。これも課金には至りません。
言い換えると、広告は課金候補を焼き払う道具になりうる。この認識が抜けたまま広告頻度を上げると、短期の eCPM と引き換えに LTV を削ることになります。
実装としての「焼き払い防止」
コードに落とすなら、広告表示の判定に「その瞬間の価値密度」を入れます。
// 価値密度の高い瞬間に広告を出さないためのガード
type Moment =
| "browsing" // 一覧・カテゴリ選択(低)
| "previewing" // 詳細を見ている(中)
| "acquired" // ダウンロード・保存が完了した(高)
| "returning"; // 完了後に一覧へ戻る途中(低)
const AD_ALLOWED: Record<Moment, boolean> = {
browsing: false, // 探索の邪魔をしない
previewing: false, // 判断の邪魔をしない
acquired: false, // ここは課金提案の場所。広告は絶対に置かない
returning: true, // 完了体験を味わい終えた直後だけ許可
};
function canShowInterstitial(moment: Moment, isPremium: boolean) {
if (isPremium) return false;
return AD_ALLOWED[moment];
}
acquired を false にしている点が肝心です。多くのアプリは「ダウンロード完了直後」に全画面広告を出します。収益効率としては最も良い位置に見えます。
しかし、そこはユーザーが最も満足している瞬間、つまり課金提案が最も通る瞬間でもあります。広告を置けば、その席は埋まってしまいます。
returning に一段ずらすだけで、満足の余韻を潰さずに広告を出せます。私はこのずらしを入れてから、広告収益をほとんど落とさずにペイウォールの転換率が改善する挙動を見ています。
Rork で収益化の骨格を出す
線の引き方が決まったら、Rork にはその設計を含めて渡します。「収益化してください」だけでは、広告と課金が同じ画面に同居した骨格が返ってきます。
壁紙アプリを作ってください。収益設計を以下の通り厳密に守ってください。
【ユーザー区分】
- 無料ユーザー:広告あり・標準解像度・一部カテゴリ
- プレミアム(月額 ¥200 / 年額 ¥1,200):広告なし・4K・全カテゴリ・同期
【広告の配置制約 — 重要】
- 一覧画面・詳細画面・ダウンロード完了画面には広告を出さない
- 全画面広告は「ダウンロード完了画面から一覧へ戻る遷移」でのみ表示
- バナーは一覧画面下部のみ
【課金提案の配置制約 — 重要】
- ペイウォールはダウンロード完了画面にのみ表示
- 初回ダウンロード時は表示しない(3回目以降)
【技術】
React Native + Expo / RevenueCat / react-native-google-mobile-ads
Entitlement 名は "premium" 固定。広告表示は Entitlement を単一の判定元にすること。
制約を「重要」と明示して渡すと、Rork は配置ルールをコンポーネント境界に反映してくれます。曖昧に渡したときとの差は、生成直後のコードを一度でも読み比べれば分かります。
生成後に必ず確認する点は一つ。広告表示の判定が Entitlement 一箇所から来ているか。isPremium のようなローカル state が画面ごとに散らばっていると、購入復元のときに必ず取りこぼします。
RevenueCat を Entitlement 中心で組む
RevenueCat を「サブスク購入 SDK」として使うと、後で必ず破綻します。プランが増えたとき、キャンペーンを打ったとき、単発課金を足したときに、条件分岐がアプリ中に増殖するからです。
正しくは、アプリ側は Entitlement だけを見る。プランと Entitlement の対応は RevenueCat のダッシュボードに寄せます。
// アプリ全体で唯一の判定元
import Purchases, { CustomerInfo } from "react-native-purchases";
const ENTITLEMENT_ID = "premium";
export function useEntitlement() {
const [isPremium, setIsPremium] = useState(false);
const [ready, setReady] = useState(false);
useEffect(() => {
const apply = (info: CustomerInfo) => {
// プラン名(monthly / annual / lifetime / campaign)を一切見ない。
// どのプランで得た権利かはダッシュボード側の関心事。
setIsPremium(info.entitlements.active[ENTITLEMENT_ID] !== undefined);
setReady(true);
};
Purchases.getCustomerInfo().then(apply).catch(() => setReady(true));
Purchases.addCustomerInfoUpdateListener(apply);
}, []);
// ready を返すのが重要。判定前に広告を出すと、
// 課金済みユーザーに一瞬だけ広告が見えてレビューで叩かれる。
return { isPremium, ready };
}
ready を返している理由を補足します。
getCustomerInfo() はネットワークを伴うため、起動直後の数百ミリ秒は権利が未確定です。この間 isPremium は false を返します。ここでバナーを描画してしまうと、課金済みユーザーの画面に広告が一瞬だけ現れます。
一瞬でも見えれば、ユーザーにとっては「金を払ったのに広告が出た」という体験です。レビューにそう書かれます。判定が確定するまでは広告枠を空のまま保持してください。
復元を「ボタン」ではなく「起動時」に置く
Apple は購入復元ボタンを要求します。ただし、ボタンに頼る設計はユーザーの取りこぼしを生みます。機種変更したユーザーが設定画面の奥まで探しに行ってくれる保証はありません。
// 起動時に静かに復元を試みる(UI を出さない)
async function silentRestore() {
try {
const info = await Purchases.restorePurchases();
return info.entitlements.active[ENTITLEMENT_ID] !== undefined;
} catch {
// 失敗しても無音。ユーザーにエラーを見せる場面ではない
return false;
}
}
ボタンは審査要件として残しつつ、実際の復元は起動時に済ませておく。ユーザーは「新しい端末でも普通に使えた」としか感じません。それが正しい体験です。
レシート検証はサーバー側に寄せる
クライアントの customerInfo を信じてサーバー側の機能を解放してはいけません。改変が容易だからです。
RevenueCat の Webhook を受けて、権利をサーバー側に記録します。
// Cloudflare Workers での Webhook 受信例
export default {
async fetch(request: Request, env: Env) {
const auth = request.headers.get("Authorization");
if (auth !== `Bearer ${env.REVENUECAT_WEBHOOK_SECRET}`) {
return new Response("unauthorized", { status: 401 });
}
const { event } = await request.json();
// 権利の付与・剥奪に関わるイベントだけを扱う
const GRANT = ["INITIAL_PURCHASE", "RENEWAL", "UNCANCELLATION", "PRODUCT_CHANGE"];
const REVOKE = ["EXPIRATION", "BILLING_ISSUE"];
const key = `entitlement:${event.app_user_id}`;
if (GRANT.includes(event.type)) {
await env.KV.put(key, JSON.stringify({
active: true,
expires_at: event.expiration_at_ms,
}), { expirationTtl: 60 * 60 * 24 * 40 });
} else if (REVOKE.includes(event.type)) {
await env.KV.delete(key);
}
// 未知のイベントも 200 を返す。500 を返すと RevenueCat が
// リトライを続け、Webhook が無効化されることがある。
return new Response("ok", { status: 200 });
},
};
最後のコメントが実務上の勘所です。知らないイベント型に対して 500 を返す実装をしていると、リトライが積み上がって Webhook 自体が止まります。扱わないイベントも受け取ったことにして 200 を返してください。
BILLING_ISSUE を即座に剥奪扱いにしている点は、アプリの性格によって判断が分かれます。決済カードの一時的な失敗で機能を止めると、復旧したユーザーが戻ってこないことがあります。猶予期間(Grace Period)を使うなら、剥奪は EXPIRATION のみに絞る設計が穏当です。
AdMob 収益を左右するのは配置より「間隔」
広告の話に戻ります。頻度制御を「N 回に 1 回」で実装している例をよく見かけますが、これは短時間に集中操作するユーザーを直撃します。
5 回に 1 回という設定は、10 秒で 5 回タップする人にとっては「10 秒に 1 回全画面広告」です。
// 回数と時間の両方を条件にする
const MIN_ACTIONS = 5;
const MIN_INTERVAL_MS = 120_000; // 最低 2 分は空ける
const COLD_START_GRACE_MS = 90_000; // 起動後 90 秒は出さない
let actionCount = 0;
let lastAdAt = 0;
const launchedAt = Date.now();
function shouldShowInterstitial(moment: Moment, isPremium: boolean): boolean {
if (!canShowInterstitial(moment, isPremium)) return false;
const now = Date.now();
if (now - launchedAt < COLD_START_GRACE_MS) return false; // 初回体験を守る
if (now - lastAdAt < MIN_INTERVAL_MS) return false;
if (++actionCount < MIN_ACTIONS) return false;
actionCount = 0;
lastAdAt = now;
return true;
}
COLD_START_GRACE_MS を入れている理由は、インストール直後の 90 秒がそのアプリの生死を決めるからです。
初回起動でいきなり全画面広告に当たったユーザーは、そのまま削除します。ここで広告を1回我慢することの見返りは、そのユーザーが翌日も開いてくれる確率です。
ATT を「聞くタイミング」で eCPM が変わる
iOS の App Tracking Transparency の許諾率は、eCPM に直接効きます。パーソナライズ広告が配信できるかどうかが変わるからです。
そして許諾率は、ダイアログを出すタイミングでかなり動きます。
起動直後に出すと、ユーザーはアプリの価値を何も知らないまま「トラッキングを許可しますか」と聞かれます。断ります。
import { requestTrackingPermissionsAsync } from "expo-tracking-transparency";
// 価値を受け取った直後に、文脈を添えて聞く
async function askTrackingAfterFirstValue() {
const shown = await AsyncStorage.getItem("att_asked");
if (shown) return;
// 事前説明を自前の画面で出してから、OS のダイアログへ
// (OS のダイアログは一度きり。断られたら設定アプリからしか戻せない)
await showPrePermissionSheet({
title: "広告の表示について",
body: "このアプリは広告収益で無料提供しております。関連性の高い広告を表示することで、無料でお使いいただける状態を維持できます。",
});
await requestTrackingPermissionsAsync();
await AsyncStorage.setItem("att_asked", "1");
}
OS のダイアログは一度しか出せません。断られると、ユーザーが自分で設定アプリを開かない限り取り返せない。だからこそ、自前の事前説明を挟んで、承諾する気のあるユーザーだけを OS のダイアログに通します。
事前説明で「後で」を選んだユーザーには、OS のダイアログを出さずに温存する。次の機会が残ります。
メディエーションは「入れる」だけでは効かない
複数の広告ネットワークを競わせるメディエーションは eCPM を押し上げますが、設定を入れただけでは足りません。
- フロア価格(eCPM floor)を設定する — 下限を設けないと、安値のネットワークが枠を埋め続けます
- ネットワークごとに地域を分ける — 日本で強いネットワークと北米で強いネットワークは違います
- 落札できないネットワークを整理する — 数を増やすほど広告のロード時間が伸び、表示機会を落とします
三つ目が見落とされがちです。ネットワークを 8 社入れて eCPM が下がったという話は、たいていロード遅延で表示自体が減っています。
ペイウォールの転換率は文言ではなく提示タイミングで決まる
ペイウォールのコピーを何度書き直しても、転換率はあまり動きません。私自身、何度もそこで時間を溶かしました。
動くのは、誰にいつ出すかです。
出してよい条件を行動で定義する
type UserSignals = {
sessionsCount: number; // 起動回数
itemsAcquired: number; // ダウンロード・保存した数
daysSinceInstall: number;
lastPaywallAt: number | null;
};
const PAYWALL_COOLDOWN_MS = 1000 * 60 * 60 * 24 * 3; // 3日
function shouldShowPaywall(s: UserSignals): boolean {
// 価値を受け取った実績がないユーザーには出さない
if (s.itemsAcquired < 3) return false;
// 一度しか起動していない人はまだ判断材料がない
if (s.sessionsCount < 2) return false;
// 断った人に連日出さない(アンインストールの主因)
if (s.lastPaywallAt && Date.now() - s.lastPaywallAt < PAYWALL_COOLDOWN_MS) {
return false;
}
return true;
}
itemsAcquired < 3 の条件が、この関数で最も効いています。
3 回ダウンロードしたということは、そのアプリが自分の役に立つと確認済みだということです。1 回目のユーザーはまだ「試している」段階で、そこに料金の話を差し込めば、試すのをやめる口実を与えるだけです。
クールダウンも重要です。断ったユーザーに翌日また出すのは、収益ではなくアンインストールを生みます。3 日空けて、そのあいだに価値を受け取ってもらう。
年額を主役にする理由と、その代償
年額プランは月額換算で 30〜50% 割安に設定し、UI で目立たせる。これは定石です。チャーンが構造的に低く、LTV が伸びます。
ただし代償もあります。年額に寄せると、改善の学習速度が落ちます。
月額なら、ペイウォールを変えた効果は数週間で解約率に現れます。年額では 1 年後です。価格やプラン構成を試行錯誤している段階では、月額の比率をある程度残しておかないと、何が効いたのか分からないまま時間だけが過ぎます。
私は、プロダクトが固まるまでは月額を素直に見せ、設計が安定してから年額を前面に出す順序を好みます。最初から年額一本に寄せるのは、まだ答えの分かっていない設計を 1 年固定するのと同じことです。
何を計測し、何を捨てるか
計測できる指標は無限にあります。全部を見る人は、結局どれも見ていません。
| 指標 |
見る理由 |
判断に使えるか |
| ARPDAU |
広告と課金を合算した総合効率 |
◎ 施策の可否をこれ一本で判定できる |
| D1 / D7 リテンション |
広告が体験を壊していないかの検知 |
◎ ARPDAU と対で見る |
| ペイウォール表示→購入率 |
提示タイミングの妥当性 |
○ 表示数を分母に取ること |
| eCPM 単体 |
広告ネットワークの調子 |
△ 表示数と切り離すと誤読する |
| DAU 単体 |
規模感の把握 |
△ 収益判断には使えない |
ARPDAU と D7 リテンションを対で見る。これだけで、広告施策のほとんどは判定できます。
ARPDAU が上がって D7 が下がったなら、その施策は将来の収益を前借りしただけです。広告頻度を上げたときに、まさにこの形が出ます。
import analytics from "@react-native-firebase/analytics";
// ペイウォールは「表示」も必ず記録する。
// 購入イベントだけ取っていると、分母がないため転換率が出せない。
async function logPaywallShown(trigger: string, signals: UserSignals) {
await analytics().logEvent("paywall_shown", {
trigger,
items_acquired: signals.itemsAcquired,
sessions_count: signals.sessionsCount,
days_since_install: signals.daysSinceInstall,
});
}
async function logPurchase(plan: "monthly" | "annual", price: number) {
await analytics().logPurchase({
currency: "JPY",
value: price,
items: [{ item_id: `subscription_${plan}`, item_name: `${plan} subscription` }],
});
}
paywall_shown に行動シグナルを添えている点が後から効きます。「ダウンロード 3 回で出したときと 5 回で出したときで、どちらが通ったか」を、実装を変えずに後から分析できます。
つまずきやすい箇所
課金済みユーザーに広告が一瞬見える
Entitlement の判定が確定する前に広告枠を描画しています。前述の ready フラグで、判定完了まで枠を空のまま保持してください。この報告はレビュー経由で届くことが多く、届いた時点ですでに星が落ちています。
本番で広告が出ない
テスト用の広告ユニット ID が残っていないか、app.json の AdMob アプリ ID が正しいかを確認します。ストア審査通過直後は配信が制限されることがあり、24〜72 時間ほど様子を見る必要があります。
購入したのにプレミアムが解放されない
customerInfo.entitlements.active のキーが RevenueCat ダッシュボードの Entitlement ID と一致しているかを見ます。Sandbox と本番で API キーが入れ替わっている事故も頻出します。
サブスクリプションが審査で却下される
購入復元の導線、キャンセル方法の明示、利用規約とプライバシーポリシーへのリンク。この三点はペイウォール画面そのものに置いてください。設定画面の奥に置いた実装は、しばしば差し戻されます。
まとめ
収益設計で最初にやるべきことは、広告ユニットを増やすことでも、ペイウォールの文言を練ることでもありません。
自分のアプリで、ユーザーが最も満足する瞬間はどこかを一つ特定すること。
その一点には、広告を絶対に置かない。そこは課金提案のために空けておく。広告はその前後の、価値密度の低い時間に寄せる。
線はそこから引けます。あとは、ARPDAU と D7 リテンションを対で見ながら、少しずつ動かしていくだけです。
Rork は骨格を数十分で出してくれます。空いた時間を、この線引きに使っていただければと思います。
私自身まだ試行錯誤の途中で、いま正しいと思っている配置も、半年後には違う結論になっているかもしれません。それでも、いまの時点で確かだと言えることを書きました。お読みいただきありがとうございました。