mcpField
A
ads-and-iap-monetization

ads-and-iap-monetization

github.com/zakariaf开发63142 次安装5 周前更新
代码仓库

关于 ads-and-iap-monetization

面向移动应用开发者的广告与应用内购买策略技能,规范激励广告、插屏和权益门控。重点是自愿观看、购买权益优先恢复,以及明确的频次限制。

主要功能

  1. 01规范激励广告奖励与结果处理
  2. 02按日限制奖励获取次数
  3. 03限定插屏展示时机和频率
  4. 04统一权益门控与购买恢复
  5. 05规定广告与权益初始化顺序

适用场景

  1. 01设计自愿观看广告换取单项奖励
  2. 02为插屏设置完成节点和频率限制
  3. 03处理一次性解锁购买与恢复
  4. 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.dart
references/
ad-unit-setup.md
entitlement-and-restore.md
scripts/
check-release-ad-ids.sh

详细介绍

广告和 IAP 变现

广告与计费接口之上的策略层:激励广告观看会授予什么、插屏广告何时可以展示以及展示频率、权益如何同时限制这两者,以及它们在启动时按什么顺序初始化。这里假设接口已经存在——声明接口、按 flavor 覆盖接口,以及避免在功能代码中使用 SDK 类型,都属于 service-boundary-and-native。

这里体现的模式是免费、优先自愿参与:用户自愿付出注意力以换取价值,而一笔非消耗型购买即可免除这种交换。这并非唯一可行的模式,但以下规则能让任何依靠广告盈利的应用保持诚信、经得起审查,并避免被归入“感觉像广告软件”的类别。

不容妥协的规则

  1. 所有广告和商店调用都必须通过注入的接口,绝不能在功能代码中使用 SDK 类型。 AdsService、BillingService;具体 SDK 在组合根中接入一次,并在测试中用伪对象替代。机制由 service-boundary-and-native 负责——不要在这里重复推导。

  2. 激励广告观看必须由用户主动选择,并且只授予一项具体收益。 它只能由用户明确点击“观看以获取奖励”后触发,绝不能自动播放;并且只返回一个单位——一个点数、一次额外尝试、部分揭示——绝不能直接给出用户正在努力争取的全部结果。原因:自动播放的“激励”广告不过是乔装打扮的插屏广告,而直接替用户完成任务的奖励会毁掉用户为之而来的体验。

  3. 只有真正获得奖励时才发放奖励。 将结果建模为密封类型(Rewarded / Dismissed / NoFill),并穷尽处理所有分支;Dismissed(提前关闭)和 NoFill 均不发放奖励,也不改变状态。原因:用 bool 返回值会把“没有可展示的广告”混同为“用户拒绝了”,而这两种情况需要不同的界面处理。

  4. 在策略层通过注入的 Clock 限制每日获奖次数。 不设上限的观看获奖循环会让任何用户绕过收益原本要限制的内容,而次数上限应放在它所保护的规则旁边——而非广告网络控制台中(关于控制台频次上限为何会主动损害自愿选择的广告形式,请参阅 references/ad-unit-setup.md)。按从 clock.now() 得出的日历日计算上限,绝不要使用 DateTime.now()。

  5. 绝不要发布可能让用户走进死胡同的获奖控件。 新广告账户的广告填充率几乎为零,因此始终显示“观看以获取奖励”按钮,每次都只能回答*“没有可用广告”*——这会造成糟糕的用户体验,也可能因控件看起来无法使用而遭到 Guideline 2.1 拒绝。预加载广告,将可用状态作为流公开,并且仅在广告确实已加载时(或正在兑换奖励时)显示控件。用户所需的任何功能都必须无需观看广告即可使用。

  6. 插屏广告只能在自然停顿处展示,并受服务负责的双重上限限制。 唯一符合条件的时机是用户已有预期的完成或转场节点——绝不能在任务进行中、引导流程中或首次会话中展示。双重上限的两个条件都必须满足:每完成 N 次最多展示一次,并且距上次展示至少间隔 M 秒。将计数逻辑和时钟放在 maybeShowInterstitial() 内,并返回广告是否已展示,这样调用方就不会负责计数。原因:在三个调用处分别执行上限控制,就会产生三个彼此不一致的上限。

  7. 主要操作界面上不得放置横幅广告。 在用户实际操作的屏幕上放置横幅广告会导致布局重排、增加误触,并且收益低于任何其他广告形式。菜单或主页是唯一说得过去的放置位置,而且也不是必需的。

  8. 一次性非消耗型解锁,并提供恢复功能;每次启动时重新检查。 ⚠️ 在应用的 首次发布中,解锁项必须与应用版本在同一次 App Review 提交中提交——Apple 要求每种类型的首次购买都必须随版本提交;仅发布版本会导致整个提交在未经审核的情况下被关闭,依据的是 Guideline 2.1(b)(release-and-store-shipping)。仅售卖一次;提供清晰可见的恢复功能;在启动时静默重新检查权益,以便重新安装或更换设备后可以重新授予权益,无需提交支持工单。一次性功能不要做成订阅;如果已经提供了可选的奖励途径,也不要把消耗型虚拟货币卖给用户换取现金。

  9. 唯一的门控条件是派生出的 isEntitled 提供程序。 所有内容——每个广告调用点、每个上限、每个付费墙入口——都读取它。任何地方都不能根据原始权益流进行分支。原因:对同一事实进行第二次读取,就等于设置了第二道门,而它最终会与第一道门出现偏差。

  10. 必须先恢复权益,再初始化广告 SDK。 仅让广告调用空操作还不够:如果已经运行了 AdsService.init(),付费用户就已经启动了 SDK、完成了同意状态解析、看到了跟踪提示,其设备也已被识别——而这些正是他们付费想要避开的。先等待权益恢复,只有在确认用户不具备权益时才初始化广告。商店或网络故障时,按不具备权益处理并初始化广告——不能因为一次调用失败就对所有免费用户都不展示广告——因此要在本地缓存上次已知的权益状态,以确保离线启动时所有者的状态正确。请参阅 references/entitlement-and-restore.md。

  11. 购买必须先通过唯一写入路径持久化,然后 UI 才响应。 先通过仓库提交一笔交易,然后 UI 因 isEntitled 再次发出状态而更新——绝不能乐观地关闭界面。原因:流程中途崩溃绝不能留下“已付费但未获授权”的异常状态。写入路径的实现机制由 persistence-drift 负责。

  12. 绝不能既采用预付费,又支持广告。 二选一。应用收费后还通过买家的注意力变现,这种做法会招致用户在评价中批评,也会让付费墙失去合理性。

  13. 广告单元标识符是配置,不是秘密——但绝不能将示例 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),用户便会失去入口,且看不到任何原因。
  • 在新账户上始终显示赚取按钮——这会永久显示“没有可用广告”;违反 Guideline 2.1。
  • 发放足以完成用户任务的奖励——只揭示一个步骤,绝不要直接给出最终结果。
  • 在任务中途、首次启动时或引导期间插播插页式广告,或由调用方统计完成次数或读取时钟——只有任务间歇才会触发广告,且上限由服务管理。
  • 在工作界面上放置横幅广告——这是唯一一种绝不值得放置广告的位置。
  • 让小组件读取原始权益数据流——所有内容都应读取同一个派生门控。
  • 对已拥有权益的用户将广告设为空操作,却未将恢复操作移到 init() 之前——他们仍会初始化 SDK、进入同意流程并看到跟踪提示。
  • 恢复调用失败时对所有人屏蔽广告——失败表示未拥有权益;缓存上次已知状态,确保离线时权益持有者的状态仍正确。
  • 在权益持久化写入前关闭付费墙——先持久化,再让门控重新发出状态。
  • 跳过启动时的恢复操作——重新安装和更换设备会变成支持工单。
  • 预先付费的应用同时放广告,或为一次性解锁设置订阅。
  • 发布广告网络的示例广告 ID——“测试广告”素材无法带来收益,在审核时看起来也像是出了问题。

