简介:Android 应用内购买与订阅开源框架,面向需要接入 BillingClient 和 RevenueCat 后端的开发者,可用于快速实现收据验证、订阅状态跟踪与分析统计,适合有 Kotlin 基础的移动端工程师直接移植或二次开发。压缩包共 382 个文件,约 772KB,其中以 158 个 Kotlin 源码、72 个 XML 配置布局、20 个 ProGuard 混淆规则、19 个 Gradle 构建脚本及 Java、Properties、Markdown 文档为主,结构完整,便于按模块阅读与集成。目前已有 316 人学习下载。通过这套源码,读者可以掌握采购框架的整体封装思路,理解服务端到服务端的购买、续订、取消等事件同步机制,并借助内置的多种集成把购买数据发送到所需平台,省去从零搭建订阅管理系统的繁琐工作。
1. 为什么我放弃了手写 Google Play Billing,转向 purchases-android
上个月接了个App改造的活儿,需求很简单:把原本的“买断制解锁”改成“订阅制”,顺便把一直靠手工对账的支付状态梳理清楚。我跟大多数Android开发者的第一反应一样——直接用Google官方的BillingClient不就行了?但真把需求拆完,我发现事情没那么简单:订阅的自动续费、宽限期、账户保留期、收据校验、以及“用户在A设备买了、B设备要能恢复”这一堆状态同步问题,全堆到客户端上,代码量直接爆炸。
后来我把方案改成了基于purchases-android(RevenueCat 的 Android SDK)来做,整个过程顺了不少。这篇文章就把我这几周的实战经验整理出来,包括这个SDK到底解决了什么问题、怎么快速接入、收据验证和状态跟踪的底层逻辑,以及我在真实项目中踩过的坑。如果你是第一次接触Android应用内购买和订阅,或者已经接入了 BillingClient 但被各种边缘状态搞到头大,这篇应该能帮你省下不少时间。
先说结论:purchases-android本质上是把 Google Play Billing 封装成了“你只管卖,状态和校验交给它”的模式。它在你的 App 和 Google Play 之间加了一层服务端,帮你统一处理收据验证、订阅状态追踪、跨端同步这些脏活累活。你不再需要自己维护一番BASE_64_ENCODED_PUBLIC_KEY,也不用自己在每个订阅事件里写一堆状态机判断。
2. 核心功能拆解:SDK到底帮你做了什么
2.1 应用内购买与订阅的统一入口
purchases-android在官方文档里的定位是“应用购买与订阅管理库”,但我觉得更准确的说法是“购买逻辑的代理层”。它把 Google Play Billing 的BillingClient、PurchasesUpdatedListener、SkuDetails这些底层对象全部封装起来,对外暴露的是一套更简单的购买流程。
举个例子,用原生 BillingClient 买一个订阅,你需要经历至少五个步骤:
- 初始化
BillingClient,并处理连接状态回调; - 查询
SkuDetails,拿到商品信息; - 调
launchBillingFlow发起购买; - 在
onPurchasesUpdated里处理购买结果; - 调
consumeAsync或acknowledgePurchase确认消费。
如果其中有一步连接失败了,你还得自己处理重试。而用purchases-android,核心流程就变成了:
// 初始化(在 Application 中) Purchases.configure( PurchasesConfiguration.Builder(context, "your_public_sdk_key").build() ) // 发起购买 Purchases.sharedInstance.purchaseWith( PurchaseParams.Builder(activity, offer) .build(), onResult = { result -> when (result) { is PurchaseResult.Success -> { // 购买成功,直接在回调里拿到最新的 CustomerInfo(用户全部订阅状态) val entitlement = result.customerInfo.entitlements["pro"] } is PurchaseResult.Error -> { // 处理错误 } is PurchaseResult.Cancelled -> { // 用户取消 } } } )注意看那个CustomerInfo,它包含了这个用户在所有设备上的订阅状态。这意味着你不需要自己在本地数据库里维护“用户是否已订阅”,也不需要自己去处理多设备恢复购买的逻辑。用户在另一台手机上重新登录,拉一次CustomerInfo就全有了。
2.2 “收据验证”到底验证的是什么
做过内购的人都知道,客户端拿到的Purchase对象里有个purchaseToken,Google 要求开发者拿这个 token 去调 Play Developer API 的purchases.subscriptions.get接口,才能真正确认这笔交易有效、并且拿到订阅的到期时间。这一步在官方文档里叫收据验证(Receipt Validation),很多国内团队直接把purchaseToken丢给自己的后端,让后端去调 Google 接口,逻辑看起来没毛病,但有几个隐患:
- 你的后端要维护一套完整的 Google OAuth 2.0 认证流程,token 过期了要刷新;
- 订阅状态不是“查询一次就结束”,而是要在用户每次启动App、每次恢复购买、以及 Google Play 每次发来“订阅续费成功”通知时都去查一遍;
- 还要处理退款、撤销、暂停订阅等异常状态。
purchases-android的解决方案是把这一步直接做了:App 端发起购买后,SDK 会把收据上传到 RevenueCat 的服务器,由他们的服务器去跟 Google Play 验证,然后把结构化好的状态推到客户端。客户端看到的是一个EntitlementInfo(权益信息),里面直接标好了isActive(是否生效)、expirationTime(过期时间)、willRenew(是否会续费)这些字段,你直接拿来判断“这个用户有没有会员”就行。
2.3 状态跟踪:从“死数据”到“活状态”
我觉得这个SDK最值钱的地方在状态跟踪。原生 BillingClient 里,queryPurchases返回的是一堆历史购买记录,你要自己根据purchaseState、acknowledged、autoRenewing等字段判断当前到底处于什么状态。但订阅是一个跨时间维度的东西,有大量边缘状态:
- 用户首次购买,3天免费试用期;
- 试用期结束自动转为付费订阅;
- 用户不想要了,在 Google Play 设置里取消了自动续费,但当前周期内还能继续用;
- 用户绑定的信用卡余额不足,Google 进入宽限期(grace period),可能续费成功也可能失败;
- 用户申请退款并成功,订阅被撤销。
以上每一种状态,在原生 API 里你都得自己用queryPurchasesAsync去拉,然后写 if-else 判断。而在purchases-android里,这些状态最终都会映射到一个EntitlementInfo上,你只需要关心几个关键布尔值。
| 业务场景 | 原生 BillingClient 需要的手工处理 | purchases-android 中的状态 |
|---|---|---|
| 用户购买订阅 | 监听 onPurchasesUpdated,判断 purchaseState | 直接拿 CustomerInfo.entitlements |
| 自动续费 | 依赖后端定时查 Play Developer API | 服务端主动 webhook 推送后同步 |
| 用户取消自动续费 | 需要 queryPurchases 检查 autoRenewing | isActive=true,willRenew=false |
| 宽限期 | 需要自己解析续费通知里的续费类型 | isActive=true,isInGracePeriod=true |
| 退款/撤销 | 后端定期查询或监听退款推送 | isActive=false,自动同步到客户端 |
| 跨设备恢复 | 自己写恢复购买逻辑 | Purchases.restorePurchases() 一行搞定 |
这种“状态统一映射”的思路,避免了开发者陷入 Google Play 各种细微字段的泥沼。订阅这套东西,状态字段之间互相影响,尤其新手很容易在purchaseState为PURCHASED但acknowledge还没做的时候,误以为购买流程没走完,白白拦截用户。
3. 实操:从零接入 purchases-android 的完整步骤
3.1 环境准备与依赖配置
接入前最好确认你的项目使用的是 Android Studio 较新版本(我用的是 Android Studio Hedgehog 之后的版本,Gradle 版本 8.x),并且 App 的目标版本不低于 Android 5.0(API 21),SDK 本身对旧的 Android 版本兼容性不错,但 Google Play Billing 库现在强制要求用较新的编译版本。
在项目级build.gradle里加 Maven 仓库(这个一般默认就有),然后在模块级build.gradle的 dependencies 里添加:
dependencies { implementation "com.revenuecat.purchases:purchases-android:8.5.1" }注意版本号,新版 SDK 把最低 API 级别提到了 21,如果你的 App 还在坚持 minSdk 19,就需要降到 7.x 版本。依赖加完后同步一下,确认没有 Billing 版本冲突即可。
3.2 初始化与用户识别
初始化需要在 Application 里完成,直接把你的 RevenueCat 公共密钥传进去:
class MyApp : Application() { override fun onCreate() { super.onCreate() Purchases.configure( PurchasesConfiguration.Builder(this, "your_public_sdk_key").build() ) // 如果有登录系统,在用户登录后调用标识 Purchases.sharedInstance.logIn(userId) { customerInfo, error -> // 登录成功后会返回最新的订阅状态 } } }这里有个关键点:logIn方法。如果你的 App 有账号体系,一定要在用户登录后调用它,把 RevenueCat 的匿名用户跟你自己的用户 ID 关联起来。否则用户换设备后,用新生成的匿名 ID 恢复购买,大概率恢复不到之前的订阅。退了登录就调logOut,防止账号串号。
3.3 商品配置与购买
RevenueCat 控制台里要创建对应的产品(Product),名字随意,但必须绑定 Google Play Console 里建好的商品 ID。代码里通过queryProductDetailsAsync拉取商品:
Purchases.sharedInstance.queryProductDetailsAsync( listOf("pro_monthly", "pro_yearly") ) { productDetails, error -> productDetails?.forEach { product -> // 展示价格、标题、描述等信息 } }然后把用户选中的商品传给purchaseWith,注意传入的activity是当前要在其上弹出 Google 支付对话框的 Activity。购买完成后,服务器验证是需要时间的,通常几秒内就能拿到结果,但极端情况下可能要等更久,所以 SDK 也提供了PurchaseResult.Success回调里的customerInfo来刷新界面,同时还可以通过后续的customerInfo监听来最终确认。
3.4 状态监听与权益判断
购买之后,最重要的就是正确判断“用户有没有权益”。我见过不少新手直接拿productId存在本地当“会员标记”,这种方案在纯买断制里还凑合,在订阅制里几乎是必出问题——因为用户可能在 Google Play 那边退订、退款,而你本地存了一个“曾经买过”的标记,就会误放行。
purchases-android的标准做法是通过CustomerInfo里的entitlements来判断。Entitlement 可以理解成“你的 App 里定义的一种权益凭证”,一个产品可以对应多个 entitlement,一个 entitlement 也可以由多个产品触发。意思就是,我可以定义一个premium权益,让月卡、年卡、终身买断都能激活它,这样客户端只要判断premium有没有效,不用关心用户具体买的是哪个商品。
Purchases.sharedInstance.getCustomerInfo { customerInfo, error -> val isPremium = customerInfo?.entitlements?.get("premium")?.isActive == true if (isPremium) { // 显示会员界面 } else { // 显示付费引导 } }在需要实时刷新的场景下,比如用户切回前台应该有最新状态,可以监听Purchases.PurchasesListener:
Purchases.sharedInstance.listeners.add(object : UpdatedCustomerInfoListener { override fun onCustomerInfoUpdated(customerInfo: CustomerInfo) { // 这里会在购买成功、续费状态变化、退款等场景被触发 } })4. 收据验证与订阅状态检查的底层逻辑
4.1 为什么要依赖服务端验证而不是本地信任
先明确一个概念:Google Play Billing 收据验证,真正安全可靠的方式是服务端验证。客户端即使拿到了purchaseToken,也完全可以直接决定“我信了”,然后给用户放行——但这就等于把一个商业系统的安全边界放到了别人能任意修改的客户端里,静态分析、逆向、改机工具都能轻松绕过。purchases-android的思路是客户端只负责展示和发起购买,最终验证由 RevenueCat 的服务端完成,再把结果下发。
这也是为什么初始化的时候用的是public_sdk_key,它是可以暴露在客户端里的。真正有权限去 Google Play 拉取验证结果的 SECRET key,永远只存在于 RevenueCat 的服务器。客户端那层就算被逆向,拿到的也只是一个对攻击者无用的公钥。
4.2 服务端如何与 SDK 协作
很多团队担心“是不是用了 purchases-android,所有逻辑都被绑架在它家了”。其实不然。这个 SDK 提供了一个可选的Purchases.attribution功能,以及一个后端 API(RevenueCat REST API),你可以把用户 ID、app user ID 同步到自己的后端,用自己的业务服务器去调 RevenueCat 的 API 核对订阅状态。
结构上看是这样:
- App 端:发购买请求,收 CustomerInfo,展示商品;
- RevenueCat 服务端:接收收据,调 Google Play Developer API 做验证,存储订阅状态,下发结果;
- 自己的后端:用
userId去 RevenueCat API 查询subscription状态(比如查询/subscribers/{app_user_id}/entitlements),用于封禁/放行自己的业务资源。
也就是说,这层“验证代理”变成一个可信的中间件,你的后端不需要直接跟 Google 对接,省去了 OAuth 凭据管理的环节,同时业务逻辑完全可以把 RevenueCat 当做数据源来用。
4.3 订阅过期时间与续费状态
在EntitlementInfo里,最有用的几个字段是:
expirationTime:订阅过期时间,如果为空,表示权益没有到期时间,比如按 AI 功能次数或一次买断的权益;isActive:当前是否生效;willRenew:是否会自动续费;productIdentifier:用户购买的具体商品。
实际操作中,“过期时间 + 是否自动续费”是判断用户能否继续使用某种云端资源的关键。比如用户订阅了一个月的会员,到期时间是月底,但他今天在 Google Play 管理页面取消了自动续费。当前周期内他还是会员(isActive = true),但下个月就不一定了(willRenew = false)。很多开发者的错误是看到“还在有效期内”就认为用户一直有权益,等用户下个月继续用的时候才傻眼——服务器早就该通过状态感知用户“即将退订”,做挽留策略或者限制一些超额资源的使用。
用一份简单的判断代码来总结:
fun checkPremium(customerInfo: CustomerInfo): Boolean { val premium = customerInfo.entitlements["premium"] ?: return false if (!premium.isActive) return false // 如果用户已经取消自动续费,但还在有效期内,可以做精细化处理 if (!premium.willRenew && premium.expirationTime != null) { // 业务上可给“即将到期用户”特殊提示,或引导续费 } return true }这里有一个容易踩的坑:有些订阅是“预付费时长包”(prepaid),它们同样有expirationTime,但没有willRenew字段的自动续费概念。对于这类商品,意志上不要拿willRenew=false去判断“用户取消续费”,因为在 Google Play 的模型中,预付时长包本身就是不自动续费的,你应当先把订阅分组区分开(RevenueCat 控制台里可以按产品归属分组)。
5. 常见问题排查与经验避坑
5.1 常见问题速查
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| 购买回调迟迟不返回 | 网络异常或 Google Play 服务未更新 | 检查网络;确认设备有 Google Play 服务;等待 30 秒后重新获取 |
| 点了商品没弹窗 | 商品未在控制台激活 / 未审核通过 | RevenueCat Console 和 Google Play Console 两边确认产品状态 |
| 测试用户购买时提示“商品不可用” | 测试账号未添加到许可测试人员,或商品未在制品版本中 | Google Play Console 里添加测试用户与许可测试账号 |
| 恢复购买后没权限 | 未调 restorePurchases 或调用了 logOut | 登录过的 appUserId 要一致,再调 restorePurchases |
| 订阅在测试环境看到自动续费失败 | 沙箱环境本身不会自动扣款 | 测试订阅续费需要用 Google Play 的测试卡/测试时间调整机制,建议只看状态流转 |
| CustomerInfo 拿不到最新状态 | SDK 缓存导致 | 手动调 getCustomerInfo 或使用 getCustomerInfoFetchPolicy 为 FETCH_CURRENT |
| 买断产品一直显示“待确认” | 未对购买作 acknowledgement | SDK 已自动处理,但自制购买流程需确认 |
5.2 我在接入中踩过的具体坑
第一个坑是queryProductDetailsAsync的商品 ID 必须和 RevenueCat 控制台里配置的产品标识完全一致。我在早期试验的时候音位手滑,在代码里写的是pro_monthly,控制台里却是pro_monthly_v2,结果列表一直为空,排查了整整一下午。
第二个坑是 Android 的onResume里不适合直接去刷新订阅状态。Google Play 支付是拉起一个系统弹窗,会暂时让当前 Activity 进入onPause,如果这时候你去调getCustomerInfo,很可能会在弹窗还没关闭时拿到一个旧的缓存状态。正确做法是用addUpdatedCustomerInfoListener监听,或者等purchase的回调返回后再刷新。
第三个坑是权益判断要放在后端,不要只信客户端。purchases-android虽然能正确返回isActive,但客户端滞后于服务端是很正常的事。比如用户在 Google Play 网页端申请退款,服务端的 webhook 可能会在下一秒就把订阅状态改成失效,而客户端该用户如果长时间不打开 App,他还是能靠本地缓存的CustomerInfo访问你的资源。真正的防线是:你的后端服务器在处理 API 请求时,拿userId去 RevenueCat API 查一次实时状态,再做业务放行。这样客户端更像是一层有缓存的UI,而业务逻辑的安全性由服务端兜底。
第四个坑是副屏设备和平板。部分国产安卓平板阉割了 Google Play 服务或者商店应用,导致BillingClient无法初始化,purchases-android虽然会抛错告诉你市场不可用,但你最好在启动时做一个环境判断,至少弹出一个“当前设备不支持在应用内购买”的提示,而不是让用户一脸懵地点击付费按钮没反应。
5.3 如何设计一套可观测的订阅状态
最后分享一个我在生产环境用的技巧:不要在客户端的请求链路里直接依赖CustomerInfo来决定所有业务权限,尤其是那些和资源强相关的操作。我会把 CustomerInfo 的 payload 脱敏后上报到自己的数据仓库里,存app_user_id、product_id、is_active、expiration_time这样一个宽表,然后定时任务从 RevenueCat API 同步全量订阅状态来做对账。
这套机制帮我发现过一次真实的事故:某个老版本 App 里,客户端用本地缓存判断是否解锁高级功能,但缓存没做失效时间,用户退了订阅后一年多还能继续白嫖高级服务。换成服务端实时查询后,这个问题消失得干干净净。
6. 一点个人总结
做了这么多年的 Android 付费功能,最深的一个体会是:应用内购和订阅的难点不在“拉起支付”,而在支付之后那一大堆状态的同步与信任边界。purchases-android的价值不是帮你省掉所有工作,而是替你处理了最容易出错的 Google Play 收据验证和订阅状态映射,让你可以把精力放在真正的业务权益设计上。
如果你正在规划一个订阅制的 Android App,我建议别一上来就怼 BillingClient 写了三百行“自研收据验证”,先认真过一遍purchases-android的初始化、商品配置、权益判断三步,再用 RevenueCat 自带的服务器验证兜底。跑通一个最小可用的订阅流程,通常比从零搭一座轮子要稳妥得多。
另外,调试时强烈建议在 RevenueCat Console 打开 “Sandbox Testing” 模式,并用测试账号做购买。Google Play 沙箱环境里,订阅的到期时间可以手动切成几分钟,方便你快速验证“订阅过期后是否还能继续用高级服务”这类场景。实测下来,这套组合拳能覆盖大多数订阅业务的核心流程,至少目前我还没遇到必须“返回去手写 BillingClient”才能搞定的需求。
本文还有配套的精品资源,点击获取