広告の管理画面に「¥38」と表示された日
Rork で生成した小さなユーティリティアプリを公開して、最初の週末が明けた朝のことでした。AdMob の管理画面を開くと、その日の見積もり収益が ¥38 と表示されていました。
インストールは 400 件を超えていました。数字としては悪くありません。それでも ¥38 です。
このとき私が最初に疑ったのは広告ユニットの設定でした。実際にはそこではありませんでした。広告は正しく表示されていて、正しく配信されていて、ただ「出す場所と回数」を何も設計していなかったのです。
収益化は SDK を入れる作業ではなく、アプリの体験のどこに課金の接点を置くかという設計の作業でした。その設計を実装レベルまで落とし込んだ過程を、動くコードと一緒に残しておきます。
収益モデルは「アプリの型」でほぼ決まっている
広告か、定額課金か、買い切りか。この選択はアプリを作ってから悩むものだと思われがちですが、実際にはアプリの型が決まった時点でほぼ決まっています。
判断の軸は「ユーザーが1日に何回開くか」と「価値が積み上がるか、その場で消えるか」の2つです。
私が最初にしくじったのは、単機能ツールに定額課金を載せようとしたことでした。週に1回しか開かないアプリに月額を払う理由は、どれだけ機能を足しても生まれません。型に合わないモデルは、実装の巧拙では埋まらないというのが実感です。
Rork の利点は、この判断を早い段階で試せることにあります。生成から公開までの時間が短いぶん、モデルを外したときの引き返しが軽く済みます。
Rork の生成コードを公開ラインに乗せるまで
収益化の話に入る前に、公開までの実務を整理しておきます。ここでの手戻りが、収益化の着手を数週間単位で遅らせます。
1. プロンプトの前に、課金の接点を決めておく
Rork に渡すプロンプトは仕様書そのものです。ここに課金の接点を含めておくと、あとから画面構成を組み替える手間が減ります。
Create an iOS and Android app called "Task Master".
Core features:
- Task list with add, delete, and complete
- Categories for organization
- Local storage only, no backend
- Offline first, dark mode support
Monetization surface (implement as placeholders):
- A settings row labeled "Upgrade" that opens an empty modal screen
- A banner slot pinned to the bottom of the task list screen
- An export action that is limited to 3 uses for free users
課金画面そのものを Rork に作らせる必要はありません。置き場所だけ先に確保しておくのが要点です。後から差し込むと、ナビゲーション構造ごと触ることになります。
2. 実機での確認を、シミュレーターより先に
生成されたコードは Expo プロジェクトとして手元に落とせます。広告 SDK も課金 SDK もネイティブモジュールを含むため、シミュレーターでは挙動が再現しません。
# 開発ビルドを作って実機に入れる(広告・課金はここで初めて確認できる)
npx expo prebuild --clean
eas build --profile development --platform ios
3. 公開前に用意するもの
広告を入れる予定があるなら、初回申請の時点でプライバシー関連の申告を広告ありの内容にしておくことをおすすめします。あとから広告を足して申告を更新すると、その回の審査が長くなりやすいためです。
証明書やプロビジョニングでつまずいた場合は、Rork Max のアプリを審査に通す — 証明書設定からリジェクト対処まで に別途まとめています。
広告は「頻度」を設計しないと、収益より先に評価が落ちる
冒頭の ¥38 の話に戻ります。原因は、インタースティシャル広告を画面遷移のたびに呼んでいたことでした。
表示回数は稼げます。ただし同じユーザーが同じ広告を短時間に何度も見せられると、アプリを閉じます。閉じたユーザーは翌日戻ってきません。表示回数は増えて、継続率は落ちる。この組み合わせがいちばん収益から遠い状態でした。
そこで、広告を出すかどうかの判断を1か所に集めるフックを書きました。
// hooks/useInterstitialGovernor.ts
import { useCallback, useEffect, useRef } from 'react' ;
import AsyncStorage from '@react-native-async-storage/async-storage' ;
import {
InterstitialAd,
AdEventType,
TestIds,
} from 'react-native-google-mobile-ads' ;
const UNIT_ID = __DEV__ ? TestIds. INTERSTITIAL : 'YOUR_INTERSTITIAL_UNIT_ID' ;
const LAST_SHOWN_KEY = 'ads:interstitial:lastShownAt' ;
// 出す条件: 前回から4分以上 / 1セッション3回まで / 起動から2分は出さない
const MIN_INTERVAL_MS = 4 * 60 * 1000 ;
const MAX_PER_SESSION = 3 ;
const COLD_START_GRACE_MS = 2 * 60 * 1000 ;
export function useInterstitialGovernor () {
const ad = useRef (InterstitialAd. createForAdRequest ( UNIT_ID )).current;
const loaded = useRef ( false );
const shownInSession = useRef ( 0 );
const sessionStartedAt = useRef (Date. now ());
useEffect (() => {
const offLoaded = ad. addAdEventListener (AdEventType. LOADED , () => {
loaded.current = true ;
});
const offClosed = ad. addAdEventListener (AdEventType. CLOSED , () => {
loaded.current = false ;
ad. load (); // 次の機会に備えて先読みしておく
});
ad. load ();
return () => {
offLoaded ();
offClosed ();
};
}, [ad]);
const maybeShow = useCallback ( async () => {
if ( ! loaded.current) return false ;
if (Date. now () - sessionStartedAt.current < COLD_START_GRACE_MS ) return false ;
if (shownInSession.current >= MAX_PER_SESSION ) return false ;
const raw = await AsyncStorage. getItem ( LAST_SHOWN_KEY );
const lastShownAt = raw ? Number (raw) : 0 ;
if (Date. now () - lastShownAt < MIN_INTERVAL_MS ) return false ;
await ad. show ();
shownInSession.current += 1 ;
await AsyncStorage. setItem ( LAST_SHOWN_KEY , String (Date. now ()));
return true ;
}, [ad]);
return { maybeShow };
}
呼び出し側は、広告を出したい場所で maybeShow() を呼ぶだけになります。
const { maybeShow } = useInterstitialGovernor ();
const handleTaskComplete = async ( taskId : string ) => {
await completeTask (taskId);
// 表示の可否はフックが判断する。呼び出し側は条件を持たない
await maybeShow ();
};
この形にしたのは、条件分岐を画面側に散らすと必ず破綻するからです。画面が増えるたびに「ここは出す」「ここは出さない」という判断が増え、最終的に誰も全体の頻度を把握できなくなります。判断を1か所に閉じ込めておけば、頻度の見直しは定数を3つ変えるだけで済みます。
起動から2分の猶予を入れているのには理由があります。初回起動の直後に広告を見せられたユーザーは、アプリの価値を1度も体験していません。この段階での離脱はレビューに直結します。
クールダウンを4分にした根拠は、私のアプリで1セッションの中央値が5分前後だったことです。ここは自分のアプリの滞在時間を見てから決めるべき数字で、他所の値をそのまま持ってくると意味を失います。
メディエーションを入れて単価を上げる話は別の論点になるため、RorkアプリにAdMobメディエーションを導入してeCPMが下がった話 に分けてあります。
定額課金は「解約されない理由」から逆算する
広告の頻度を整えた後に着手したのが定額課金でした。ここで最初に決めたのは価格ではなく、課金判定をどちらに倒すかという方針です。
課金状態の取得は、通信の失敗やレシート検証の遅延で必ず失敗します。そのときに「判定できないから有料扱い」にすると、無料ユーザーに有料機能が漏れます。逆に倒しておくのが安全です。
// lib/entitlement.ts
import Purchases, { CustomerInfo } from 'react-native-purchases' ;
const ENTITLEMENT_ID = 'pro' ;
export async function configurePurchases ( apiKey : string , appUserId ?: string ) {
await Purchases. configure ({ apiKey, appUserID: appUserId ?? null });
}
export function isPro ( info : CustomerInfo | null ) : boolean {
// 判定できないときは無料扱いに倒す(付与しない側を既定にする)
if ( ! info) return false ;
return info.entitlements.active[ ENTITLEMENT_ID ] !== undefined ;
}
export async function refreshEntitlement () : Promise < boolean > {
try {
const info = await Purchases. getCustomerInfo ();
return isPro (info);
} catch {
return false ;
}
}
次に決めたのが、ペイウォールを出すタイミングです。起動直後に出す実装をやめて、無料の上限に到達した瞬間の1回だけに絞りました。
// lib/paywall.ts
import { refreshEntitlement } from './entitlement' ;
const FREE_EXPORT_LIMIT = 3 ;
export async function shouldOpenPaywall ( exportCount : number ) : Promise < boolean > {
// 課金済みユーザーには出さない
if ( await refreshEntitlement ()) return false ;
// 価値を体験する前に出さない
if (exportCount < FREE_EXPORT_LIMIT ) return false ;
// 上限に到達した1回だけ出す(以降は設定画面からの導線に任せる)
return exportCount === FREE_EXPORT_LIMIT ;
}
exportCount === FREE_EXPORT_LIMIT という等値比較にしているのが要点です。>= にすると、上限を超えたユーザーが機能を使うたびにペイウォールが出続けます。それは課金の説得ではなく、単に邪魔をしているだけの状態でした。
無料枠を3回にしたのは、2回では価値が伝わらず、5回では課金の理由が薄れたためです。この数字はアプリの主機能が1回で完結するかどうかで変わります。上限の決め方とペイウォールの置き場所については、Rorkアプリの無料ユーザーが定額会員に変わる瞬間 により詳しく書いています。
トライアルについては、3日間の無料期間を年額プランにだけ付けています。月額に付けると、トライアル終了と同時に解約される割合が高くなりました。年額なら、トライアル中に「1年使う価値があるか」を判断してもらえます。
広告と定額課金を同じアプリに同居させる場合の設計は、Rork Max × RevenueCat × AdMob ハイブリッド収益モデルの実装 にまとめました。
審査で止まった項目と、そこから作った申請前チェック
収益化の実装を入れると、審査で見られる箇所が増えます。私が実際に指摘を受けた項目を残しておきます。
このうち復元導線と価格表示は、Rork の生成コードには含まれません。課金画面を自分で組む段階で必ず入れるものとして扱っています。
審査メモは軽視されがちですが、ここに再現手順を3行書いておくだけで審査の往復が1回減ります。私はテンプレートを用意して、毎回そこに機能差分だけを書き足しています。
数字をどう読むか — 試算と、実際に効いた打ち手
ここから先は試算です。実際の数字はアプリの型と流入経路で大きく変わるため、そのまま当てはまるものではありません。構造を掴むための骨組みとして読んでいただければと思います。
広告モデルの場合、月間の収益はおおよそ次の掛け算になります。
月間収益 ≒ DAU × 1人あたり日次インプレッション × eCPM ÷ 1000 × 30
たとえば DAU 3,000、1人あたり日次インプレッション 2、eCPM 400 円なら、月におよそ 72,000 円です。ここで動かせる変数は3つあります。
インプレッションを増やす方向は即効性がありますが、DAU を削るため長期では合計が減りやすい。私が頻度制御を入れたのは、この掛け算の2つ目と1つ目が逆方向に効くことに気づいたからでした。
定額課金モデルはもっと単純です。
月間収益 ≒ 有料ユーザー数 × 単価
有料ユーザー数 ≒ 累計インストール × 課金率 × 継続率
課金率が 1% から 2% になると収益は倍になります。インストールを倍にするより、ペイウォールの位置を変えるほうが到達は早い。私が実装側の設計に時間を割いているのは、このためです。
実際に効いた打ち手を3つだけ挙げます。
スクリーンショットの1枚目を作り直したこと。 機能一覧ではなく、アプリを使い終わった後の画面に変えました。検索結果の一覧で目に入るのは実質1枚目だけです。
レビュー依頼のタイミングを変えたこと。 初回起動時をやめ、ユーザーが主機能を3回完了した直後に移しました。同じ依頼文でも、返ってくる評価の平均が変わりました。
アップデートの間隔を一定にしたこと。 最初の3か月は隔週、その後は月1回。内容の大小より、間隔が空かないことのほうが評価の維持に効いています。
つまずきやすい4つの分岐点
広告面を増やして継続率を落とす。 インプレッション単体を追うと必ずここに来ます。管理画面で見るべきは表示回数ではなく、表示回数と翌日継続率の組み合わせです。
リリース直後の不具合を放置する。 公開直後の低評価は平均点に長く残ります。公開後72時間はクラッシュ率だけを見る時間として空けておくと、対処が間に合います。
公開して終わりにする。 告知を何もしないと、ストア内検索だけが流入経路になります。公開の前週に告知先を3つ書き出しておくだけでも、初週の数字が変わります。
要望を読まなくなる。 レビューと問い合わせは、次に何を作るかの唯一の手がかりです。返信を1週間以内に返す習慣にしておくと、低評価が更新されることも珍しくありません。
次にやること
もし今すでにアプリを公開していて、収益が想定より低いなら、まず広告の表示回数と翌日継続率を並べて見てください。表示回数だけが伸びているなら、この記事の頻度制御フックをそのまま入れて、定数を自分のアプリの滞在時間に合わせるところから始められます。
まだ公開前であれば、課金の接点だけをプレースホルダーとして先に置いておくことをおすすめします。後から差し込むより、ずっと軽く済みます。
数字が動くまでには時間がかかります。私自身もまだ調整を続けている途中です。お読みいただきありがとうございました。