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 中隐藏当前方案,或者只显示更高层级,可以改善用户看到的选项,但不会改变底层交易的性质。
推荐的实现结构
各部分可以这样分工:
- Superwall 决定何时显示付费墙,并展示可选方案。
- 应用或订阅后端确认用户当前的有效方案。
- 未订阅用户使用标准购买流程。
- 已订阅用户选择其他方案时,使用 Google Play subscription replacement 流程。
- 交易完成后,根据商店回执或服务端验证结果更新用户权益。
- 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 原生层需要完成以下工作:
- 查询
targetProductId对应的ProductDetails - 从
subscriptionOfferDetails中找到目标 base plan 或 offer - 获取相应的
offerToken - 构造
SubscriptionUpdateParams - 调用
launchBillingFlow - 在
PurchasesUpdatedListener中处理结果 - 验证并 acknowledge 新 purchase
- 将新 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。
可靠的数据来源通常按以下顺序排列:
- 服务端验证后的商店订阅状态
- 通过 Google Play Developer API 或实时开发者通知同步的状态
- 客户端查询到的当前购买记录
- 本地缓存和 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 展示状态。
备注:内容仅供参考。
