ads-and-iap-monetization

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Ads and IAP monetization

广告与 IAP 商业化

The policy layer over the ads and billing seams: what a rewarded view grants, when an interstitial may fire and how often, how an entitlement gates both, and in what order they initialize at launch. It assumes the seam already exists — declaring the interface, overriding it per flavor, and keeping SDK types out of feature code belong to
service-boundary-and-native
.
The model this encodes is free, opt-in-first: value is exchanged for attention the user chose to give, and one non-consumable purchase removes the exchange. It is not the only viable model, but the rules below are what keep any ad-funded app honest, reviewable, and out of the "feels like adware" bucket.
广告和计费接缝之上的策略层:规定激励广告展示授予什么、插屏广告何时可以触发及触发频率、entitlement 如何门控二者,以及它们在启动时以何种顺序初始化。本文假定相关接缝已经存在——声明接口、按 flavor 覆盖实现,以及确保功能代码中不出现 SDK 类型,均属于
service-boundary-and-native
的职责。
这里编码的模型是免费、主动选择优先:用户以自己选择提供的注意力换取价值,而一次非消耗型购买会消除这种交换。它并非唯一可行的模型,但以下规则能让任何依靠广告盈利的应用保持诚实、可审核,并避免落入“感觉像广告软件”的类别。

Non-negotiable rules

