PAYATHON 2026

Flutter 中使用 Superwall Paywall 升降级订阅方案

支付小周

结论

Superwall Paywall 负责展示付费墙、提供商品选项并触发购买。用户点击另一个商品时,它不会自动替换 Google Play 中已有的订阅。

如果用户出现多个 active plan,通常说明新方案走的是普通订阅购买,而不是订阅替换。正确执行升级或降级时,需要通过 Google Play Billing 发起交易,并传入:

  • 目标订阅或 base plan
  • 当前订阅的 purchaseToken
  • 对应的 ReplacementMode

如果 superwallkit_flutter: ^1.3.9 的购买接口无法传递这些参数,Android 订阅替换就不能直接使用默认的 Paywall 购买流程。此时需要使用自定义购买处理器、原生 Platform Channel,或者接入支持 subscription replacement 的计费或订阅管理 SDK。

为什么会产生多个 active plan

普通购买和订阅替换使用的是两种不同的 Billing Flow。

普通购买只包含目标商品:

targetProductDetails

订阅替换还需要提供旧订阅信息:

targetProductDetails
oldPurchaseToken
replacementMode

缺少 oldPurchaseToken 时,Google Play 无法判断新商品应该替换哪笔订阅,因此可能把它创建为一笔独立订阅。

在 Paywall 中隐藏当前方案,或者只显示更高层级,可以改善用户看到的选项,但不会改变底层交易的性质。

推荐的实现结构

各部分可以这样分工:

  1. Superwall 决定何时显示付费墙,并展示可选方案。
  2. 应用或订阅后端确认用户当前的有效方案。
  3. 未订阅用户使用标准购买流程。
  4. 已订阅用户选择其他方案时,使用 Google Play subscription replacement 流程。
  5. 交易完成后,根据商店回执或服务端验证结果更新用户权益。
  6. Superwall 用户属性用于受众划分和 Paywall 展示,不作为订阅状态的唯一依据。

Paywall 如何显示正确选项

可以根据当前权益维护类似的用户属性:

await Superwall.shared.setUserAttributes({
  'subscription_tier': 'basic',
  'is_subscriber': true,
});

具体方法名应以 superwallkit_flutter: 1.3.9 实际公开的 API 为准,不同版本可能使用不同的属性更新接口。

然后在 Superwall Dashboard 中配置对应的 audience、campaign 或 paywall:

  • 未订阅:显示 basic、standard、premium
  • 当前为 basic:显示 standard、premium
  • 当前为 standard:显示 basic、premium
  • 当前为 premium:显示 basic、standard

Paywall 上还需要说明:

  • 升级是立即生效,还是按照商店规则结算
  • 降级是立即生效,还是到下个续费周期生效
  • 当前方案不能重复购买

这些配置只影响选项展示。真正的订阅替换仍须在 Billing Flow 中完成。

Android 端的核心处理

下面的代码只用于说明 Google Play Billing 的关键结构,不能直接作为完整实现复制到 Flutter 项目中。BillingClient 的具体 API 和可用 ReplacementMode 取决于项目使用的 Google Play Billing Library 版本。

val updateParams =
    BillingFlowParams.SubscriptionUpdateParams.newBuilder()
        .setOldPurchaseToken(oldPurchaseToken)
        .setSubscriptionReplacementMode(
            BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.DEFERRED
        )
        .build()

val productDetailsParams =
    BillingFlowParams.ProductDetailsParams.newBuilder()
        .setProductDetails(targetProductDetails)
        .setOfferToken(targetOfferToken)
        .build()

val billingFlowParams =
    BillingFlowParams.newBuilder()
        .setProductDetailsParamsList(listOf(productDetailsParams))
        .setSubscriptionUpdateParams(updateParams)
        .build()

billingClient.launchBillingFlow(activity, billingFlowParams)

其中最重要的是:

.setOldPurchaseToken(oldPurchaseToken)
.setSubscriptionReplacementMode(...)

没有这两项,目标方案通常会被当作新订阅购买。

oldPurchaseToken 应从当前有效的 Google Play 订阅购买记录中获取,同时确认它对应的正是用户准备替换的订阅。缓存中的 tier 名称和 Superwall 用户属性都不能代替 purchase token。

Flutter 中的调用方式

如果当前版本的 Superwall Flutter SDK 无法配置 subscription update 参数,可以通过 Platform Channel 调用 Android 原生代码:

import 'package:flutter/services.dart';

class AndroidSubscriptionManager {
  static const MethodChannel _channel =
      MethodChannel('app.subscription/replacement');

  static Future<void> replaceSubscription({
    required String oldPurchaseToken,
    required String targetProductId,
    required String targetBasePlanId,
    required String replacementMode,
  }) async {
    await _channel.invokeMethod<void>('replaceSubscription', {
      'oldPurchaseToken': oldPurchaseToken,
      'targetProductId': targetProductId,
      'targetBasePlanId': targetBasePlanId,
      'replacementMode': replacementMode,
    });
  }
}

调用示例:

await AndroidSubscriptionManager.replaceSubscription(
  oldPurchaseToken: currentSubscription.purchaseToken,
  targetProductId: 'app_subscription',
  targetBasePlanId: 'premium',
  replacementMode: 'DEFERRED',
);