完成标准

  • 只能通过注入的接口访问广告和计费;功能代码中不出现任何 SDK 类型。
  • 激励广告必须由用户主动选择,只发放一项奖励,且仅在 Rewarded 时发放;结果类型为密封类型,并通过穷举分支处理。
  • 赚取循环根据注入的 Clock 按日历日设定上限;上限由应用管理。
  • 赚取控件预加载,且只有广告已加载时才显示;无需观看任何内容也能完成任务。
  • 插页式广告仅在任务间歇触发;maybeShowInterstitial() 负责管理次数和经过时间的上限,并报告广告是否已展示;调用方不统计次数。
  • 任何主要工作界面上都不放置横幅广告。
  • 只提供一个非消耗型项目,并显示“恢复购买”入口;每次启动时都在后台重新检查权益。
  • 首次发布时,产品与应用版本在同一次提交中一并发布,并提供 App Review 截图、税务类别、供应范围,以及每种应用本地化语言对应的一种本地化内容。
  • isEntitled 是唯一的门控;任何功能都不直接根据原始权益数据流进行分支处理。
  • 恢复操作在 AdsService.init() 之前运行;有测试断言已拥有权益的用户对应的假实现 initCount == 0;恢复失败时回退为未拥有权益,并使用本地缓存支持离线状态。
  • 购买操作在任何 UI 响应之前,都通过唯一的写入路径提交。
  • 已通过 scripts/check-release-ad-ids.sh 验证发布广告 ID;发布版本中不含示例 ID。
  • 测试使用假实现覆盖:观看 → 已发放奖励、达到上限 → 拒绝、无广告填充 → 隐藏控件、购买 → 移除广告并取消上限。

相关技能

  • 有关声明 AdsService/BillingService、按构建变体提供线上实现,以及这些测试所用的假实现,请参阅 service-boundary-and-native。
  • 有关 main() 中恢复后再初始化的流程应放置的位置,请参阅 app-startup-and-bootstrap。
  • 有关派生门控 Provider,请参阅 state-management-riverpod;有关购买提交所经的唯一写入路径,请参阅 persistence-drift。
  • 有关上述返回的 Result/Failure 分支,请参阅 error-handling-typed-results。
  • 有关广告 SDK 要求的隐私声明,以及解释审核期间广告库存为空的审核说明,请参阅 release-and-store-shipping。

参考资料

评论

支持 Markdown