不可妥协的规则

  1. Every ad and store call goes through an injected interface, never an SDK type in feature code.
    AdsService
    ,
    BillingService
    ; the concrete SDK is wired once at the composition root and faked in tests. Mechanism is owned by
    service-boundary-and-native
    — do not re-derive it here.
  2. A rewarded view is opt-in and grants exactly one concrete benefit. It fires only from an explicit "watch to earn" tap, never automatically, and it hands back one unit — a credit, an extra attempt, a partial reveal — never the whole outcome the user is working toward. WHY: an auto-playing "rewarded" ad is an interstitial wearing a costume, and a grant that finishes the task for the user destroys the thing they came for.
  3. Grant only on a genuine reward outcome. Model the result as a sealed type (
    Rewarded
    /
    Dismissed
    /
    NoFill
    ) and switch exhaustively;
    Dismissed
    (closed early) and
    NoFill
    grant nothing and leave state untouched. WHY: a
    bool
    return collapses "no ad existed" into "user declined" and the two need different UI.
  4. Cap the earn loop per day, in the policy layer, against an injected
    Clock
    .
    An uncapped watch-to-earn loop lets any user bypass whatever the benefit was rationing, and the ceiling belongs next to the rule it protects — not in the ad network console (see
    references/ad-unit-setup.md
    for why a console-side frequency cap actively harms an opt-in format). Key the cap to a calendar day derived from
    clock.now()
    , never
    DateTime.now()
    .
  5. Never ship an earn control that can dead-end. A brand-new ad account has near-zero fill, so an always-visible "watch to earn" button answers "no ads available" every time — poor UX and a Guideline 2.1 rejection risk for a control that looks broken. Preload, expose availability as a stream, and show the control only while an ad is actually loaded (or a redemption is in flight). Whatever the user needs must remain reachable without watching anything.
  6. Interstitials fire only at a natural break, under a two-part cap the service owns. The only eligible moment is a completion or transition boundary the user already expected — never mid-task, never during onboarding, never in the first session. Both halves of the cap must pass: at most one per N completions and at least M seconds since the last one. Put the counting and the clock inside
    maybeShowInterstitial()
    , and return whether it showed, so no caller counts anything. WHY: a cap enforced at three call sites is three caps that will disagree.
  7. No banner on the primary work surface. A banner on the screen where the user is actually doing the thing reflows layout, invites mis-taps, and earns the least of any format. A menu or home surface is the only defensible placement, and it is optional.
  8. One non-consumable unlock plus Restore, re-checked on every launch. ⚠️ On the app's first release the unlock must be submitted in the same App Review submission as the app version — Apple requires the first purchase of each type to ride a version, and shipping the version alone gets the whole submission closed unreviewed under Guideline 2.1(b) (
    release-and-store-shipping
    ). Sell it once; expose a visible Restore; silently re-check entitlement at launch so a reinstall or a new device re-grants it without a support ticket. Not a subscription for a one-time capability, and not a consumable currency sold for cash where an opt-in rewarded path already exists.
  9. One derived
    isEntitled
    provider is the only gate.
    Everything — every ad call site, every cap, every paywall affordance — reads it. Nothing branches on the raw entitlement stream. WHY: a second read of the same truth is a second gate that will drift out of agreement with the first.
  10. Restore the entitlement BEFORE initializing the ad SDK. No-op-ing ad calls is not enough: if
    AdsService.init()
    already ran, a paying user has had the SDK started, consent resolved, a tracking prompt shown, and their device identified — everything they paid to avoid. Await the restore first and initialize ads only for a confirmed non-entitled user. A store or network failure resolves to not entitled and initializes — never withhold ads from every free user because one call flaked — so cache the last known entitlement locally to keep an offline launch correct for an owner. See
    references/entitlement-and-restore.md
    .
  11. A purchase persists through the single write path before the UI reacts. One committed transaction through the repository, then the UI updates because
    isEntitled
    re-emits — never an optimistic dismiss. WHY: a crash mid-flow must never leave a "paid but not entitled" ghost. Write-path mechanics are owned by
    persistence-drift
    .
  12. Never paid-upfront and ad-supported. Pick one. Charging for the app and then monetizing the buyer's attention is the pattern users punish in reviews, and it makes the paywall unarguable.
  13. Ad unit identifiers are configuration, not secrets — but sample ids must never ship. They are visible in any decompiled app, so compile-time constants beat
    --dart-define
    (a release build cannot forget a constant). Gate the release on it:
    scripts/check-release-ad-ids.sh
    fails a release that still carries the network's well-known sample ids, which serve "test ad" creatives and read as broken to a reviewer.
  1. 每次广告和商店调用都必须经过注入接口,功能代码中绝不能出现 SDK 类型。
    AdsService
    、
    BillingService
    ;具体 SDK 只在 composition root 中完成一次接线,并在测试中使用 fake。 机制由
    service-boundary-and-native
    负责——不要在这里重复推导。
  2. 激励广告展示必须由用户主动选择,并且只授予一项具体利益。 它只能由明确的“观看获利”点击触发,绝不能自动播放,并且只能给予一个单位——积分、额外尝试次数或局部揭示——绝不能直接完成用户正在争取的整个结果。 原因:自动播放的“激励广告”只是换了外衣的插屏广告,而直接完成用户任务的奖励会摧毁他们原本要完成的内容。
  3. 只有在真实奖励结果下才授予奖励。 将结果建模为 sealed type(
    Rewarded
    /
    Dismissed
    /
    NoFill
    ),并通过穷尽式
    switch
    处理;
    Dismissed
    (提前关闭)和
    NoFill
    不授予任何奖励,也不改变状态。 原因:
    bool
    返回值会把“没有广告”混同为“用户拒绝”,而二者需要不同的 UI。
  4. 在策略层基于注入的
    Clock
    对每日获利循环设上限。
    不设上限的观看获利循环会让任何用户绕过原本受该利益限制的配额,因此上限应与它所保护的规则放在一起——而不是放在广告网络控制台中(至于为什么控制台侧的频次上限会严重损害主动选择格式,请参阅
    references/ad-unit-setup.md
    )。 上限按从
    clock.now()
    推导出的日历日计算,绝不能使用
    DateTime.now()
    。
  5. 绝不能发布可能走到死路的获利控件。 全新广告账户的广告填充率接近于零,因此始终可见的“观看获利”按钮每次都只会得到“暂无广告”的结果——这既是糟糕的 UX,也会让控件看起来已经损坏,从而产生 Guideline 2.1 的拒审风险。 应预加载广告、以流的形式暴露可用性,并且仅在广告确实已加载时(或兑换正在进行时)显示控件。无论用户需要完成什么,都必须能在不观看任何广告的情况下到达。
  6. 插屏广告只能在自然断点触发,并受服务层负责的双重上限约束。 唯一符合条件的时机是用户早已预期的完成节点或转场边界——绝不能在任务中途、引导流程中或首次会话中触发。 必须同时满足上限的两个条件:每完成 N 次最多展示一次且距上次展示至少间隔 M 秒。计数和时钟读取都应放在
    maybeShowInterstitial()
    内部,并返回是否实际展示,这样调用方就无需进行任何计数。 原因:在三个调用点分别实施上限,最终就会得到三个彼此不一致的上限。
  7. 主要工作界面上不得放置横幅广告。 在用户真正执行任务的屏幕上放置横幅广告会改变布局、容易引发误触,而且收益是所有广告形式中最低的。菜单或首页界面是唯一站得住脚的放置位置,而且可选。
  8. 一个非消耗型解锁,加上 Restore,并在每次启动时重新检查。 ⚠️ 应用首次发布时,该解锁必须与应用版本在同一次 App Review 提交中一并提交——Apple 要求每种购买类型的首次购买都必须随某个应用版本一同提交;如果只提交版本,整个提交会依据 Guideline 2.1(b)(
    release-and-store-shipping
    )被直接关闭且不予审核。 该功能只销售一次;提供可见的 Restore;在启动时静默重新检查 entitlement,以便用户重装应用或更换设备后能够重新获得权益,而无需提交客服工单。 一次性能力不应做成订阅;在已经存在主动选择的奖励广告路径时,也不应将可消耗货币作为现金付费商品出售。
  9. 仅以派生
    isEntitled
    provider 作为唯一门控。
    所有内容——每个广告调用点、每项上限、每个付费墙入口——都读取它。任何位置都不得基于原始 entitlement 流进行分支。 原因:同一事实的第二次读取就是第二道门控,它最终会与第一道门控产生分歧。
  10. 必须在初始化广告 SDK 之前恢复 entitlement。 仅将广告调用设为空操作并不够:如果
    AdsService.init()
    已经运行,付费用户仍会启动 SDK、处理同意事项、展示跟踪提示并被标识设备——而这些正是他们付费想要避免的。 必须先等待恢复完成,并且只为已确认未获 entitlement 的用户初始化广告。商店或网络调用失败时,应按未获 entitlement 处理并继续初始化广告——绝不能因为某一次调用偶发失败就向所有免费用户屏蔽广告——因此应在本地缓存最近一次已知 entitlement,让离线启动时已购买用户的状态仍然正确。 参见
    references/entitlement-and-restore.md
    。
  11. 购买必须先通过单一写入路径完成持久化,之后 UI 才能响应。 先通过 repository 提交一笔交易,随后由
    isEntitled
    重新发射来更新 UI——绝不能乐观地关闭付费墙。 原因:流程中途崩溃绝不能留下“已付款但未获 entitlement”的幽灵状态。写入路径的具体机制由
    persistence-drift
    负责。
  12. 绝不能同时采用预付费和广告支持模式。 二选一。先向应用收费,再变现付费用户的注意力,是用户会在评论中严厉惩罚的模式,也让付费墙显得无可辩驳。
  13. 广告单元 ID 属于配置而非机密信息,但示例 ID 绝不能进入发布版本。 它们在任何反编译应用中都清晰可见,因此编译期常量优于
    --dart-define
    (发布构建无法“忘记”常量)。 应将这一点设为发布门禁:
    scripts/check-release-ad-ids.sh
    会让仍包含广告网络通用示例 ID 的发布失败,因为这些 ID 会展示“测试广告”素材,在审核者看来像是故障。

