关于 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 变现
广告与计费接口之上的策略层:激励广告观看会授予什么、插屏广告何时可以展示以及展示频率、权益如何同时限制这两者,以及它们在启动时按什么顺序初始化。这里假设接口已经存在——声明接口、按 flavor 覆盖接口,以及避免在功能代码中使用 SDK 类型,都属于 service-boundary-and-native。
这里体现的模式是免费、优先自愿参与:用户自愿付出注意力以换取价值,而一笔非消耗型购买即可免除这种交换。这并非唯一可行的模式,但以下规则能让任何依靠广告盈利的应用保持诚信、经得起审查,并避免被归入“感觉像广告软件”的类别。
不容妥协的规则
-
所有广告和商店调用都必须通过注入的接口,绝不能在功能代码中使用 SDK 类型。
AdsService、BillingService;具体 SDK 在组合根中接入一次,并在测试中用伪对象替代。机制由service-boundary-and-native负责——不要在这里重复推导。 -
激励广告观看必须由用户主动选择,并且只授予一项具体收益。 它只能由用户明确点击“观看以获取奖励”后触发,绝不能自动播放;并且只返回一个单位——一个点数、一次额外尝试、部分揭示——绝不能直接给出用户正在努力争取的全部结果。原因:自动播放的“激励”广告不过是乔装打扮的插屏广告,而直接替用户完成任务的奖励会毁掉用户为之而来的体验。
-
只有真正获得奖励时才发放奖励。 将结果建模为密封类型(
Rewarded/Dismissed/NoFill),并穷尽处理所有分支;Dismissed(提前关闭)和NoFill均不发放奖励,也不改变状态。原因:用bool返回值会把“没有可展示的广告”混同为“用户拒绝了”,而这两种情况需要不同的界面处理。 -
在策略层通过注入的
Clock限制每日获奖次数。 不设上限的观看获奖循环会让任何用户绕过收益原本要限制的内容,而次数上限应放在它所保护的规则旁边——而非广告网络控制台中(关于控制台频次上限为何会主动损害自愿选择的广告形式,请参阅references/ad-unit-setup.md)。按从clock.now()得出的日历日计算上限,绝不要使用DateTime.now()。 -
绝不要发布可能让用户走进死胡同的获奖控件。 新广告账户的广告填充率几乎为零,因此始终显示“观看以获取奖励”按钮,每次都只能回答*“没有可用广告”*——这会造成糟糕的用户体验,也可能因控件看起来无法使用而遭到 Guideline 2.1 拒绝。预加载广告,将可用状态作为流公开,并且仅在广告确实已加载时(或正在兑换奖励时)显示控件。用户所需的任何功能都必须无需观看广告即可使用。
-
插屏广告只能在自然停顿处展示,并受服务负责的双重上限限制。 唯一符合条件的时机是用户已有预期的完成或转场节点——绝不能在任务进行中、引导流程中或首次会话中展示。双重上限的两个条件都必须满足:每完成 N 次最多展示一次,并且距上次展示至少间隔 M 秒。将计数逻辑和时钟放在
maybeShowInterstitial()内,并返回广告是否已展示,这样调用方就不会负责计数。原因:在三个调用处分别执行上限控制,就会产生三个彼此不一致的上限。 -
主要操作界面上不得放置横幅广告。 在用户实际操作的屏幕上放置横幅广告会导致布局重排、增加误触,并且收益低于任何其他广告形式。菜单或主页是唯一说得过去的放置位置,而且也不是必需的。
-
一次性非消耗型解锁,并提供恢复功能;每次启动时重新检查。 ⚠️ 在应用的 首次发布中,解锁项必须与应用版本在同一次 App Review 提交中提交——Apple 要求每种类型的首次购买都必须随版本提交;仅发布版本会导致整个提交在未经审核的情况下被关闭,依据的是 Guideline 2.1(b)(
release-and-store-shipping)。仅售卖一次;提供清晰可见的恢复功能;在启动时静默重新检查权益,以便重新安装或更换设备后可以重新授予权益,无需提交支持工单。一次性功能不要做成订阅;如果已经提供了可选的奖励途径,也不要把消耗型虚拟货币卖给用户换取现金。 -
唯一的门控条件是派生出的
isEntitled提供程序。 所有内容——每个广告调用点、每个上限、每个付费墙入口——都读取它。任何地方都不能根据原始权益流进行分支。原因:对同一事实进行第二次读取,就等于设置了第二道门,而它最终会与第一道门出现偏差。 -
必须先恢复权益,再初始化广告 SDK。 仅让广告调用空操作还不够:如果已经运行了
AdsService.init(),付费用户就已经启动了 SDK、完成了同意状态解析、看到了跟踪提示,其设备也已被识别——而这些正是他们付费想要避开的。先等待权益恢复,只有在确认用户不具备权益时才初始化广告。商店或网络故障时,按不具备权益处理并初始化广告——不能因为一次调用失败就对所有免费用户都不展示广告——因此要在本地缓存上次已知的权益状态,以确保离线启动时所有者的状态正确。请参阅references/entitlement-and-restore.md。 -
购买必须先通过唯一写入路径持久化,然后 UI 才响应。 先通过仓库提交一笔交易,然后 UI 因
isEntitled再次发出状态而更新——绝不能乐观地关闭界面。原因:流程中途崩溃绝不能留下“已付费但未获授权”的异常状态。写入路径的实现机制由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),用户便会失去入口,且看不到任何原因。
- 在新账户上始终显示赚取按钮——这会永久显示“没有可用广告”;违反 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。
参考资料
- 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
- 用户消息平台 / 同意:https://developers.google.com/admob/flutter/privacy
- App 跟踪透明度:https://developer.apple.com/documentation/apptrackingtransparency