如何为 Flutter 应用添加应用内购买
使用 in_app_purchase 插件为 Flutter 应用添加应用内购买。配置 Apple 和 Google 商店、验证交易、恢复购买,并安全测试。
在 iOS 或 Android 上销售数字商品或订阅的 Flutter 应用,通常必须通过 Apple 的应用内购买系统或 Google Play 结算系统收款。此类场景不允许使用 Stripe 或自定义结账流程,而且两大商店都会从每笔交易中抽取佣金。
如果你的应用已经在 Web 端通过 Stripe 收款,你可能会想在移动端构建中复用同一套结账流程。但对于数字商品,这种做法往往会导致应用被 App Review 拒绝。
Apple 的标准佣金比例为 30%。通过 App Store 小型企业计划,上一年度收益不超过 100 万美元的开发者,无论是付费应用还是应用内购买,都只需支付 15% 的佣金。Google 没有统一的费率,其服务费说明页面指出,在需要缴纳服务费的开发者中,99% 符合 15% 或更低费率的条件。对于实体商品以及在应用外使用的服务,Stripe 仍然是合适的工具。相关规则详见 Apple 的 App Store 审核指南中关于应用内购买的部分以及 Google 的 Play 付款政策。在欧盟,《数字市场法》(DMA)要求应用商店允许使用替代支付方式,Apple 在其 DMA 开发者页面中介绍了面向欧盟的相关机制。在美国店面,Apple 的审核指南现在也允许应用添加指向外部购买选项的按钮和链接。关于外部支付链接的规则因地区而异且在不断变化,因此在设计购买流程之前,请务必阅读两大商店的最新指南。
本文将完整介绍官方 in_app_purchase 插件:商品类型、商店配置、一个可运行的服务类、导致购买丢失的常见故障模式,以及测试方法。如果你是 Flutter 新手,建议先阅读 Flutter 新开发者指南。
要点速览
- 在 iOS 和 Android 应用内销售的数字商品,通常必须通过 Apple 和 Google 的结算系统。Stripe 适用于实体商品及在应用外使用的服务。
buyConsumable和buyNonConsumable返回的Future<bool>仅表示购买请求已发起。实际结果会以PurchaseDetails的形式通过purchaseStream传递。- 如果某笔购买的
pendingCompletePurchase为 true 却未调用completePurchase,Google Play 会在三天后自动退款,而 App Store 则会让该交易一直处于未完成状态。 - Apple 的审核指南要求为可恢复的购买提供恢复机制。
- 只有在后端通过 App Store Server API 或 Google Play Developer API 验证
serverVerificationData之后,才能解锁内容。
消耗型、非消耗型与订阅
Flutter 的 in_app_purchase 插件支持三种商品类型:消耗型(consumable)、非消耗型(non-consumable)和订阅(subscription)。消耗型商品使用 buyConsumable,非消耗型商品和订阅都使用 buyNonConsumable。商品类型还决定了应用需要自行记录哪些数据。
| 类型 | 购买方法 | 示例 | 应用需要跟踪的内容 | 可恢复? |
|---|---|---|---|---|
| 消耗型 | buyConsumable | 100 金币 | 余额,存储在后端 | 否 |
| 非消耗型 | buyNonConsumable | 高级版解锁 | 权益标志 | 是 |
| 订阅 | buyNonConsumable | Pro 月度会员 | 到期日期和续订状态,来自服务端验证 | 是 |
消耗型是唯一一种商店不会替你记录的商品类型。金币包被消耗后,只有你自己的后端保存着余额记录。非消耗型商品的权益随时可以通过恢复购买来重建。订阅状态会随着续订、过期和退款而不断变化,因此应从经过验证的服务端数据中读取,而不是依赖本地缓存的标志。
商店配置才是主要工作量
为 Flutter 应用添加应用内购买,大部分工作都花在 App Store Connect 和 Play Console 上,Dart 代码部分其实很短。在两个商店都配置完成之前,无论代码多么正确,queryProductDetails 都不会返回任何结果。
Apple(App Store Connect):
- 签署付费应用协议(Paid Apps agreement),并填写银行和税务信息。
- 创建每个应用内购买项目,并设置商品 ID,例如
premium_unlock。订阅需要在订阅群组中创建。 - 补全元数据,使每个商品的状态变为”准备提交”(Ready to Submit)。
Google(Play Console):
- 将构建版本上传到测试轨道,内部测试轨道即可。
- 创建商品。使用与 Apple 相同的 ID,或者在代码中做映射。
- 激活商品。Google 指出,商品必须发布后才能进行测试。
如果 queryProductDetails 将你的 ID 列在 notFoundIDs 中,原因几乎总是出在商店配置上,而不是 Dart 代码。常见原因包括:ID 不匹配、商品未激活、未签署付费应用协议,或者没有向 Play 测试轨道上传构建版本。
Flutter 应用内购买代码:监听流、购买、完成
Flutter 应用内购买流程分为四步:在启动时订阅 purchaseStream,检查 isAvailable,通过 queryProductDetails 加载商品,然后调用购买方法。所有结果都在流监听器中处理。
添加依赖:
dependencies:
in_app_purchase: ^3.3.1
in_app_purchase 包支持 Android SDK 24+、iOS 13+ 和 macOS 10.15+。StoreKit 和 Play Billing 的实现是官方认可的联合插件(endorsed federated package),会被自动引入,只有在需要使用它们的平台特定类时才需要手动添加。在 Apple 平台上,StoreKit 2 是默认实现(在支持它的设备上)。
buyConsumable 和 buyNonConsumable 并不会返回购买结果。一个常见的错误写法如下:
// Wrong: the Future resolves to a bool, not a purchase
final result = await iap.buyNonConsumable(purchaseParam: param);
if (result) unlockPremium(); // true only means the sheet opened
正确的写法是将这个 bool 值视为”请求已发起”,所有发放权益的逻辑都放在流监听器中处理:
final started = await iap.buyNonConsumable(purchaseParam: param);
if (!started) showError('Could not start purchase');
// Unlocking happens in the purchaseStream handler
以下是完整的服务类,在 main() 中、runApp 之前完成订阅。插件 README 建议在应用的 initState 中订阅,而在 main() 中订阅则更早一步,确保在插件发出任何更新之前监听器就已存在。
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
class PurchaseService {
PurchaseService._();
static final instance = PurchaseService._();
static const coins100 = 'coins_100';
static const premium = 'premium_unlock';
final InAppPurchase _iap = InAppPurchase.instance;
StreamSubscription<List<PurchaseDetails>>? _sub;
void start() {
_sub ??= _iap.purchaseStream.listen(
_onPurchases,
onDone: () => _sub?.cancel(),
onError: (Object e) {/* log */},
);
}
Future<List<ProductDetails>> loadProducts() async {
if (!await _iap.isAvailable()) return [];
final res = await _iap.queryProductDetails({coins100, premium});
if (res.notFoundIDs.isNotEmpty) {/* store setup problem */}
return res.productDetails;
}
Future<bool> buy(ProductDetails p) {
final param = PurchaseParam(productDetails: p);
return p.id == coins100
? _iap.buyConsumable(purchaseParam: param)
: _iap.buyNonConsumable(purchaseParam: param);
}
Future<void> restore() => _iap.restorePurchases();
Future<void> _onPurchases(List<PurchaseDetails> purchases) async {
for (final p in purchases) {
var finish = true;
switch (p.status) {
case PurchaseStatus.pending:
// show "waiting for approval"
break;
case PurchaseStatus.purchased:
case PurchaseStatus.restored:
final valid = await _verifyOnServer(p); // null = unreachable
if (valid == null) {
finish = false; // redelivered next launch
} else if (valid) {
await _deliver(p);
}
case PurchaseStatus.error:
// show p.error?.message
break;
case PurchaseStatus.canceled:
// no error UI
break;
}
if (finish && p.pendingCompletePurchase) {
await _iap.completePurchase(p);
}
}
}
Future<bool?> _verifyOnServer(PurchaseDetails p) async {
// POST p.verificationData.serverVerificationData,
// p.productID and platform to your backend
return true;
}
Future<void> _deliver(PurchaseDetails p) async {/* credit coins / set flag */}
}
void main() {
WidgetsFlutterBinding.ensureInitialized();
PurchaseService.instance.start();
runApp(const MyApp());
}
_verifyOnServer 是后端调用的占位实现。当服务器无法访问时,它返回 null,此时该购买保持未完成状态,并会在下次启动时重新推送。但只有在 Android 三天期限内得到处理,这种做法才是安全的。
| 状态 | 应用的处理方式 | 是否调用 completePurchase? |
|---|---|---|
pending | 显示等待状态(例如”购买前询问”或延迟到账的支付方式) | 否,等待最终状态 |
purchased | 在服务端验证后发放 | 是,若 pendingCompletePurchase 为 true |
restored | 验证后重新授予权益 | 是,若 pendingCompletePurchase 为 true |
error | 显示 error.message | 是,若 pendingCompletePurchase 为 true |
canceled | 静默返回付费墙 | 是,若 pendingCompletePurchase 为 true |
应用内购买的常见问题
有四种常见错误会导致购买丢失或应用被拒:缺少恢复购买按钮、订阅流的时机太晚、从未调用 completePurchase,以及信任客户端结果。
缺少恢复购买按钮。 Apple 的审核指南 3.1.1 要求应用为所有可恢复的应用内购买提供恢复途径。恢复的购买会以 PurchaseStatus.restored 状态通过 purchaseStream 传递,因此该按钮只需调用 restorePurchases(),现有的处理逻辑会负责授予权限。请在付费墙和设置页面中都放置该按钮:
TextButton(
onPressed: PurchaseService.instance.restore,
child: const Text('Restore Purchases'),
)
如果付费墙是通过推送通知或营销链接打开的,可以参考 Flutter 中的深度链接进行路由,确保用户随时只需一次点击就能找到恢复购买按钮。
监听时机太晚。 在应用关闭期间完成的购买(例如已批准的”购买前询问”请求或延迟到账的付款)会在应用下次启动时通过流推送。如果监听器挂载在用户从未访问的页面上,这次更新就不会被处理。用户无法获得已付费的内容,而且在 Android 上,从未完成确认的购买会在三天后被退款。
从未完成购买。 如果某笔购买的 pendingCompletePurchase 为 true 却从未调用 completePurchase,Google Play 会在三天后退款,而 App Store 会让该交易一直处于未完成状态。
信任客户端结果。 不要根据应用收到的状态直接解锁内容。应将 verificationData.serverVerificationData 发送到后端进行验证:在 Apple 平台上使用 App Store Server API,在 Android 上使用 Google Play Developer API。在 StoreKit 2 下,serverVerificationData 是一个经过签名的 JWS 交易。服务器应验证其签名、通过 App Store Server API 查询该交易,或两者兼做。验证请求只是一个普通的后端调用。如果你的后端使用 GraphQL,可以参考 在 Flutter 中使用 GraphQL API 了解如何对接。
付费墙设计以及 RevenueCat 等托管订阅 SDK 属于独立的选型决策,它们构建在上述流程之上。
如何在不花真钱的情况下测试购买?
Apple 和 Google 都为应用内购买提供了测试环境。Apple 提供沙盒账户和 Xcode StoreKit 配置文件,Google 则在 Play 测试轨道上提供许可测试人员机制。
- Apple 沙盒测试员: 在 App Store Connect 中创建沙盒账户,并在设备上登录。沙盒购买会使用你在 App Store Connect 中配置的真实商品,但不会产生任何扣费。
- StoreKit 配置文件: 借助 Xcode 中的 StoreKit 配置文件,你可以在本地定义商品,并在 App Store Connect 完全配置好之前,就在 iOS 模拟器中测试购买。
- Play 许可测试人员: 在 Play Console 中打开”设置 > 许可测试”,添加 Google 账号。许可测试人员进行的测试购买不会向其账户扣费。Google 的结算测试指南提醒:测试轨道上的测试人员如果不是许可测试人员,则会被真实扣费。
提交审核之前,请在两个平台上都测试全新安装以及恢复购买的场景。
准备发布
要实现正确的 Flutter 应用内购买流程:配置好两个商店,在其他任何逻辑运行之前订阅 purchaseStream,在服务端验证每一笔购买,并对每一笔设置了 pendingCompletePurchase 的购买调用完成确认。下一步,在两个控制台中各创建一个商品,分别使用沙盒账户和许可测试人员进行测试,并确认该商品出现在 productDetails 中,而不是 notFoundIDs 中。
常见问题
如何使用 in_app_purchase 在 Android 上升级或降级订阅?
调用 buyNonConsumable,并传入包含 ChangeSubscriptionParam 的 GooglePlayPurchaseParam。向其传入用户原有的 GooglePlayPurchaseDetails,还可以按需指定 ReplacementMode,例如 ReplacementMode.withTimeProration。这些类来自 in_app_purchase_android,因此需要时请导入该包。在 iOS 上可以跳过这一步:同一订阅群组中的订阅本身就会相互替换,App Store 会自动处理切换。
in_app_purchase 插件支持 Flutter Web 吗?
不支持。该插件需要依托应用商店,目前仅支持 App Store(iOS 和 macOS)和 Google Play(Android)。Flutter Web 构建背后没有应用商店,因此 Web 端销售需要使用 Stripe 等 Web 支付服务商。关于 Stripe 的平台限制只适用于在 iOS 和 Android 应用内销售的数字商品,并不适用于在 Web 上进行的购买。
in_app_purchase 与 RevenueCat 的 purchases_flutter 有什么区别?
in_app_purchase 只负责与应用商店的连接,服务端验证、权益存储和订阅状态跟踪都需要由你自己的后端完成。RevenueCat 的 purchases_flutter 将 StoreKit 和 Google Play 结算系统与 RevenueCat 的托管后端封装在一起,额外提供服务端收据验证、订阅状态跟踪和 Webhook。两个包最终都会通过 Apple 和 Google 的结算系统,因此无论选择哪一个,应用商店收取的佣金都相同。
提高订阅价格后,现有订阅用户会怎样?
这取决于商店、地区以及涨价幅度。有些涨价需要订阅用户同意,如果用户不同意,订阅将停止续订。两大商店也允许部分涨价只需提前通知即可。在 iOS 上,StoreKit 默认会在应用内弹出价格确认界面,in_app_purchase 的 README 说明了如何将其推迟到更合适的时机显示。在 Android 上,Google Play 会通过电子邮件和通知告知订阅用户价格变更,因此无需编写 Dart 代码来触发。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k