The earn loop, end to end

获利循环的端到端流程

dart
// 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)

  
  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
    };
  }
}
The control that calls it is bound to availability, so it is never a dead end (rule 5):
dart
// 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(),
And the interstitial call site counts nothing (rule 6):
dart
Future<void> onTaskCompleted(WidgetRef ref) async {
  if (ref.read(isEntitledProvider).valueOrNull ?? false) return;   // rule 9
  await ref.read(adsServiceProvider).maybeShowInterstitial();      // owns both cap halves
}
dart
// 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)

  
  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):
dart
// 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):
dart
Future<void> onTaskCompleted(WidgetRef ref) async {
  if (ref.read(isEntitledProvider).valueOrNull ?? false) return;   // rule 9
  await ref.read(adsServiceProvider).maybeShowInterstitial();      // owns both cap halves
}

Launch order

启动顺序

dart
// 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();
}
Cover it with a test asserting the fake ads service records
initCount == 0
for an entitled user — that single assertion is what keeps rule 10 true through future refactors.
dart
// 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();
}
应使用一项测试覆盖此流程,断言 fake ads service 对 entitlement 用户记录
initCount == 0
——正是这一项断言能够确保规则 10 在未来的重构中始终成立。

Anti-patterns

反模式

  • Auto-playing a "rewarded" ad, or granting on
    Dismissed
    /
    NoFill
    — it is opt-in, and the grant is for a completed view only.
  • A
    bool
    rewarded result
    — "no fill" and "user closed it" need different UI; use the sealed type.
  • An uncapped earn loop, or a cap enforced in the ad console instead of the app — the console cap produces no-fill, and no-fill hides the control (rule 5), so the user loses the path with no visible reason.
  • An always-visible earn button on a new account — permanent "no ads available"; Guideline 2.1.
  • A grant that completes the user's task — reveal a step, never the destination.
  • Interstitials mid-task, on first launch, during onboarding, or a caller that counts completions or reads a clock — the break is the only trigger and the cap lives in the service.
  • A banner on the working surface — the one placement that is never worth it.
  • Widgets reading the raw entitlement stream — everything reads the one derived gate.
  • No-op-ing ads for an entitled user without moving the restore before
    init()
    — they still got the SDK, the consent flow, and the tracking prompt.
  • Blocking ads for everyone when a restore call fails — failure means not entitled; cache the last known state so an offline owner stays correct.
  • Dismissing the paywall before the entitlement is durably written — persist, then let the gate re-emit.
  • Skipping the launch restore — reinstalls and device swaps become support tickets.
  • Paid-upfront plus ads, or a subscription for a one-time unlock.
  • Shipping the network's sample ad ids — "test ad" creatives earn nothing and look broken on review.
  • 自动播放“激励广告”,或在
    Dismissed
    /
    NoFill
    时授予奖励——它应由用户主动选择,而且奖励只针对完整看完的展示。
  • 使用
    bool
    表示激励广告结果
    ——“无填充”和“用户关闭”需要不同 UI;应使用 sealed type。
  • 不设上限的获利循环,或在广告控制台而非应用中实施上限——控制台上限会造成无填充,而无填充会隐藏控件(规则 5),导致用户在没有可见原因的情况下失去该路径。
  • 在新账户上始终显示获利按钮——永久显示“暂无广告”;Guideline 2.1。
  • 直接完成用户任务的奖励——只能揭示一个步骤,绝不能直接给出最终结果。
  • 在任务中途、首次启动、引导流程中展示插屏广告,或由调用方统计完成次数、读取时钟——只有断点是合法触发点,上限必须位于服务层。
  • 在工作界面上放置横幅广告——这是唯一完全不值得采用的广告位。
  • Widget 读取原始 entitlement 流——所有内容都应读取唯一的派生门控。
  • 仅为 entitlement 用户将广告设为空操作,却没有把恢复操作移到
    init()
    之前
    ——他们仍会启动 SDK、进入同意流程并看到跟踪提示。
  • 恢复调用失败时对所有用户屏蔽广告——失败意味着未获 entitlement;应缓存最近状态,让离线购买者仍保持正确。
  • 在 entitlement 完成持久化之前关闭付费墙——先持久化,再让门控重新发射。
  • 跳过启动时恢复——重装和设备更换会变成客服工单。
  • 同时采用预付费与广告,或为一次性解锁采用订阅。
  • 发布广告网络的示例广告 ID——“测试广告”素材没有收益,在审核时看起来也像故障。

