Android版を出した最初の月、AdMobの管理画面を開いて、数字の桁を二度見しました。iOSと同じ広告ユニット構成を入れたつもりだったのに、表示回数と収益の比が明らかに噛み合っていないのです。原因はネットワーク側ではなく、レポートの単位を私が読み違えていたことでした。
それ以来、デュアルストアの運用でいちばん時間を割いているのは、収益を増やす施策ではなく、収益の見え方を壊さない仕組みづくりです。単価は交渉できませんが、単位と桁は自分で守れます。 ここを守れているかどうかで、月初に見る数字の意味が変わってしまうのだと気づいたのは、一度桁を間違えたあとでした。
Rork MaxはネイティブなSwiftUIアプリの生成に強く、土台がReact Nativeであることを活かせばAndroidにも広げられます。ここでは課金と広告の設計、そして集計まわりで私がつまずいた箇所を順に書き残します。金額そのものはジャンルと地域構成で桁が動くため、断定できる数字は出しません。代わりに、どこを測り、どこで取り違えやすいのかを残しておきます。
iOSとAndroidで課金行動が違う理由
同じアプリを出しても、プラットフォームによって収益の構造が変わります。この違いを理解せずに「とりあえず両方出す」だけでは、せっかくの展開機会を活かしきれません。
App Store(iOS)の課金特性
iOSユーザーは課金転換率が高い傾向があります。App Storeの決済体験が磨かれており、Face IDでの1タップ決済が抵抗感を下げています。
- サブスクリプション転換率: 無料試用から有料への転換が比較的高い(5〜15%が現実的なレンジ)
- ARPU(一人あたり平均収益): Androidより高い傾向(一般的に1.5〜2倍)
- 審査の厳格さ: 課金フローと広告表示について、厳しい基準が敷かれています
Google Play(Android)の課金特性
Androidは絶対数が多く、特定の地域(東南アジア・インド・中南米)では相応のシェアを持ちます。課金転換率はiOSより低いものの、ダウンロード数の多さがカバーします。
- ダウンロード量: iOSの2〜3倍のダウンロード数が一般的
- 広告収益: DAU(デイリーアクティブユーザー)が多いため、AdMob収益はAndroidで大きくなりやすい
- 審査の柔軟性: App Storeより審査基準が緩いケースが多く、機能の多様性を出しやすい
プラットフォーム別の収益構造(アプリ種別ごとの典型例):
ユーティリティ・ツール系:
iOS: 月額サブスクが主収益 → 課金転換率重視
Android: 広告 + 課金の複合型 → DAU最大化重視
ゲーム・娯楽系:
iOS: IAP(アプリ内購入)+ 少量の広告
Android: 広告メイン + IAP(広告のARPDAU高め)
習慣・ライフスタイル系:
iOS: 高単価サブスク(月額¥1,200〜¥3,600)
Android: 低単価サブスク + 広告(¥500〜¥1,500)
RevenueCatで両プラットフォームの課金を一元管理する
クロスプラットフォームで課金実装するとき、最も避けるべきはiOSとAndroidで別々の課金ロジックを書くことです。バグが2倍、デバッグコストが2倍になります。
RevenueCatは、App StoreとGoogle Playの両方をひとつのSDKで抽象化します。
古い記事を参考に import Purchases と書くとビルドが通りません。モジュール名は途中で RevenueCat に変わり、ユーザー識別も logIn(_:) が第一引数にラベルを取らない形になりました。ここは検索で出てくる断片をそのまま貼ると必ず詰まる箇所です。
// iOS + Android共通の初期化(React Native / Expo でも同様)
import RevenueCat
// アプリ起動時
Purchases.configure(withAPIKey: "YOUR_REVENUECAT_API_KEY")
// ユーザー識別(ログイン後に呼ぶ)
Purchases.shared.logIn(userId) { (customerInfo, created, error) in
if let info = customerInfo {
self.updateSubscriptionStatus(from: info)
}
}
func updateSubscriptionStatus(from info: CustomerInfo) {
let isSubscribed = info.entitlements["premium"]?.isActive == true
let expiresDate = info.entitlements["premium"]?.expirationDate
DispatchQueue.main.async {
self.isPremium = isSubscribed
self.premiumExpiresDate = expiresDate
}
}
// 購入フロー(プラットフォーム問わず同じコード)
func purchase(packageId: String) async throws {
let offerings = try await Purchases.shared.offerings()
guard let package = offerings.current?.availablePackages.first(where: {
$0.identifier == packageId
}) else {
throw PurchaseError.packageNotFound
}
let result = try await Purchases.shared.purchase(package: package)
if result.customerInfo.entitlements["premium"]?.isActive == true {
// 購入成功
await updateUI(isPremium: true)
}
}
プラットフォーム別の価格設計
同じアプリでも、iOSとAndroidで価格を変えることを検討すべき場合があります。
月額サブスクリプション価格の設計例:
iOSの場合(高ARPU、課金転換率重視):
Lite: ¥480/月
Pro: ¥1,200/月 ← 主力プラン
Annual: ¥9,800/年(月換算¥817・33%オフ)
Androidの場合(ダウンロード数重視、転換率は低め):
Lite: ¥380/月 ← iOSより低めに設定
Pro: ¥980/月
Annual: ¥7,800/年(月換算¥650・33%オフ)
理由:
- Androidユーザーは価格感度が高い傾向
- 転換率を上げるために参入ハードルを下げる
- 年額プランでLTVを確保する構造は共通
RevenueCatでプラットフォーム別の価格を設定するには、App Store ConnectとGoogle Play Consoleそれぞれで別の製品IDを作成し、RevenueCatのダッシュボードで「Offering」として管理します。
AdMob統合 — 両プラットフォームで広告収益を最大化する
サブスクリプションと広告を組み合わせる場合、「有料ユーザーに広告を表示しない」というロジックが必須です。これを間違えると有料ユーザーが解約します(実際にあった事例です)。
// 課金状態と広告表示の連動
class AdManager {
static let shared = AdManager()
// 広告を表示すべきかどうか(課金状態に依存)
var shouldShowAds: Bool {
return !SubscriptionManager.shared.isPremium
}
func loadInterstitialIfNeeded() {
guard shouldShowAds else {
print("Premium user — skipping ad load")
return
}
GADInterstitialAd.load(
withAdUnitID: adUnitId,
request: GADRequest()
) { [weak self] ad, error in
self?.interstitialAd = ad
}
}
func showInterstitialIfReady(from viewController: UIViewController) {
guard shouldShowAds, let ad = interstitialAd else { return }
ad.present(fromRootViewController: viewController)
interstitialAd = nil
loadInterstitialIfNeeded() // 次の広告をプリロード
}
}
iOS と Android で広告ユニットIDを切り替える
App IDと広告ユニットIDはプラットフォームごとに異なります。
// 広告ユニットIDの分岐(iOS ネイティブ側)
// Swift のコンパイル条件に os(Android) は存在しません。同じ struct の中で
// 同名の static let を二度宣言することもできないため、ここは DEBUG だけで分けます。
struct AdConfig {
#if DEBUG
// 開発中は Google 提供のテストIDを使います
static let bannerAdUnitId = "ca-app-pub-3940256099942544/2934735716"
static let interstitialAdUnitId = "ca-app-pub-3940256099942544/4411468910"
#else
static let bannerAdUnitId = "ca-app-pub-XXXX/YOUR_IOS_BANNER_ID"
static let interstitialAdUnitId = "ca-app-pub-XXXX/YOUR_IOS_INTERSTITIAL_ID"
#endif
}
Android側のIDは、Swiftのコンパイル条件ではなく React Native / Expo の Platform.OS で選びます。テストIDがプラットフォームごとに別の値である点にも注意が必要です。iOS用のテストIDをAndroidに流用すると、広告が返らないまま「実装が間違っている」と思い込むことになります。
// React Native / Expo 側で iOS と Android を分ける
import { Platform } from 'react-native';
const PROD_AD_UNITS = {
ios: {
banner: 'ca-app-pub-XXXX/YOUR_IOS_BANNER_ID',
interstitial: 'ca-app-pub-XXXX/YOUR_IOS_INTERSTITIAL_ID',
},
android: {
banner: 'ca-app-pub-XXXX/YOUR_ANDROID_BANNER_ID',
interstitial: 'ca-app-pub-XXXX/YOUR_ANDROID_INTERSTITIAL_ID',
},
} as const;
// Google が公開しているテストID(iOS と Android で別の値です)
const TEST_AD_UNITS = {
ios: {
banner: 'ca-app-pub-3940256099942544/2934735716',
interstitial: 'ca-app-pub-3940256099942544/4411468910',
},
android: {
banner: 'ca-app-pub-3940256099942544/6300978111',
interstitial: 'ca-app-pub-3940256099942544/1033173712',
},
} as const;
const platform = Platform.OS === 'android' ? 'android' : 'ios';
export const adUnits = __DEV__ ? TEST_AD_UNITS[platform] : PROD_AD_UNITS[platform];
広告の表示タイミングと収益の関係
広告表示のタイミングはARPDAUに直結します。以下は自分のアプリで配置を入れ替えながら確かめた並びです。金額はジャンルと地域構成で桁が動くため、順序だけを残します。
タイミング別のARPDAU(相対比較・金額は地域構成で大きく変わります):
起動時インタースティシャル(セッション開始時1回):
ARPDAU: いちばん高い
継続率への影響: 低〜中程度
向くアプリ: ゲームや娯楽系
機能完了後インタースティシャル(タスク完了ごと):
ARPDAU: 中程度
継続率への影響: 小さい(タイミングが自然なため)
向くアプリ: ユーティリティ系の最良パターン
動画リワード広告(自発的に視聴):
ARPDAU: 低〜中
継続率への影響: プラスに働く(ユーザーが選んで見るため)
向くアプリ: フリーミアムの追加価値提供
バナー(常時表示):
ARPDAU: いちばん低い
継続率への影響: わずかにマイナス
向くアプリ: 滞在が長く、操作の邪魔になりにくい画面
AdMobメディエーションで広告単価を底上げする
AdMob単体で配信していた頃、広告収益は「こういうものだ」と半ば諦めていました。考えを変えたのはメディエーションの導入です。ひとつのインプレッションに対して複数の広告ネットワークを競わせ、最も高い単価を提示したネットワークに配信します。表示回数は同じまま、単価だけが底上げされる仕組みです。
メディエーションには大きく2つの方式があります。仕組みを先に理解しておくと、設定画面で迷いません。
現在は入札対応ネットワークを軸に据え、ウォーターフォールを補助的に併用する構成が主流です。私自身のアプリでも、主要ネットワークを入札へ寄せてから平均eCPMが目に見えて安定しました。日ごとの上下は残りますが、月次で均すと下振れが減ります。
接続しているのは AdMob(Google)・Meta Audience Network・AppLovin・Pangle・Unity Ads の5社です。iOSとAndroidで強いネットワークが異なるため、両方に同じ設定を流用すると取りこぼします。
具体的な金額はアプリのジャンルと地域構成で大きく変わるため断定はできませんが、傾向として次の2点は繰り返し観測しています。
- 地域差: 米国・日本・西欧はeCPMが高く、東南アジア・インド・中南米は低い傾向です。ただし後者はDAUの絶対数が多く、Androidでは「単価×表示回数」で無視できない収益になります
- 時間帯差: 広告主の予算が動く時間帯にeCPMが上がります。起動時インタースティシャルのようなセッション依存の広告は、この波の影響を受けます
メディエーション導入後は、レポートを「どのネットワークがどれだけ埋めたか(Fill Rate)」と「ネットワーク別eCPM」で見るようになります。Fill Rateが慢性的に低いネットワークは外し、入札対応の新しいネットワークを足します。この入れ替えを四半期に一度行うだけで、広告収益は緩やかに改善していきます。派手な施策ではありませんが、こうした地道な調整が積み上がって効いてきます。
iOSのATT対応で広告収益を守る
デュアルストア運用でiOS側の広告収益を語るとき、避けて通れないのがATT(App Tracking Transparency)です。iOS 14.5以降、アプリがIDFA(広告識別子)でユーザーを追跡するには、同意ダイアログの表示が必須になりました。ここで「許可しない」を選ばれると、広告のターゲティング精度が落ち、そのぶんeCPMが下がります。
私が最初にATTダイアログを実装したときは、拒否される方が多い状態がしばらく続きました。表示のタイミングと文言を見直すと、この比率は動きます。
import AppTrackingTransparency
import AdSupport
func requestTrackingIfNeeded() async {
// すでに決定済みなら何もしない
guard ATTrackingManager.trackingAuthorizationStatus == .notDetermined else {
return
}
// システムダイアログを表示
let status = await ATTrackingManager.requestTrackingAuthorization()
switch status {
case .authorized:
// IDFAが利用可能。広告SDKへ渡す
let idfa = ASIdentifierManager.shared().advertisingIdentifier
AdManager.shared.configureTracking(idfa: idfa)
default:
// 非追跡モードでも広告自体は配信される(単価は下がる)
AdManager.shared.configureNonPersonalized()
}
}
実装で効いたのは、システムダイアログをアプリ起動の直後に出さないことでした。ユーザーがまだアプリの価値を体験していない段階で「追跡を許可しますか」と問われても、多くの人は反射的に拒否します。
- タイミング: 最初の主要機能を一度使い終えた直後に表示します。価値を体感した後なら、同意率は目に見えて上がります
- 事前説明(プレプロンプト): システムダイアログの前に、自前の画面で「なぜ許可が無料提供の維持につながるか」を丁寧に伝えます。Appleのガイドライン上、事前説明で誤誘導をしないことが条件です
- 拒否されても止まらない設計: ATTを拒否したユーザーにも、非パーソナライズ広告は配信されます。SKAdNetworkによる集計は同意なしでも機能するため、キャンペーンの計測自体は続けられます
Androidにはこの同意フローがありません。iOSだけがATTのぶん広告単価で不利になります。これも「Androidは広告を厚めに、iOSは課金を厚めに」という配分設計を後押しする、実務上の理由のひとつです。
統合収益ダッシュボードの設計
両プラットフォームの収益をひとつの画面で把握できる仕組みは、運用の負担を確実に軽くします。ただ、ここで最初につまずきました。RevenueCat の REST API には「期間を渡すと全購読者の売上が返ってくる」口がありません。/v1/subscribers/{app_user_id} は名前のとおり1人ぶんを返すエンドポイントで、月次の合計を組み立てる用途には向いていないのです。
ですから月次の集計は、Webhook で届くイベントを自分のデータベースへ溜めて作ります。RevenueCat は購入・更新・解約をイベントとして送ってくれます。受け取って冪等に保存し、あとは SQL で足し合わせるだけです。
# revenue_store.py — RevenueCat の Webhook を受けて月次集計の土台を作る
# 事前準備: RevenueCat のダッシュボードで Webhook の送信先にこのエンドポイントを登録します
import os
import sqlite3
from datetime import datetime
from flask import Flask, request, abort
DB = "revenue.sqlite3"
WEBHOOK_AUTH = os.environ["REVENUECAT_WEBHOOK_AUTH"] # ダッシュボードで設定した Authorization ヘッダの値
app = Flask(__name__)
def init_db() -> None:
with sqlite3.connect(DB) as con:
con.execute("""
CREATE TABLE IF NOT EXISTS rc_event (
event_id TEXT PRIMARY KEY, -- 再送に備えて冪等にする
event_type TEXT NOT NULL,
store TEXT NOT NULL, -- APP_STORE / PLAY_STORE
app_user_id TEXT NOT NULL,
product_id TEXT,
purchased_at_ms INTEGER,
price_usd REAL -- ストア手数料は引かれていない額
)
""")
@app.post("/revenuecat/webhook")
def receive():
if request.headers.get("Authorization") != WEBHOOK_AUTH:
abort(401)
event = request.get_json(force=True)["event"]
if event["type"] not in ("INITIAL_PURCHASE", "RENEWAL", "NON_RENEWING_PURCHASE"):
return "", 204 # 解約予告などは売上に足さない
with sqlite3.connect(DB) as con:
con.execute(
"INSERT OR IGNORE INTO rc_event VALUES (?, ?, ?, ?, ?, ?, ?)",
(
event["id"],
event["type"],
event["store"],
event["app_user_id"],
event.get("product_id"),
event.get("purchased_at_ms"),
event.get("price"),
),
)
return "", 204
def monthly_subscription_usd(year: int, month: int) -> dict:
"""ストア別の課金売上(USD)。返金と解約は別イベントとして差し引きます"""
start = int(datetime(year, month, 1).timestamp() * 1000)
next_year = year + (1 if month == 12 else 0)
next_month = 1 if month == 12 else month + 1
end = int(datetime(next_year, next_month, 1).timestamp() * 1000)
with sqlite3.connect(DB) as con:
rows = con.execute(
"""
SELECT store, COALESCE(SUM(price_usd), 0)
FROM rc_event
WHERE purchased_at_ms >= ? AND purchased_at_ms < ?
GROUP BY store
""",
(start, end),
).fetchall()
totals = {store: round(amount, 2) for store, amount in rows}
return {
"ios_subscription_usd": totals.get("APP_STORE", 0.0),
"android_subscription_usd": totals.get("PLAY_STORE", 0.0),
}
def build_monthly_report(year: int, month: int,
admob_ios_jpy: int, admob_android_jpy: int,
usd_jpy: float) -> dict:
"""課金(USD)と広告(円)を1つのレポートにまとめる"""
sub = monthly_subscription_usd(year, month)
ios_sub_jpy = int(sub["ios_subscription_usd"] * usd_jpy)
android_sub_jpy = int(sub["android_subscription_usd"] * usd_jpy)
return {
"period": f"{year}-{month:02d}",
"ios": {"subscription": ios_sub_jpy, "admob": admob_ios_jpy,
"total": ios_sub_jpy + admob_ios_jpy},
"android": {"subscription": android_sub_jpy, "admob": admob_android_jpy,
"total": android_sub_jpy + admob_android_jpy},
}
イベントの store は APP_STORE / PLAY_STORE で届くため、プラットフォーム別の切り分けはこの1列で足ります。気をつけたいのは price です。ここはユーザーが支払った額で、AppleやGoogleの手数料は引かれていません。ストアからの入金額と突き合わせるときは、手数料率を別に掛ける必要があります。返金や解約は別のイベント種別で届きますから、売上から差し引く処理は分けて書いたほうが、あとから読み返したときに追いやすいかもしれません。
AdMob Reporting API で広告収益まで自動集計する
先ほどの統合ダッシュボードでは、AdMob収益を手動入力のままにしていました。運用が続くと、この手作業が地味に負担になります。AdMob Reporting APIを使えば、課金収益と同じスクリプトの中で広告収益まで取得でき、月初の確認作業が数分で終わります。
# AdMob Reporting API で月次の広告収益をプラットフォーム別に取得する
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
def get_admob_revenue(account_id: str, start: dict, end: dict) -> dict:
"""
account_id: "pub-XXXXXXXXXXXXXXXX"
start / end: {"year": 2026, "month": 4, "day": 1} 形式
"""
creds = Credentials.from_authorized_user_file("admob_token.json")
service = build("admob", "v1", credentials=creds)
report_spec = {
"dateRange": {"startDate": start, "endDate": end},
"dimensions": ["PLATFORM"], # iOS / ANDROID で分割
"metrics": ["ESTIMATED_EARNINGS", "IMPRESSIONS"],
}
response = service.accounts().networkReport().generate(
parent=f"accounts/{account_id}",
body={"reportSpec": report_spec},
).execute()
result = {"ios_micros": 0, "android_micros": 0}
for row in response:
r = row.get("row")
if not r:
continue
platform = r["dimensionValues"]["PLATFORM"]["value"]
earnings = int(r["metricValues"]["ESTIMATED_EARNINGS"]["microsValue"])
if platform == "IOS":
result["ios_micros"] += earnings
elif platform == "ANDROID":
result["android_micros"] += earnings
return result
# ESTIMATED_EARNINGS はマイクロ単位(1,000,000 で1通貨単位)で返る
raw = get_admob_revenue(
"pub-XXXXXXXXXXXXXXXX",
{"year": 2026, "month": 4, "day": 1},
{"year": 2026, "month": 4, "day": 30},
)
admob_ios_jpy = raw["ios_micros"] // 1_000_000
admob_android_jpy = raw["android_micros"] // 1_000_000
print(f"AdMob iOS: ¥{admob_ios_jpy:,} / Android: ¥{admob_android_jpy:,}")
ここで返る ESTIMATED_EARNINGS はマイクロ単位(1,000,000で1通貨単位)である点に注意してください。桁を取り違えると、レポートの数字が6桁ずれます。私は最初にこれを見落として、「今月は広告収益が100万倍になった」と一瞬だけ喜んだことがあります。
この関数を先ほどの build_monthly_report に差し込めば、admob_ios_jpy と admob_android_jpy の手動入力を消せます。課金と広告、iOSとAndroidの4象限が1回のスクリプト実行で揃うと、収益の全体像が一目で把握できるようになります。通貨単位はAdMobアカウントの設定に従うため、円建てアカウントならそのまま円で返ります。
ユーザーLTVのプラットフォーム別計算
どちらのプラットフォームへの投資対効果が高いかを判断するには、プラットフォーム別のLTV(顧客生涯価値)計算が必要です。
def calculate_ltv_by_platform(platform: str, data: dict) -> dict:
"""
プラットフォーム別LTV計算
data = {
"monthly_arpu": 月次ARPU(円),
"monthly_churn_rate": 月次解約率(0〜1),
"ad_revenue_per_dau": 広告ユーザー一人あたりの月次収益
}
"""
arpu = data["monthly_arpu"]
churn = data["monthly_churn_rate"]
ad_rev = data.get("ad_revenue_per_dau", 0)
# 課金ユーザーLTV
subscription_ltv = arpu / churn if churn > 0 else arpu * 12
# 広告ユーザーLTV(平均継続月数 × 月次広告収益)
avg_retention_months = 1 / churn if churn > 0 else 12
ad_ltv = ad_rev * avg_retention_months
return {
"platform": platform,
"subscription_ltv_jpy": int(subscription_ltv),
"ad_user_ltv_jpy": int(ad_ltv),
"avg_retention_months": round(avg_retention_months, 1)
}
# 入力例(自分のアプリの実測値に置き換えてください)
ios_ltv = calculate_ltv_by_platform("iOS", {
"monthly_arpu": 1200,
"monthly_churn_rate": 0.08,
"ad_revenue_per_dau": 120
})
android_ltv = calculate_ltv_by_platform("Android", {
"monthly_arpu": 880,
"monthly_churn_rate": 0.11,
"ad_revenue_per_dau": 160 # Androidは広告収益が高い
})
print(f"iOS: 課金LTV ¥{ios_ltv['subscription_ltv_jpy']:,} / 広告LTV ¥{ios_ltv['ad_user_ltv_jpy']:,}")
print(f"Android: 課金LTV ¥{android_ltv['subscription_ltv_jpy']:,} / 広告LTV ¥{android_ltv['ad_user_ltv_jpy']:,}")
# 出力例:
# iOS: 課金LTV ¥15,000 / 広告LTV ¥1,500
# Android: 課金LTV ¥8,000 / 広告LTV ¥1,454
この計算で重要なのは、iOSの課金LTVはAndroidより高い傾向がある一方で、Androidの広告LTVは課金LTVとの差が相対的に小さいことです。これがAndroidをAdMob中心のマネタイズにする根拠になります。
Rork MaxでのクロスプラットフォームUI生成の注意点
Rork MaxはSwiftUI(iOS)の生成が主力ですが、課金UIについては両プラットフォームで設計を共通化できる部分と、分ける必要がある部分があります。
共通化できる部分:
- プランの表示ロジック(価格一覧・機能比較)
- 購入後の状態管理(isPremium フラグ)
- RevenueCat の呼び出しロジック
分ける必要がある部分:
- 審査ガイドライン上の表示要件(App Store はより厳格)
- ペイウォールのUIコンポーネント(プラットフォーム設計に合わせる)
- バックボタンの挙動(Android は必須、iOS は任意)
Rork Maxへのプロンプトでは、生成するコードがどちらのプラットフォーム向けかを明示することで、適切な設計パターンが出力されます。
Rork Maxプロンプト例(ペイウォール画面):
「RevenueCatを使ったサブスクリプションのペイウォール画面を作成してください。
対象プラットフォーム: iOS(SwiftUI)
要件:
- 月額プラン(¥1,200)と年額プラン(¥9,800)の2つを表示
- 年額プランにはバッジで「33%オフ」を表示
- 14日間無料試用のバナーを目立たせる
- App Storeのガイドライン上必要な購読条件の表示(下部に)
- エラー時(決済失敗など)の状態も実装すること」
ストア審査を通すための収益化ルール
両プラットフォームの審査基準の違いを知らずに実装すると、審査落ちで数週間のロスが生じます。
App Store(iOS)の主要ルール
- ガイドライン3.1.1: デジタルコンテンツへのアクセス提供(サブスク・IAP)にはApp Storeの決済を使う必要があります。外部決済への誘導は認められません
- ガイドライン3.1.3: リーダーアプリ(Kindleのような外部で購入したコンテンツを見るアプリ)は例外あり
- 広告: インタースティシャルは「意図せずタップしにくい」配置であることが求められます。起動直後3秒以内のフルスクリーン広告は、審査落ちの原因になります
// App Store審査で問題になるパターン(禁止)
// ❌ 外部サイトへの決済誘導
Button("ウェブサイトで購入する(お得)") {
UIApplication.shared.open(URL(string: "https://yoursite.com/checkout")!)
}
// ✅ StoreKit経由での購入のみ
Button("Proにアップグレード") {
Task { try await purchase(packageId: "pro_monthly") }
}
Google Play の主要ルール
- 支払いポリシー: デジタルコンテンツへのアクセスにはGoogle Playの課金を使用(ただし、ウェブアプリのラッパーや物理商品は例外)
- 広告: 偽の閉じるボタンや誤タップを誘導するレイアウトは禁止。インタースティシャルに5秒のカウントダウンを表示することが推奨(強制ではない)
- 定期購読の解約: アプリ内から簡単に解約できる必要があります(App Storeより要件が明確です)
実際の運用で学んだこと
両プラットフォームを1年以上運用して、最も影響が大きかった発見を3つ挙げます。
発見1: AndroidのAdMob収益は季節変動が大きい
iOSのサブスクリプション収益は比較的安定していますが、AndroidのAdMob収益は広告主の予算サイクルに連動して変動します。Q4(10〜12月)は広告単価が高く、Q1(1〜3月)は下がります。この変動を見越した資金計画が必要です。
発見2: iOS向けの機能がAndroidで喜ばれる
iOSの審査が通ったUIデザインや機能は、そのままAndroidに展開しても高い評価を受けます。Google Playのユーザーもデザインのクオリティに敏感で、「iOSっぽい丁寧なUI」は好意的に受け取られます。
発見3: 年額プランの比率がLTVを決める
月額プランより年額プランを選ぶユーザーの比率が高いほど、解約率が下がりLTVが上がります。年額プランの見せ方を見直した時期は、新規ユーザーを増やしていないのに月次収益の下振れが小さくなりました。増分の大きさは私のアプリの事情に依存しますが、効く方向はどのアプリでも同じだと感じています。月換算の価格を前に出し、年額を先に並べるだけでも比率は動きます。
次のアクション
デュアルストア展開の最初の一歩は、Android版のリリースではなく「RevenueCatのプロジェクトにAndroidを追加する」作業です。課金ロジックはSDKの側で抽象化されているため、ここはアプリのコードをほとんど触らずに終わります。
労力がかかるのはそのあとです。ストアごとのスクリーンショットと説明文、そしてAndroid実機での購入テスト——このふたつは、iOSで通った手順をそのまま流用できません。Rork MaxでAndroid向けのビルドを生成する流れを一度作っておくと、二本目からはずいぶん楽になります。
まずはAdMobの管理画面を開いて、Android用プロパティのレポートが何を単位にした数字なのかを確かめるところから始めていただければと思います。桁を取り違えたまま増減を追っても、判断の材料にはならないのです。私はそこで一度つまずいてから、数字を見る前に単位を確かめる順番に変えました。
長い記事をここまでお読みいただき、ありがとうございました。