PAYATHON 2026

Flutter 如何使用 developer determined 的 Google Play 订阅优惠?

技术老齐

结论

developer determined 表示优惠资格由应用或服务端判断。Google Play 不知道用户是否把“运动”设为爱好,也不会执行这项资格规则。

Flutter 端可以按以下流程处理:

  1. 通过 queryProductDetails() 获取订阅的基础方案和优惠。
  2. 从 GooglePlayProductDetails.subscriptionOfferDetails 读取优惠,不要手动创建 SubscriptionOfferDetailsWrapper。
  3. 使用 offerId 或 offerTags 定位指定优惠。
  4. 由业务逻辑或服务端判断用户是否符合条件。
  5. 用户符合条件时展示优惠的 pricingPhases,否则展示基础方案价格。
  6. 购买时传入选中的 GooglePlayProductDetails,由插件使用其中对应的 offerToken。

不要手动创建 SubscriptionOfferDetailsWrapper

SubscriptionOfferDetailsWrapper 只用于包装 Google Play 的查询结果,客户端不应使用它创建优惠。优惠价格、计费周期和 offerToken 都必须来自 Google Play。

以下写法是错误的:

offer = SubscriptionOfferDetailsWrapper(
  basePlanId: basePlanId,
  offerTags: offerTags,
  offerIdToken: offerIdToken,
  pricingPhases: pricingPhases,
);

客户端自行构造的对象并不代表 Google Play 中实际存在的优惠,伪造的 offerToken 也无法用于购买。

查询订阅和优惠

在较新的 in_app_purchase_android 中,同一订阅的基础方案和不同优惠会分别表示为多个 GooglePlayProductDetails,每个对象通过 subscriptionOfferDetails 指向对应的方案。

可以在 Google Play Console 中为运动用户优惠添加一个容易识别的 tag,例如:

sports_10_percent

查询代码如下:

import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:in_app_purchase_android/in_app_purchase_android.dart';

const subscriptionId = 'premium_subscription';
const basePlanId = 'monthly';
const sportsOfferTag = 'sports_10_percent';

final InAppPurchase inAppPurchase = InAppPurchase.instance;

Future<List<GooglePlayProductDetails>> loadSubscriptionVariants() async {
  final ProductDetailsResponse response =
      await inAppPurchase.queryProductDetails({subscriptionId});

  if (response.error != null) {
    throw StateError(
      'Failed to query products: ${response.error!.message}',
    );
  }

  return response.productDetails
      .whereType<GooglePlayProductDetails>()
      .where((product) => product.id == subscriptionId)
      .toList();
}

可以用下面的辅助函数查找方案:

T? firstWhereOrNull<T>(
  Iterable<T> values,
  bool Function(T value) test,
) {
  for (final value in values) {
    if (test(value)) {
      return value;
    }
  }
  return null;
}

查找运动用户优惠:

GooglePlayProductDetails? findSportsOffer(
  List<GooglePlayProductDetails> variants,
) {
  return firstWhereOrNull(variants, (product) {
    final details = product.subscriptionOfferDetails;

    return details != null &&
        details.basePlanId == basePlanId &&
        details.offerTags.contains(sportsOfferTag);
  });
}

基础方案通常可以通过 offerId == null 识别:

GooglePlayProductDetails? findBasePlan(
  List<GooglePlayProductDetails> variants,
) {
  return firstWhereOrNull(variants, (product) {
    final details = product.subscriptionOfferDetails;

    return details != null &&
        details.basePlanId == basePlanId &&
        details.offerId == null;
  });
}

部分旧版本的 wrapper 可能没有公开 offerId。遇到这种情况,最好同时升级 in_app_purchase 和 in_app_purchase_android。不要仅凭“没有运动优惠 tag”来选择基础方案,因为同一个基础方案下可能还有其他优惠。

如果当前版本没有单个 subscriptionOfferDetails 属性,而是通过底层 productDetails.subscriptionOfferDetails 返回列表,说明该插件版本采用了不同的映射方式。此时应按照该版本的 API 处理,或者升级两个 package 后使用上面的方式。

判断用户是否符合条件

“用户把运动列为爱好”是应用自己的数据。资格可以在本地判断,但由服务端根据已登录用户返回结果更稳妥:

Future<bool> isEligibleForSportsOffer() async {
  // 调用自己的服务端。
  // 服务端根据当前账号资料、活动规则等返回资格结果。
  return fetchEligibilityFromBackend();
}

加载页面时,根据资格选择展示的方案:

class SubscriptionSelection {
  const SubscriptionSelection({
    required this.standardPlan,
    required this.selectedPlan,
    required this.isUsingOffer,
  });

  final GooglePlayProductDetails standardPlan;
  final GooglePlayProductDetails selectedPlan;
  final bool isUsingOffer;
}