Definition of done

完成定义

  • Ads and billing are reached only through injected interfaces; no SDK type appears in feature code.
  • Rewarded is opt-in, grants exactly one benefit, and grants only on
    Rewarded
    ; the outcome type is sealed and switched exhaustively.
  • The earn loop is capped per calendar day against the injected
    Clock
    ; the cap lives in the app.
  • The earn control is preloaded and hidden unless an ad is loaded; the task stays completable without watching anything.
  • Interstitials fire only at a break;
    maybeShowInterstitial()
    owns the count and elapsed caps and reports whether it showed; no caller counts.
  • No banner on any primary work surface.
  • One non-consumable + a visible Restore; entitlement silently re-checked at every launch.
  • On a first release, the product ships in the same submission as the app version, with its App Review screenshot, tax category, availability, and one localization per app locale.
  • isEntitled
    is the single gate; no feature branches on the raw entitlement stream.
  • Restore runs before
    AdsService.init()
    ; a test asserts the fake's
    initCount == 0
    for an entitled user; a failed restore falls back to not entitled with a local cache for offline.
  • The purchase commits through the single write path before any UI reacts.
  • Release ad ids verified by
    scripts/check-release-ad-ids.sh
    ; no sample ids in a release build.
  • Tests drive: watch → granted, cap reached → refused, no-fill → control hidden, purchase → ads gone and caps lifted, against fakes.
  • 广告和计费仅通过注入接口访问;功能代码中不出现 SDK 类型。
  • Rewarded 由用户主动选择,只授予一项利益,并且仅在
    Rewarded
    状态下授予;结果类型为 sealed type,并通过穷尽式
    switch
    处理。
  • 获利循环基于注入的
    Clock
    按日历日设上限;上限位于应用中。
  • 获利控件经过预加载,仅在广告已加载时显示;无论是否观看广告,任务都必须可以完成。
  • 插屏广告仅在断点触发;
    maybeShowInterstitial()
    同时负责次数和时间间隔上限,并报告是否实际展示;调用方不执行计数。
  • 任何主要工作界面上都没有横幅广告。
  • 存在一个非消耗型解锁和可见的 Restore;每次启动都会静默重新检查 entitlement。
  • 首次发布时,产品与应用版本在同一次提交中发布,并提供 App Review 截图、税务类别、可用性,以及每个 app locale 一套本地化。
  • isEntitled
    是唯一门控;没有任何功能基于原始 entitlement 流进行分支。
  • Restore 在
    AdsService.init()
    之前运行;测试断言 entitlement 用户的 fake
    initCount == 0
    ;恢复失败时回退为未获 entitlement,并使用本地缓存支持离线场景。
  • 购买在任何 UI 响应前都通过单一写入路径提交。
  • 通过
    scripts/check-release-ad-ids.sh
    验证发布广告 ID;发布构建中不包含示例 ID。
  • 测试覆盖:观看 → 授予、达到上限 → 拒绝、无填充 → 隐藏控件、购买 → 广告消失且上限解除,并使用 fake 执行。

