目前在dart 的 pub.dev/上,比较流行的支付插件有
其中 in_app_purchase 是 Flutter 官方团队出的,但由于推出时间比较晚,所以在推出前,flutter_inapp_purchase 在社区也比较流行,但本着有官方用官方的原则,我在项目中还是使用了 in_app_purchase 库,本文会讲解这个库的用法和个人的一些封装,希望对大家能有帮助
发起支付
首先来理解一下谷歌支付中商品的概念
商品:即用户支付要购买的东西,在谷歌商店后台,每个商品用对应的 sku 标识
在一般的支付开发流程中,我们往往会通过后端获取应用内可以购买的商品信息,商品模型除了必要的 sku,还有我们业务所需的信息(比如商品对应的权益等),当用户对某商品或某些商品发起购买时,我们需要通过对应的 sku 查找到谷歌的商品模型
const Set<String> _kIds = <String>{'product1', 'product2'};
final ProductDetailsResponse response =
await InAppPurchase.instance.queryProductDetails(_kIds);
if (response.notFoundIDs.isNotEmpty) {
// Handle the error.
}
List<ProductDetails> products = response.productDetails;
以上这段代码来自官方 README
我们使用InAppPurchase.instance.queryProductDetails
来根据商品 sku 获取获取商品,传参类型为集合,支持多个商品查找。
查找到商品之后,我们就可以发起支付
final ProductDetails productDetails = ... // 我们之前查找到的商品模型
final PurchaseParam purchaseParam = PurchaseParam(productDetails: productDetails);
if (_isConsumable(productDetails)) {
InAppPurchase.instance.buyConsumable(purchaseParam: purchaseParam);
} else {
InAppPurchase.instance.buyNonConsumable(purchaseParam: purchaseParam);
}
注意到这里有两个方法
-
buyConsumable
-
buyNonConsumable
虽然传参一样,但第一个是用于购买消耗型商品,第二个用于购买非消耗型商品
消耗型的概念,通俗可以这么理解:消耗型商品可以购买多次,具体来说只要之前购买过的商品被消耗掉了,就可以再次购买,而非消耗型商品就是只能购买一次,官方列举了一个例子:应用解锁 【Non consumable items can only be bought once. For example, a purchase that unlocks a special content in your app.】
我们项目中的商品是类似于充值点数的概念,很明显是消耗型商品,所以用的应该是buyConsumable方法。
下面再来说说上述方法的传参:
PurchaseParam
class PurchaseParam {
/// Creates a new purchase parameter object with the given data.
PurchaseParam({
required this.productDetails,
this.applicationUserName,
});
/// The product to create payment for.
///
/// It has to match one of the valid [ProductDetails] objects that you get from [ProductDetailsResponse] after calling [InAppPurchasePlatform.queryProductDetails].
final ProductDetails productDetails;
/// An opaque id for the user's account that's unique to your app. (Optional)
///
/// Used to help the store detect irregular activity.
/// Do not pass in a clear text, your developer ID, the user’s Apple ID, or the
/// user's Google ID for this field.
/// For example, you can use a one-way hash of the user’s account name on your server.
final String? applicationUserName;
}
- productDetails 上述已有提到
- applicationUserName 可以理解成支付模型给我们提供的业务字段,可以往这里传入诸如用户 id 或者后端订单 id 用于关联信息
讲到这里, 先说下在业务中一般的支付流程
1. 根据商品 sku 查找支付商品模型
2. 请求后端创建订单信息
3. 创建支付模型,将商品信息与订单信息关联起来,然后调用支付方法
但实际在项目中,我并没有用**buyConsumable**
或者 **buyNonConsumable**
因为我们的业务需要同时把商品模型与用户 id 以及订单 id关联起来,如果需要通过buyConsumable
或者 buyNonConsumable
实现关联,能想到的方案可以是传一个 json 到 applicationUserName
字段解决问题。然而,我们的支付功能之前是由原生开发实现,如果有用过 BillingClient 做过原生谷歌支付开发的同学,对以下代码应该不陌生
// An activity reference from which the billing flow will be launched.
val activity : Activity = …;
val productDetailsParamsList = listOf(
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(productDetails)
.setObfuscatedProfileId(profileId)
.setObfuscatedAccountId(accountId)
.setOfferToken(selectedOfferToken)
.build()
)
val billingFlowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(productDetailsParamsList)
.build()
// Launch the billing flow
val billingResult = billingClient.launchBillingFlow(activity, billingFlowParams)
我们之前的原生代码,会使用setObfuscatedProfileId
写入订单 id ,setObfuscatedAccountId
写入用户 id,这两个字段在使用 in_app_purchase插件有一些不一样的地方。前面没提到的是,in_app_purchase
这个库在 android 平台上的实现其实就是 BillingClient,具体可以看以下代码
@override
Future buyNonConsumable({required PurchaseParam purchaseParam}) async {
ChangeSubscriptionParam? changeSubscriptionParam;
// ...省略部分代码
final BillingResultWrapper billingResultWrapper =
await billingClientManager.runWithClient((BillingClient client) =>
client.launchBillingFlow(
product: purchaseParam.productDetails.id,
offerToken: offerToken,
accountId: purchaseParam.applicationUserName,
oldProduct: changeSubscriptionParam?.oldPurchaseDetails.productID,
purchaseToken: changeSubscriptionParam?.oldPurchaseDetails.verificationData.serverVerificationData,
prorationMode: changeSubscriptionParam?.prorationMode,
replacementMode: changeSubscriptionParam?.replacementMode),
);
return billingResultWrapper.responseCode == BillingResponse.ok;
}
Future launchBillingFlow(
{
required String product,
String? offerToken,
String? accountId,
String? obfuscatedProfileId,
String? oldProduct,
String? purchaseToken,
ProrationMode? prorationMode,
ReplacementMode? replacementMode,
}) async {
assert((oldProduct == null) == (purchaseToken == null), 'oldProduct and purchaseToken must both be set, or both be null.');
return resultWrapperFromPlatform(
await _hostApi.launchBillingFlow(PlatformBillingFlowParams(
product: product,
prorationMode: const ProrationModeConverter().toJson(prorationMode ?? ProrationMode.unknownSubscriptionUpgradeDowngradePolicy),
replacementMode: const ReplacementModeConverter().toJson(replacementMode ?? ReplacementMode.unknownReplacementMode),
offerToken: offerToken,
accountId: accountId,
obfuscatedProfileId: obfuscatedProfileId,
oldProduct: oldProduct,
purchaseToken: purchaseToken,
)));
}
看到这里会发现,launchBillingFlow
方法其实有一个 obfuscatedProfileId
可选参数,但是 buyNonConsumable
在调用时并没有传,上文提到的 applicationUserName
则会传给launchBillingFlow
方法的accountId
Future launchBillingFlow(
{
required String product,
String? offerToken,
String? accountId,
String? obfuscatedProfileId,
String? oldProduct,
String? purchaseToken,
ProrationMode? prorationMode,
ReplacementMode? replacementMode}) async {
assert((oldProduct == null) == (purchaseToken == null), 'oldProduct and purchaseToken must both be set, or both be null.');
return resultWrapperFromPlatform(
await _hostApi.launchBillingFlow(PlatformBillingFlowParams(
product: product,
prorationMode: const ProrationModeConverter().toJson(prorationMode ?? ProrationMode.unknownSubscriptionUpgradeDowngradePolicy),
replacementMode: const ReplacementModeConverter().toJson(replacementMode ?? ReplacementMode.unknownReplacementMode),
offerToken: offerToken,
accountId: accountId,
obfuscatedProfileId: obfuscatedProfileId,
oldProduct: oldProduct,
purchaseToken: purchaseToken,
)));
}
再继续查看源码可以发现,PurchaseParam.applicationUserName
最终会传递到BillingClient中BillingFlowParams.ProductDetailsParams的setObfuscatedAccountId方法。
in_app_pruchase 与原生代码的交互这部分使用了 pigeon,有兴趣的同学可以有空了解下,这是谷歌官方推出的一个方便Flutter与原生通信时的基于
dart
代码自动生成三端代码的插件,可以极大的提高原生混合开发时通信的开发效率
可以看到,buyNonConsumable
方法还不能实现跟原生 BillingClient 一样的传入obfuscatedProfileId
这给我们项目迁移到 flutter 支付造成了一些困难。
回到buyNonConsumable
方法这里
@override
Future buyNonConsumable({required PurchaseParam purchaseParam}) async {
ChangeSubscriptionParam? changeSubscriptionParam;
// ...省略部分代码
final BillingResultWrapper billingResultWrapper =
await billingClientManager.runWithClient((BillingClient client) =>
client.launchBillingFlow(
product: purchaseParam.productDetails.id,
offerToken: offerToken,
accountId: purchaseParam.applicationUserName,
oldProduct: changeSubscriptionParam?.oldPurchaseDetails.productID,
purchaseToken: changeSubscriptionParam?.oldPurchaseDetails.verificationData.serverVerificationData,
prorationMode: changeSubscriptionParam?.prorationMode,
replacementMode: changeSubscriptionParam?.replacementMode),
);
return billingResultWrapper.responseCode == BillingResponse.ok;
}
我们可以看到,方法内调用了billingClientManager.runWithClient,在lambda参数中调用了BillingClient的launchBillingFlow方法,我们刚才看代码发现,这个方法本身是支持可选参数obfuscatedProfileId的,所以,如果直接使用billingClientManager.runWithClient,问题好像就可以得到解决。
@visibleForTesting
final BillingClientManager billingClientManager;
因为 billingClientManager
在内部添加了@visibleForTesting
注解,所以在项目中直接引用它会有 ide 警告,但我们可以选择忽略这个警告,在我们的项目,我们是这么写的
BillingClientManager get _billingClientManager {
return (InAppPurchasePlatform.instance as InAppPurchaseAndroidPlatform)
// ignore: invalid_use_of_visible_for_testing_member
.billingClientManager;
}
/// 发起谷歌支付(为做示例,部分代码做了调整)
@override
Future launchPayment(
{
required GooglePlayProductDetails product,
required String orderId,
required String userId,
}
) async {
//...
await _billingClientManager.runWithClient((client) {
return client.launchBillingFlow(
product: product.id,
accountId: userId,
obfuscatedProfileId: orderId,
offerToken: product.offerToken,
);
});
//...
}
注:这部分代码因为用到 Google 平台功能,需要依赖扩展插件in_app_purchase_android 以及 in_app_purchase_platform_interface
支付结果监听
不管是 InAppPurchase.buyNonConsumable
或是使用BillingClientManager
调用launchBillingFlow
方法返回的 future 并不是支付的具体结果,比如launchBillingFlow
返回的是一个Future<BillingResultWrapper>
, 表示发起支付是否成功,而查看InAppPurchase.buyNonConsumable
也可以看到,当BillingResultWrapper.responseCode
为BillingResponse.ok
, 方法就会返回 Future.value(true)
, 否则返回 Future.value(false)
;
在这一步里,发起支付成功可以理解成是否调起了谷歌支付页面,用户是否确实支付了,或者是否中途取消了支付。
想要知道订单最终的支付结果,我们使用 Stream 进行监听
class _MyAppState extends State<MyApp> {
StreamSubscription<List<PurchaseDetails>> _subscription;
@override
void initState() {
final Stream purchaseUpdated =
InAppPurchase.instance.purchaseStream;
_subscription = purchaseUpdated.listen((purchaseDetailsList) {
_listenToPurchaseUpdated(purchaseDetailsList);
}, onDone: () {
_subscription.cancel();
}, onError: (error) {
// handle error here.
});
super.initState();
}
@override
void dispose() {
_subscription.cancel();
super.dispose();
}
InAppPurchasePlatform.instance.purchaseStream
是一个Stream<List<PurchaseDetails>>
类型。如果实际支付了一笔,那监听获取到的purchaseDetailsList
就是一个只有单个元素的 List。
实际在开发过程中,通过这样的监听获取订单支付结果未免有些不优雅,要是能直接 await 获取到结果该多好啊,因此我们项目做了个简单的封装
typedef PurchaseUpdatedListener = Function(List<PurchaseDetails> purchaseList);
class TWIAPUpdateDispatcher {
static TWIAPUpdateDispatcher? _instance;
late StreamSubscription<List<PurchaseDetails>> _subscription;
final Set<PurchaseUpdatedListener> _listeners = {};
factory TWIAPUpdateDispatcher() => _instance ??= TWIAPUpdateDispatcher._();
TWIAPUpdateDispatcher._() {
_subscription = InAppPurchase.instance.purchaseStream.listen((purchases) {
//有支付订单状态更新
_listenToPurchaseUpdated(purchases);
}, onDone: () {
_subscription.cancel();
_listeners.clear();
}, onError: (Object error) {
//...
});
}
void _listenToPurchaseUpdated(List<PurchaseDetails> purchaseList) {
final iterator = List.unmodifiable(_listeners).iterator;
while (iterator.moveNext()) {
iterator.current.call(purchaseList);
}
}
void register(PurchaseUpdatedListener listener) {
_listeners.add(listener);
}
void unregister(PurchaseUpdatedListener listener) {
_listeners.remove(listener);
}
}
先对 Stream 监听做一个简单的封装,实现动态注册监听。
/// 单例类
class TWIAPGooglePlatform {
//...
Completer<TWIAPGooglePurchaseResult>? _completer;
/// 是否有正在支付的订单
bool get inPurchasing => _completer?.isCompleted == false;
Future<TWIAPGooglePurchaseResult> launchPayment({
required ProductDetails product,
required String orderId,
}) async {
if (product is! GooglePlayProductDetails) {
return TWIAPGooglePurchaseResult.failure();
}
if (inPurchasing) {
return Future.value(TWIAPGooglePurchaseResult.failure(message: '有正在支付的订单'));
} else {
final c = Completer<TWIAPGooglePurchaseResult>();
_completer = c;
_billingClientManager.runWithClient((client) {
return client.launchBillingFlow(
product: product.id,
accountId: //...,
obfuscatedProfileId: orderId,
offerToken: product.offerToken,
);
}).then((value) {
TWIAPGoogleLogger.log('launchBillingFlow: ${value.responseCode}');
if (value.responseCode != BillingResponse.ok) {
final result = TWIAPGooglePurchaseResult.failure();
c.complete(result);
return result;
} else {
//插入支付结果监听
TWIAPUpdateDispatcher().register(_handlePurchase);
}
}, onError: (e) {
final result = TWIAPGooglePurchaseResult.failure();
c.complete(result);
});
return c.future;
}
}
}
/// 接收谷歌支付结果回调
_handlePurchase(List<PurchaseDetails> purchaseDetails) async {
//移除支付结果监听
TWIAPUpdateDispatcher().unregister(_handlePurchase);
//...
for (var p in purchaseDetails) {
switch (p.status) {
case PurchaseStatus.error:
//...
_completer?.complete(TWIAPGooglePurchaseResult.failure(purchase: p));
break;
case PurchaseStatus.canceled:
//...
_completer?.complete(TWIAPGooglePurchaseResult.failure(purchase: p));
break;
case PurchaseStatus.purchased:
//...
_completer?.complete(TWIAPGooglePurchaseResult.success(p));
break;
case PurchaseStatus.restored:
// 这个状态只在本地查找未确认订单时才有,因为这里是只处理即时支付的结果,不需要处理这种情况
// 本地查找订单我们稍后会说到。
break;
case PurchaseStatus.pending:
//...
_completer?.complete(TWIAPGooglePurchaseResult.failure(message: '支付待處理', purchase: p));
break;
}
}
}
大概就是这样,创建一个单例类并持有一个 Completer 对象_completer
保存支付结果,在发起支付的时候重新创建 Completer并作为方法的返回值,随后当_billingClientManager.runWithClient
进入 then 后,动态插入监听回调方法 _handlePurchase
,在方法内部移除监听,并把订单结果通过 Completer.complete([FutureOr<T>? value])
让 _completer
结束掉。
做完这些后,我们就可以通过 await 获取到支付结果
final result = await TWIAPGooglePlatform().launchPayment(product: iapProduct, orderId: orderId);
if(result.isSuccess){
//支付成功,发起入账...
} else {
//支付失败,提示用户...
}
订单消耗
在支付成功之后,我们还需要进行入账,这里的入账一般是我们要告知后端,App 支付已经成功了,商品可以“发货”给用户了。但仅仅这样是不够的,因为,我们还要向 Google 消耗/确认掉这笔订单。如果我们没有在 3 天内向谷歌发起消耗/确认,那么 3 天后,这笔订单将会自动退款!
在 Google 支付中,"consume" 和 "acknowledge" 是两个不同的概念,用于管理购买的商品或订阅。
- Consume(消耗): - 当用户购买了一个可消耗型商品(consumable),比如游戏中的金币或道具,开发者需要在用户购买后将该商品标记为已消耗,以便用户可以重复购买。消耗商品后,用户可以再次购买相同的商品。
- Acknowledge(确认): - 当用户购买了一个非消耗型商品(non-consumable)或自动续订订阅时,开发者需要在用户购买后确认该交易,以表明已经处理了该交易并且用户可以开始使用购买的商品或服务。
总结来说,消耗(Consume)用于可重复购买的商品,而确认(Acknowledge)用于一次性购买或订阅。在开发应用时,根据商品类型选择适当的操作是非常重要的。
具体由哪端发起消耗,移动端与后端协商处理即可。如果是移动端进行,那有一点要注意的是
InAppPurchase.instance.completePurchase(PurchaseDetails purchase)
目前 InAppPurchase 提供的这个 api,在 Google 上调用的是 acknowledge,如果是可消耗型商品的情况,那可以通过 InAppPurchase.instance
InAppPurchase.instance.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>().consumePurchase(PurchaseDetails)
完整代码如下:
/// 消耗谷歌订单
Future<bool> consumePurchase(
GooglePlayPurchaseDetails purchase,
) async {
final wrapper = await InAppPurchase.instance
.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>()
.consumePurchase(purchase);
final consumed = wrapper.responseCode == BillingResponse.ok;
if (!consumed) {
// 消耗失败
}
return consumed;
}
这部分代码同样需要依赖扩展插件in_app_purchase_android
接下来讲讲上文提到PurchaseStatus.restored
时说的查找历史订单的问题
相信各位看到这里,或多或少都感觉完整地走完支付流程的调用链还是有点长的,实际上,移动端开发受限于性能,网络波动,服务器问题等,特别是当我们面对支付这种涉及用户金额的比较重要的业务,还需要多考虑异常情况。
举个例子,假设在后端发货成功后,移动端发起订单消耗失败了。但这个时候如果没有任何兜底方案,那么 3 天后,这笔订单将会自动退款,但用户又收到了商品,这样造成的问题一是因为技术原因让用户“白嫖”了,二是如果公司部门一旦进行对账,很容易会发现问题。
有人可能会想到,那就做个失败重试嘛,比如失败后,间隔某个时间再发起重试,再比如加个重试上限 3 次啥的,但这其实也不稳妥,假设网络波动就是某段时间持续出问题,但重试还是会一直失败,也不能 100%解决问题。
那如果是失败时本地保存一下订单相关信息呢,然后比如在 app 每次启动或者切换到前台时查找一下这些消耗失败的订单再统一向 Google重新发起消耗,这种做法比较可行,而且也可以作为上述重试策略的一个补充。
不过我们可以省掉写代码实现本地保存失败订单的这一步,因为这个库已经提供了可以查找设备上未消耗订单的 api
InAppPurchase.instance.restorePurchases({String? applicationUserName});
查找所有的历史订单,支持传入applicationUserName
限定查找范围,返回值通过监听InAppPurchase.instance.purchaseStream 获取,订单的status均是 PurchaseStatus.restored
这也就解释了上文提到的,在 解析支付中订单时,switch-case中为何不处理PurchaseStatus.restored状态的订单,因为这个状态只在本地查找未确认订单时才有
InAppPurchase.instance.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>().queryPastPurchases({String? applicationUserName});
区别是通过传入类型参数只查找 Google订单,可以通过 await方法获取结果,此时订单 status并不会为PurchaseStatus.restored
查看这两个方法的源码可以发现,在 Android上会调用到
billing_client_wrapper.dart
的 BillingClient#queryPurchases``(ProductType productType)
而且均调用了两次
一次是queryPurchases(ProductType.inapp)
一次是queryPurchases(ProductType.subs)
分别代表查找一次性商品及订阅商品,最后再合并成一个列表
细心的话,你还会发现在这个文件下,BillingClient还有一个类似的也是查找历史订单的方法
queryPurchaseHistory(ProductType productType)
查看注释可以发现,这个方法注释写着
Unlike [queryPurchases], this makes a network request via Play and returns the most recent purchase for each [ProductDetailsWrapper] of the given [ProductType] even if the item is no longer owned.
也就是说,这个方法会通过网络请求查找历史订单
各位可以比较一下取适合自己业务的方法
因为上述我用到的很多方法都是通过 BillingClient调用的,因此我这里为了保证一致性,直接通过BillingClient调用queryPurchases,另外一个就是我们的支付业务只有一次性商品[inapp],并没有订阅类型,因此可以直接这么写
final restoredResp = await _billingClientManager.runWithClient((client) =>
client.queryPurchases(ProductType.inapp));
if (restoredResp.responseCode != BillingResponse.ok) {
return Future.value([]);
}
return restoredResp.purchasesList
.expand((e) => GooglePlayPurchaseDetails.fromPurchase(e))
.toList();
之后,从历史订单中捞出 PurchaseStatus.purchased
的订单并进行入账&消耗就可以了
Pending订单
最后再花点时间说下 Google支付独有的Pending订单,就是待支付的订单
有一些国家地区的Google支付是可以选择线下支付,在还没支付时,我们收到的就会是一笔待支付订单,状态会是
PurchaseStatus.pending
大概来说,线下支付是这么个流程:
用户选择线下支付后,发起的谷歌支付弹窗会关闭(这时候谷歌会返回一个 pending状态的订单给监听者),然后用户在拿着生成的线下订单票据去线下支付,可能是现金,银行卡也可能是其他方式,在这个过程中,我们的 app可能会一直打开着,又或者是被用户退出了,完成线下交易后,用户期许回到 app后,能看到订单入账后的状态,比如如果是充值点数的话就是点数增加了。
这里各位可以参考一下谷歌官方文档的指导
https://developer.android.com/google/play/billing/integrate#pending
这里我们的做法是
1. 当收到待支付订单时,通过吐司或弹窗提醒用户订单未完成待支付
2. 启动 app的时候初始化一个后台服务,监听InAppPurchase.instance.purchaseStream有无订单转为已支付。app处于后台时停止监听,回到前台时恢复监听,注意需要排除正在支付中的订单,避免对同一笔订单产生重复处理
3.当通过后台服务获取的已支付订单入账成功并消耗成功时,通过吐司或弹窗提醒用户订单已成功入账
4. 启动 app立即获取一次历史订单,从中找到成功支付的订单发起入账消耗,成功后同样提示用户订单已成功入账
各位可以参考这个做法,基本上可以保证用户能及时收到订单状态的反馈,如果有更好的做法也欢迎评论区分享
后话
由于专业限制,本文主要侧重于Google支付,Apple Pay也只是略带提及,但相信看完本文后,你一定对 in_app_purchase库的使用有了基本上的了解,相信 Apple Pay的实现也是水到渠成,如果有哪里写得不清晰或者不对的也烦请评论区或留言指正~