Future<SubscriptionSelection> loadSubscriptionSelection() async {
  final variants = await loadSubscriptionVariants();

  final standardPlan = findBasePlan(variants);
  if (standardPlan == null) {
    throw StateError('Base plan $basePlanId was not returned by Google Play.');
  }

  final eligible = await isEligibleForSportsOffer();
  final sportsOffer = eligible ? findSportsOffer(variants) : null;

  return SubscriptionSelection(
    standardPlan: standardPlan,
    selectedPlan: sportsOffer ?? standardPlan,
    isUsingOffer: sportsOffer != null,
  );
}

业务系统判定用户符合条件后,仍要处理 Google Play 没有返回优惠的情况。原因可能是优惠尚未生效或配置有误,也可能是当前账号、地区或测试环境不满足 Play 侧条件。遇到这种情况应回退到基础方案,不能自行拼接优惠价格或 offerToken。

显示正确价格

客户端不应通过“原价乘以 0.9”计算展示价格。币种、税费、舍入方式和本地化格式都应以 Google Play 返回的 formattedPrice 为准。

优惠只有一个持续计费阶段时,可以直接显示所选对象的 price:

final selection = await loadSubscriptionSelection();

final String displayedPrice = selection.selectedPlan.price;

如果优惠包含免费试用、限期折扣或后续恢复原价等多个阶段,需要读取完整的 pricingPhases:

String describePricing(GooglePlayProductDetails product) {
  final details = product.subscriptionOfferDetails;

  if (details == null) {
    return product.price;
  }

  return details.pricingPhases.pricingPhaseList.map((phase) {
    return '${phase.formattedPrice} / ${phase.billingPeriod}';
  }).join(',之后 ');
}

界面可以分别显示标准价格和当前价格:

final selection = await loadSubscriptionSelection();

if (selection.isUsingOffer) {
  print('标准价格:${describePricing(selection.standardPlan)}');
  print('运动用户优惠:${describePricing(selection.selectedPlan)}');
} else {
  print('订阅价格:${describePricing(selection.standardPlan)}');
}

billingPeriod 通常是 ISO 8601 周期,例如 P1M。正式界面可以根据业务支持的周期转换为“每月”等文案。存在多个 pricing phase 时,还要结合 billingCycleCount 和 recurrenceMode 说明优惠持续次数,以免把限时价格显示成永久价格。

使用选中的优惠发起购买

发起购买时,必须使用刚才选中的 GooglePlayProductDetails:

Future<void> buySubscription(
  GooglePlayProductDetails selectedProduct,
) async {
  final purchaseParam = GooglePlayPurchaseParam(
    productDetails: selectedProduct,
  );

  await InAppPurchase.instance.buyNonConsumable(
    purchaseParam: purchaseParam,
  );
}

在会把一个订阅方案展开成多个 GooglePlayProductDetails 的插件版本中,所选对象已经关联对应的 SubscriptionOfferDetailsWrapper,Android 实现会从中读取 offerIdToken。

如果当前版本的 GooglePlayPurchaseParam 明确提供了 offerToken 参数,也可以按照该版本的 API 显式传入:

final offerDetails = selectedProduct.subscriptionOfferDetails;

final purchaseParam = GooglePlayPurchaseParam(
  productDetails: selectedProduct,
  offerToken: offerDetails?.offerIdToken,
);

不要长期保存 offerIdToken,也不要把另一次查询得到的 token 与当前 ProductDetails 混用。每次进入购买流程时重新查询产品详情会更安全。

资格判断的安全边界

客户端控制“是否显示优惠”只能限制正常的操作流程,不能充当严格的访问控制。developer determined 优惠可能会随产品详情返回客户端,修改过的应用可能绕过界面判断并尝试使用该优惠。

资格规则应以服务端记录为准,服务端还需要验证购买凭据、产品 ID、基础方案和优惠信息。Google Play 无法根据应用中的“运动爱好”字段阻止不符合条件的账号购买。如果优惠需要由 Google Play 强制执行资格限制,应优先使用 Play Console 支持的资格条件,不要完全依赖 developer determined。

查询结果为空时检查什么

如果 response.productDetails 为空,可以逐项检查:

  • 查询参数使用的是订阅 product ID,而非 base plan ID 或 offer ID。
  • 订阅、基础方案和优惠均已启用,并已发布到当前测试轨道。
  • 应用的 package name、签名与 Play Console 中的应用一致。
  • 测试账号已加入相应测试范围,并且应用是通过 Google Play 安装的。
  • 优惠适用于测试账号所在的国家或地区。
  • in_app_purchase 与 in_app_purchase_android 的版本相互兼容。

各优惠字段的含义可参考 ProductDetails.SubscriptionOfferDetails。

备注:内容仅供参考。