Related skills

相关 skill

  • See
    service-boundary-and-native
    for declaring
    AdsService
    /
    BillingService
    , the per-flavor live implementation, and the fakes these tests use.
  • See
    app-startup-and-bootstrap
    for where the restore-then-init sequence belongs in
    main()
    .
  • See
    state-management-riverpod
    for the derived-gate provider and
    persistence-drift
    for the single write path the purchase commits through.
  • See
    error-handling-typed-results
    for the
    Result
    /
    Failure
    arms returned above.
  • See
    release-and-store-shipping
    for the privacy declaration the ad SDK drives and the reviewer note that explains empty ad inventory during review.
  • 参阅
    service-boundary-and-native
    ,了解如何声明
    AdsService
    /
    BillingService
    、按 flavor 提供线上实现,以及这些测试所使用的 fake。
  • 参阅
    app-startup-and-bootstrap
    ,了解 restore-then-init 顺序在
    main()
    中应放置的位置。
  • 参阅
    state-management-riverpod
    ,了解派生门控 provider;参阅
    persistence-drift
    ,了解购买所提交的单一写入路径。
  • 参阅
    error-handling-typed-results
    ,了解上述
    Result
    /
    Failure
    分支。
  • 参阅
    release-and-store-shipping
    ,了解由广告 SDK 驱动的隐私声明,以及向审核员解释审核期间广告库存为空的说明。

References

参考资料