重磅发布!KMP 双端订阅支付彻底封神,一套代码搞定 iOS+Android

本文首发于公众号"Android技术圈"

Kotlin Multiplatform 终于把订阅支付这块最难啃的骨头啃下来了。

RevenueCat 最近发布了 purchases-kmp SDK,一套代码同时搞定 Google Play 和 App Store 的订阅、收据验证、付费墙展示。对做过双端订阅的开发者来说,这意味着不用再同时维护 BillingClient 和 StoreKit 两套接入逻辑。

核心抽象:四个概念干掉两套 SDK

RevenueCat KMP SDK 把双端订阅抽象成四个共享概念:

  • Offering --- 在 RevenueCat 后台配置的商品集合,不在代码里写死

  • Package --- 一个 Offering 里的可购买单元(月付、年付、终身)

  • Entitlement --- 应用层检查的权限等级(premiumpro),不关心来自哪个商店

  • CustomerInfo --- 当前用户跨平台的所有活跃权限聚合

关键变化在于:应用代码不再关心用户从哪个商店购买,只需检查一行:

bash 复制代码
val isPremium = customerInfo.entitlements["premium"]?.isActive == true

这行代码在 Android 和 iOS 上完全一样。

接入步骤

添加依赖

purchases-kmp 发布在 Maven Central,两个 artifact:

bash 复制代码
# gradle/libs.versions.toml
[versions]
purchases-kmp = "2.2.14+17.24.0"

[libraries]
purchases-kmp-core = { module = "com.revenuecat.purchases:purchases-kmp-core", version.ref = "purchases-kmp" }
purchases-kmp-ui   = { module = "com.revenuecat.purchases:purchases-kmp-ui",   version.ref = "purchases-kmp" }

commonMain 里直接依赖,不需要平台特定的依赖块:

bash 复制代码
kotlin {
  sourceSets {
    commonMain.dependencies {
      implementation(libs.purchases.kmp.core)
      implementation(libs.purchases.kmp.ui)
    }
  }
}

iOS 端需要通过 CocoaPods 链接原生框架。在 build.gradle.ktscocoapods 块里声明:

bash 复制代码
kotlin {
  cocoapods {
    ios.deploymentTarget = "15.0"
    pod("RevenueCat") { version = "~> 5.21" }
    pod("RevenueCatUI") { version = "~> 5.21" }
  }
}

跑一次 ./gradlew podInstall 就行。

平台初始化

SDK 初始化是唯一需要写平台特定代码的地方。

Android 端在 Application.onCreate

bash 复制代码
class App : Application() {
  override fun onCreate() {
    super.onCreate()
    Purchases.logLevel = LogLevel.DEBUG
    Purchases.configure(
      PurchasesConfiguration(apiKey = "your_android_key") {
        appUserId = null // 匿名用户
      }
    )
  }
}

iOS 端在 SwiftUI Appinit

bash 复制代码
@main
struct iosApp: App {
  init() {
    Purchases.logLevel = .debug
    Purchases.configure(withAPIKey: "your_ios_key")
  }
}

初始化完成后,Purchases.sharedInstance 就可以在 commonMain 里随便用了。

权限检查

commonMain 里写一个 suspend 函数就能检查订阅状态:

bash 复制代码
suspend fun isPremium(): Boolean {
  val customerInfo = Purchases.sharedInstance.awaitCustomerInfo()
  return customerInfo.entitlements["premium"]?.isActive == true
}

CustomerInfo.entitlements 是一个 Map,每个值包含 isActiveexpirationDatewillRenewstore 等字段。判断权限只需要看 isActive,不需要管来源是 Google Play 还是 App Store。

实际项目里建议封装成 Repository:

bash 复制代码
interface PaywallsRepository {
  fun fetchOffering(): Flow<Result<Offering>>
  fun fetchCustomerInfo(): Flow<Result<CustomerInfo>>
  fun awaitPurchase(packageId: String): Flow<Result<StoreTransaction>>
}

ViewModel 通过 stateIn 暴露状态:

bash 复制代码
class ArticleViewModel(
  paywallsRepository: PaywallsRepository
) : ViewModel() {

  val customerInfo: StateFlow<CustomerInfo?> =
    paywallsRepository.fetchCustomerInfo()
      .map { it.getOrNull() }
      .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),
        initialValue = null
      )
}

UI 层读取状态做权限控制:

bash 复制代码
@Composable
fun ArticleContent(
  article: Article,
  viewModel: ArticleViewModel,
  onJoinClicked: () -> Unit
) {
  val customerInfo by viewModel.customerInfo.collectAsState()
  val isEntitled = customerInfo?.entitlements?.get("premium")?.isActive == true

  // 订阅用户看全文,免费用户显示模糊 + "立即加入"按钮
  DetailsContent(
    article = article,
    isEntitled = isEntitled,
    onJoinClicked = onJoinClicked
  )
}

