Flutter 集成谷歌支付[in_app_purchase]

1,430 阅读14分钟

目前在dart 的 pub.dev/上,比较流行的支付插件有

in_app_purchase

flutter_inapp_purchase

其中 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.responseCodeBillingResponse.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" 是两个不同的概念,用于管理购买的商品或订阅。 

  1. Consume(消耗): - 当用户购买了一个可消耗型商品(consumable),比如游戏中的金币或道具,开发者需要在用户购买后将该商品标记为已消耗,以便用户可以重复购买。消耗商品后,用户可以再次购买相同的商品。
  2. 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.dartBillingClient#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的实现也是水到渠成,如果有哪里写得不清晰或者不对的也烦请评论区或留言指正~