Android 原生层需要完成以下工作:

  1. 查询 targetProductId 对应的 ProductDetails
  2. 从 subscriptionOfferDetails 中找到目标 base plan 或 offer
  3. 获取相应的 offerToken
  4. 构造 SubscriptionUpdateParams
  5. 调用 launchBillingFlow
  6. 在 PurchasesUpdatedListener 中处理结果
  7. 验证并 acknowledge 新 purchase
  8. 将新 purchase token 和权益状态同步到后端

不要直接信任客户端传入的任意 replacementMode 字符串。更安全的方式是在原生层或后端根据“当前方案 → 目标方案”的关系,映射到允许使用的枚举值。

升级与降级的结算策略

Google Play 提供多种 replacement mode,但具体支持哪些模式,取决于 Billing Library 版本和订阅配置。常见策略包括:

  • 升级立即生效,并按规则收取差价或重新计费
  • 升级立即生效,但暂不调整价格
  • 降级到下一续费周期生效
  • 替换后重新开始新的计费周期

不能只根据 basic、standard、premium 这些名称选择模式。业务层应建立明确的层级映射:

enum SubscriptionTier {
  basic,
  standard,
  premium,
}

bool isUpgrade(
  SubscriptionTier current,
  SubscriptionTier target,
) {
  return target.index > current.index;
}

例如,业务上可以把升级映射到立即生效的模式,把降级映射到 DEFERRED。最终选择仍需结合 Billing Library 文档、Play Console 配置、地区税费和实际测试结果。

Play Console 的配置建议

如果三个层级共用同一套订阅周期体系,可以优先将它们设计成同一个 subscription 下的不同 base plans。即使使用不同的 subscription product,也仍然需要明确执行订阅替换。

无论采用哪种模型,都要检查:

  • product ID、base plan ID 和 offer ID 是否匹配
  • 所有方案是否已激活,并覆盖目标国家或地区
  • 目标 offer 是否适用于当前用户
  • Play Console 中是否配置了不希望用户并行持有的独立订阅
  • 测试账号是否遗留了多个有效 purchase token

仅调整 Play Console 中的商品结构,无法自动修复客户端购买流程。客户端仍需传入旧的 purchaseToken。

用户状态应该如何管理

用户状态需要自行管理,但不能只在本地保存一个 currentPlan。

可靠的数据来源通常按以下顺序排列:

  1. 服务端验证后的商店订阅状态
  2. 通过 Google Play Developer API 或实时开发者通知同步的状态
  3. 客户端查询到的当前购买记录
  4. 本地缓存和 Superwall 用户属性

状态模型至少应区分:

class SubscriptionState {
  final String currentTier;
  final String? pendingTier;
  final DateTime? expiresAt;
  final bool willRenew;
  final bool isActive;

  const SubscriptionState({
    required this.currentTier,
    this.pendingTier,
    this.expiresAt,
    required this.willRenew,
    required this.isActive,
  });
}

使用延迟降级时,currentTier 和 pendingTier 可能同时存在。例如,用户当前仍在使用 premium,但已经安排在下个周期降为 standard。此时不能立即撤销 premium 权益。

与 Superwall 集成时的处理方式

理想情况下,Paywall 只返回用户选择的目标方案,之后由应用接管购买流程:

用户选择 premium
        ↓
读取已验证的当前订阅
        ↓
没有有效订阅 → 普通购买
已有其他订阅 → subscription replacement
        ↓
验证交易结果
        ↓
更新应用权益
        ↓
同步 Superwall 用户属性

能否直接使用 Superwall 的自定义 purchase controller,取决于 superwallkit_flutter: 1.3.9 是否提供对应的扩展点,以及能否传入 Android 订阅替换参数。在核对该版本的 API 之前,不能假定默认购买回调已经支持 oldPurchaseToken 和 ReplacementMode。

如果该版本没有这些接口,可以选择:

  • 升级到支持相应自定义购买能力的 Superwall SDK 版本
  • 通过原生 Platform Channel 实现替换流程
  • 接入支持 Google Play subscription replacement 的订阅管理 SDK,让 Superwall 只负责展示 Paywall
  • 暂时将“管理订阅”按钮链接到 Google Play 订阅管理页。这种方式适合让用户取消或管理订阅,但无法完全控制应用内的升级体验

其他注意事项

  • 新购买成功前,不要主动取消旧订阅。
  • 不要只根据 Superwall 的 isSubscriber 判断具体 tier。
  • 购买完成后,应进行服务端验证并正确 acknowledge。
  • 恢复购买、退款、宽限期、暂停、付款失败和关闭自动续订需要分别处理。
  • 测试时使用 Google Play License Tester,并覆盖升级、延迟降级、取消降级和付款失败等场景。
  • 已经产生的多个 active plan 不会在代码修复后自动合并。需要逐一检查订阅状态,并让测试用户取消多余方案或等待其到期。
  • iOS 使用 App Store subscription group 的升级和降级规则,不能复用 Android 的 purchaseToken 与 ReplacementMode 实现。两端需要分别处理。

问题不在于 Paywall 如何排列三个商品,而在于已订阅用户选择新方案后,不能再执行普通购买。应用必须将当前有效订阅的 purchase token 传入 Google Play 的 subscription replacement 流程,再根据验证结果更新用户权益和 Superwall 展示状态。

备注:内容仅供参考。