发起购买

购买流程也是纯 commonMain 代码:

bash 复制代码
suspend fun purchaseMonthly() {
  val offerings = Purchases.sharedInstance.awaitOfferings()
  val current = offerings.current ?: error("未配置 offering")
  val monthly = current.monthly ?: current.availablePackages.first()
  val transaction = Purchases.sharedInstance.awaitPurchase(monthly)
}

awaitPurchase 返回后,收据已经在 RevenueCat 服务端验证完毕,CustomerInfo 自动更新。UI 绑定了 customerInfo Flow 的话会自动重组。

用户取消购买时 SDK 抛 PurchasesException,错误码是 PurchaseCancelled,当无事件处理就行。

服务端驱动付费墙

purchases-kmp-ui 提供了一个开箱即用的 Paywall Composable:

bash 复制代码
@Composable
fun PaywallScreen() {
  val navigator = currentComposeNavigator

  Paywall(
    options = PaywallOptions(
      dismissRequest = { navigator.navigateUp() }
    )
  )
}

不传 offering 参数时默认渲染当前 Offering。A/B 测试变体自动选择和归因。

重点是:付费墙的布局、推荐套餐、试用文案都可以在 RevenueCat 后台的 Paywall Editor 里改,不需要发版。

项目结构参考

RevenueCat 的 demo 项目 cat-paywalls-kmp 的模块划分:

bash 复制代码
composeApp/
  commonMain/     → App.kt, NavHost, 所有共享 UI
  androidMain/    → Application (Purchases.configure)
  iosMain/        → MainViewController() 桥接
feature/
  home/           → 文章列表
  article/        → 文章详情 + 权限控制
  paywalls/       → 服务端驱动付费墙
  subscriptions/  → 订阅管理
core/
  data/           → PaywallsRepository (SDK 调用封装)
  model/          → 数据类
  network/        → Ktor 客户端
  designsystem/   → 主题
  navigation/     → 导航图

composeApp 是唯一有平台特定代码的模块。feature/* 只依赖 core/*,所有 UI 和业务逻辑都在 commonMain

几个需要注意的点

匿名用户迁移appUserId = null 会生成匿名 ID,后续调用 Purchases.sharedInstance.logIn(userId) 可以把购买记录迁移到登录账号。

版本号格式purchases-kmp 的版本号是 <sdk>+<hybrid-common>,比如 2.2.14+17.24.0。加号后面的数字是 iOS Pod 的 PurchasesHybridCommon 版本,两边要对齐。

CustomerInfo 的 StoreTransaction :返回值是信息性的,收据已经验证完毕。下次调 awaitCustomerInfo() 就能看到最新的 entitlement 状态。

写在最后

双端订阅接入一直是 KMP 生态里缺的一块拼图。以前要么各自接原生 SDK 写两遍,要么自己封装一层抽象层维护成本很高。

RevenueCat 这个 SDK 把商店差异完全屏蔽在抽象层下面,应用代码只跟 Offering、Entitlement 打交道,再加上服务端驱动的付费墙,基本上把订阅相关的开发和运营成本压到最低。

对正在用或打算用 Compose Multiplatform 的团队来说,这是值得优先接入的基础设施。

#KMP #ComposeMultiplatform #订阅支付 #RevenueCat #跨平台开发

相关推荐
Carson带你学Android2 小时前
别再乱学了!深度解读 Google 官方发布 Android 6 大核心 Skills
android·前端·ai编程
张风捷特烈2 小时前
状态管理大乱斗#06 | Riverpod 源码评析 (下) - 外功心法
android·前端·flutter
三少爷的鞋2 小时前
Kotlin 协程 vs Java 虚拟线程:两种并发模型的对比
android
白云LDC11 小时前
Android Studio新建Vecter asset一直显示Loading icons(转圈圈)的解决办法
android·ide·android studio
Rytter14 小时前
某气骑士 libtprt.so 反 Frida 机制分析与绕过
android·安全·网络安全
alexhilton15 小时前
揭密:Compose应用如何做到启动提升34%
android·kotlin·android jetpack
沐言人生17 小时前
React Native 源码分析1——HybridData 机制深度分析
android·react native
程序员陆业聪17 小时前
跨平台框架全景图:Flutter/KMP/KuiKly/RN的2026年格局
android
码云数智-园园18 小时前
Fibers(纤程)来了:打破阻塞,实现纯PHP下的异步非阻塞IO
android