ads-and-iap-monetization について
モバイルアプリ開発者向けに、広告とアプリ内購入の方針を定めるスキルです。任意の報酬広告、インタースティシャルの制限、権利確認、起動順序を扱い、無料利用の経路と購入者の広告なし利用を保ちます。
主な機能
- 01報酬広告の結果と付与内容の定義
- 02報酬獲得回数の日次制限
- 03インタースティシャルの時機と頻度制限
- 04権利確認と購入復元の一元化
- 05権利確認と広告初期化の順序管理
活用シーン
- 01任意視聴で単一の特典を得る広告の設計
- 02インタースティシャルの境界と頻度制限の設定
- 03一回限りのアンロックと購入復元への対応
- 04広告未配信時と権利状態のテスト
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill ads-and-iap-monetizationインストール後、Claude Code で/ads-and-iap-monetizationを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
examples/rewarded_credit_economy.dartreferences/ad-unit-setup.mdentitlement-and-restore.mdscripts/check-release-ad-ids.sh詳細
広告と IAP による収益化
広告と課金の境界に適用するポリシー層です。リワード広告の視聴で「何を」付与するのか、インタースティシャル広告を「いつ」「どのくらいの頻度で」表示できるのか、エンタイトルメントが両者を「どのように」制御するのか、起動時に「どの順番で」初期化するのかを定めます。境界がすでに存在することを前提としています。インターフェースの宣言、フレーバーごとのオーバーライド、機能コードからの SDK 型の排除は、service-boundary-and-native の責務です。
この仕組みが前提とするモデルは、無料で、まずはユーザーの任意選択です。ユーザーが自ら選んで提供した注意と価値を交換し、非消費型の購入を 1 つ行うと、その交換がなくなります。これだけが実現可能なモデルではありませんが、以下のルールは、広告収益で成り立つアプリが誠実で、審査可能であり、「アドウェアのように感じる」ものに分類されないために必要です。
譲れないルール
-
広告とストアへの呼び出しはすべて、注入されたインターフェースを経由させ、機能コードでは SDK 型を使いません。
AdsService、BillingServiceを使います。具象 SDK はコンポジションルートで一度だけ接続し、テストではフェイクを使います。仕組みの責務はservice-boundary-and-nativeにあります。ここで再定義しないでください。 -
リワード広告はユーザーが任意に選ぶもので、具体的な特典をちょうど 1 つ付与します。 明示的な「視聴して獲得」タップからのみ再生し、自動再生はしません。また、付与するのはクレジット、追加の試行回数、一部の表示など、1 単位だけです。ユーザーが目指している結果全体を付与してはいけません。理由:自動再生される「リワード」広告は、名前を変えただけのインタースティシャル広告です。また、タスクを完了させる報酬を与えると、ユーザーが求めていた体験そのものが損なわれます。
-
本当に報酬が発生した場合に限り付与します。 結果は、シールド型(
Rewarded/Dismissed/NoFill)としてモデル化し、網羅的に切り替えて処理します。Dismissed(途中で閉じた)とNoFillは何も付与せず、状態も変更しません。理由:戻り値をboolにすると、「広告が存在しなかった」と「ユーザーが辞退した」が同じになってしまいますが、この 2 つには異なる UI が必要です。 -
注入された
Clockを使い、ポリシー層で 1 日あたりの獲得ループを制限します。 視聴して獲得するループに上限がなければ、ユーザーは誰でも、特典で制限しようとしていたものを回避できます。また、上限は、それが守るルールのすぐそばに置くべきです。広告ネットワークのコンソールに置いてはいけません(オプトイン形式ではコンソール側の頻度制限がかえって害になる理由については、references/ad-unit-setup.mdを参照)。上限の基準には、必ずclock.now()から導出した暦日を使い、DateTime.now()は使わないでください。 -
行き詰まる獲得操作をリリースしてはいけません。 新しい広告アカウントでは広告のフィル率がほぼゼロです。そのため、「視聴して獲得」ボタンを常時表示すると、毎回 「利用できる広告がありません」 と表示されます。これは UX を損ない、壊れているように見える操作として ガイドライン 2.1 に基づくリジェクトのリスクもあります。広告を事前に読み込み、ストリームとして利用可否を公開し、広告が実際に読み込み済みの間(または報酬の引き換え処理中)に限って操作を表示します。ユーザーが必要とする機能は、広告を視聴しなくても常に利用できなければなりません。
-
インタースティシャル広告は、自然な区切りでのみ表示し、サービスが管理する 2 要素の上限を設けます。 表示できるのは、ユーザーがすでに予期している完了時または画面遷移時の境界だけです。作業の途中、オンボーディング中、初回セッション中には表示しません。上限の両方を満たす必要があります。N 回完了するごとに最大 1 回、かつ前回の表示から M 秒以上経過していることです。カウントと時刻の管理は
maybeShowInterstitial()の内部に置き、表示したかどうかを返します。呼び出し側でカウントしてはいけません。理由:3 か所の呼び出し元で上限を適用すると、互いに食い違う 3 つの上限になります。 -
主な作業画面にバナーを表示しません。 ユーザーが実際に作業している画面にバナーを置くと、レイアウトが再配置され、誤タップを誘発し、どの形式よりも収益が低くなります。妥当な配置場所はメニューまたはホーム画面のみで、表示は任意です。
-
非消費型の買い切りアンロックと復元を用意し、起動のたびに再確認します。 ⚠️ アプリの 初回リリースでは、アンロックをアプリのバージョンと同じ App Review の申請に含める 必要があります。Apple は各タイプの最初の購入をバージョンと一緒に申請するよう求めており、 バージョンだけをリリースすると、ガイドライン 2.1(b)(
release-and-store-shipping)に基づき、申請全体が審査されずに却下されます。販売は一度きりにし、表示される 復元機能を用意します。再インストール時や新しいデバイスで、サポートへの問い合わせなしに 再び利用できるよう、起動時にエンタイトルメントをバックグラウンドで再確認します。一度限りの 機能にサブスクリプションを使わず、オプトインのリワード獲得手段がすでにある場合に、現金で購入する 消費型の通貨も販売しないでください。 -
派生した
isEntitledプロバイダーだけをゲートにします。 すべて、つまりすべての広告呼び出し箇所、 すべての上限、すべてのペイウォール表示がこれを参照します。生のエンタイトルメントストリームを 条件分岐に使ってはいけません。理由:同じ状態を二度読み取ると、二つ目のゲートが一つ目と 食い違うようになります。 -
広告 SDK を初期化する前に、エンタイトルメントを復元します。 広告呼び出しを何もしないようにする だけでは不十分です。
AdsService.init()がすでに実行されていれば、購入者のデバイスでは SDK が 起動し、同意が解決され、トラッキングの許可を求めるプロンプトが表示され、デバイスが識別されています。 これらはすべて、購入者が避けるために料金を支払ったことです。まず復元を待ち、エンタイトルメントが ないことを確認できたユーザーに対してのみ広告を初期化します。ストアまたはネットワークのエラーでは エンタイトルメントなし として処理し、広告を初期化します。ひとつの呼び出しの失敗を理由に、すべての 無料ユーザーから広告を取り上げてはいけません。そのため、オフライン起動時に所有者の状態を正しく 保てるよう、最後に確認できたエンタイトルメントをローカルにキャッシュします。references/entitlement-and-restore.mdを参照してください。 -
購入後は、UI が反応する前に、単一の書き込み経路を通じて状態を永続化します。 リポジトリを通じて トランザクションをひとつコミットし、その後
isEntitledの再通知を受けて UI を更新します。楽観的に 画面を閉じてはいけません。理由:処理途中でクラッシュしても、「支払い済みなのに利用権がない」という 状態を決して残してはいけません。書き込み経路の仕組みはpersistence-driftが担当します。 -
前払い制と広告サポートを併用してはいけません。 どちらか一方を選びます。アプリの料金を請求したうえで 購入者の注意を広告収益化に使うのは、レビューでユーザーの批判を招くパターンであり、ペイウォールの 論拠も成り立たなくなります。
-
広告ユニット識別子は設定値であり、秘密情報ではありません。ただし、サンプル ID をリリースに含めては いけません。 逆コンパイルしたアプリでは誰でも確認できるため、
--dart-defineよりコンパイル時定数を 使うほうが確実です(リリースビルドでは定数を入れ忘れることがありません)。これをリリースの条件に します。scripts/check-release-ad-ids.shは、ネットワークで広く知られているサンプル ID がまだ 含まれているリリースを失敗させます。これらの ID では「テスト広告」のクリエイティブが配信され、 審査担当者には不具合のように見えます。
獲得ループの全体像
// Policy layer: depends on the interfaces and the entitlement gate — never on an SDK.
sealed class RewardOutcome { const RewardOutcome(); }
final class Rewarded extends RewardOutcome { const Rewarded(); }
final class Dismissed extends RewardOutcome { const Dismissed(); } // closed early
final class NoFill extends RewardOutcome { const NoFill(); } // no ad existed
class CreditEconomy extends Notifier<CreditState> {
static const int freeCreditsPerDay = 1; // the allowance the design assumes
static const int maxEarnedPerDay = 3; // the integrity ceiling (rule 4)
@override
CreditState build() => const CreditState();
/// Opt-in earn path. Grants ONLY on `Rewarded`, and only under the daily cap.
Future<Result<Credit, MonetizationFailure>> watchToEarn() async {
if (ref.read(isEntitledProvider).valueOrNull ?? false) return _grant(); // no ad at all
if (state.earnedToday(ref.read(clockProvider)) >= maxEarnedPerDay) {
return const Err(EarnCapReached());
}
return switch (await ref.read(adsServiceProvider).showRewarded()) {
Rewarded() => _grant(),
Dismissed() => const Err(RewardNotEarned()), // distinct: the user closed it
NoFill() => const Err(NoAdAvailable()), // distinct: nothing to show
};
}
}
これを呼び出すコントロールは、利用可能かどうかに連動しているため、行き止まりにはなりません(ルール 5)。
// Shown only while an ad is genuinely loaded; the free path stays reachable regardless.
final rewardedReadyProvider =
StreamProvider<bool>((ref) => ref.watch(adsServiceProvider).rewardedAvailability);
if (ref.watch(rewardedReadyProvider).valueOrNull ?? false)
const WatchToEarnButton(),
インタースティシャルの呼び出し箇所では、何もカウントしません(ルール 6)。
Future<void> onTaskCompleted(WidgetRef ref) async {
if (ref.read(isEntitledProvider).valueOrNull ?? false) return; // rule 9
await ref.read(adsServiceProvider).maybeShowInterstitial(); // owns both cap halves
}
起動順序
// Entitlement first, ads only if the user is not entitled (rule 10).
// Startup-sequence mechanics are owned by `app-startup-and-bootstrap`.
final entitlement = await ref.read(billingServiceProvider).restore(); // failure ⇒ not entitled
if (!entitlement.isEntitled) {
await ref.read(adsServiceProvider).init(); // consent / tracking prompt happens in here
await ref.read(adsServiceProvider).preloadRewarded();
}
エンタイトルメントを持つユーザーに対して、偽の広告サービスが initCount == 0 を記録することを
アサートするテストでカバーしてください。このひとつのアサーションが、将来のリファクタリングでも
ルール 10 を守ります。
アンチパターン
- 「リワード」広告を自動再生すること、または
Dismissed/NoFillで付与すること — 広告の視聴はユーザーが選択するものであり、付与するのは視聴が完了した場合のみです。 - リワードの結果を
boolで表すこと — 「広告の在庫なし」と「ユーザーが閉じた」は異なるUIが必要です。シールド型を使用してください。 - 上限のない獲得ループ、またはアプリではなく広告コンソールで上限を設定すること — コンソールで上限に達すると広告の在庫がなくなり、在庫がないと操作項目が非表示になる(ルール5)ため、ユーザーには理由が見えないまま利用手段が失われます。
- 新しいアカウントに獲得ボタンを常時表示すること — 「広告を利用できません」という状態がずっと続きます。ガイドライン2.1に抵触します。
- 付与によってユーザーのタスクを完了させること — 表示するのは次のステップまでにし、ゴールを表示しないでください。
- タスクの途中、初回起動時、オンボーディング中にインタースティシャル広告を表示すること、または完了数をカウントしたり時刻を参照したりする呼び出し元 — 広告表示のトリガーは中断箇所だけとし、上限はサービス側で管理します。
- 作業画面にバナーを表示すること — 表示する価値がない唯一の配置です。
- ウィジェットがエンタイトルメントの生ストリームを読み取ること — すべて、単一の派生ゲートを読み取ります。
- エンタイトルメントがあるユーザー向けに広告を無効化する際、
init()の前に復元処理を移動しないこと — SDK、同意フロー、トラッキングのプロンプトはすでに表示されています。 - 復元呼び出しが失敗したとき、すべてのユーザーに広告を表示しないこと — 失敗はエンタイトルメントなしを意味します。最後に確認できた状態をキャッシュし、オフラインでも購入済みユーザーが正しく扱われるようにします。
- エンタイトルメントが永続的に書き込まれる前にペイウォールを閉じること — 先に永続化し、その後ゲートから再通知させます。
- 起動時の復元を省略すること — 再インストールやデバイスの変更がサポートへの問い合わせにつながります。
- 買い切りと広告を併用すること、または一度限りのアンロックにサブスクリプションを使うこと。
- 広告ネットワークのサンプル広告IDをリリースすること — 「テスト広告」のクリエイティブでは収益が得られず、審査で不具合のように見えます。
完了の定義
- 広告と課金には、注入されたインターフェース経由でのみアクセスできること。SDKの型が機能コードに現れないこと。
- リワード広告はユーザーが選択でき、特典をちょうど1つ付与し、
Rewardedの場合にのみ付与すること。結果型はシールド型で、網羅的に分岐すること。 - 獲得ループは注入された
Clockを基準に暦日ごとに上限を設け、その上限をアプリ側で管理すること。 - 獲得操作は事前に読み込まれ、広告が読み込まれている場合を除いて非表示になること。何も視聴しなくてもタスクを完了できること。
- インタースティシャル広告は中断箇所でのみ表示すること。
maybeShowInterstitial()が回数と経過時間の両方の上限を管理し、表示したかどうかを返すこと。呼び出し元で回数をカウントしないこと。 - 主な作業画面にバナー広告を表示しないこと。
- 非消費型アイテムを1つ用意し、復元を常に表示すること。エンタイトルメントは起動のたびに通知なしで再確認すること。
- 初回リリースでは、アプリのバージョンと同じ申請でプロダクトをリリースし、App Review用スクリーンショット、税区分、提供状況、アプリの各ロケールに対するローカライズを1つずつ用意すること。
-
isEntitledを唯一のゲートとし、機能側でエンタイトルメントの生ストリームを使った分岐をしないこと。 - 復元処理は
AdsService.init()の前に実行すること。エンタイトルメントがあるユーザーに対して、フェイクのinitCount == 0を検証するテストを用意すること。復元に失敗した場合はエンタイトルメントなしとして扱い、オフライン用にローカルキャッシュを使うこと。 - UIが反応する前に、購入を単一の書き込み経路で確定すること。
- リリース用広告IDを
scripts/check-release-ad-ids.shで検証し、リリースビルドにサンプルIDが含まれないこと。 - フェイクを使ったテストで、視聴→付与、上限到達→拒否、広告の在庫なし→操作項目を非表示、購入→広告がなくなり上限が解除されることを検証すること。
関連スキル
service-boundary-and-nativeでは、AdsService/BillingServiceの宣言、フレーバーごとの実装、これらのテストで使用するフェイクについて説明します。app-startup-and-bootstrapでは、復元してから初期化するシーケンスをmain()のどこに置くかを説明します。state-management-riverpodでは派生ゲートのプロバイダーを、persistence-driftでは購入が確定する単一の書き込み経路を説明します。error-handling-typed-resultsでは、上記で返されるResult/Failureの各ケースを説明します。release-and-store-shippingでは、広告SDKに応じて必要となるプライバシーに関する申告と、審査中に広告在庫が空である理由を説明する審査担当者向けの注記を説明します。
参考資料
- App Store 審査ガイドライン(2.1 アプリの完全性、3.1 支払い):https://developer.apple.com/app-store/review/guidelines/
in_app_purchase(StoreKit / Play Billing):https://pub.dev/packages/in_app_purchase- Flutter — アプリ内購入のクックブック:https://docs.flutter.dev/cookbook/plugins/in-app-purchases
google_mobile_ads:https://pub.dev/packages/google_mobile_ads- AdMob リワード広告(Flutter):https://developers.google.com/admob/flutter/rewarded
- User Messaging Platform / 同意:https://developers.google.com/admob/flutter/privacy
- App Tracking Transparency:https://developer.apple.com/documentation/apptrackingtransparency