android market 程序如何实现试用锁定与付费解锁
结论
不要采用“付款后联系开发者,再由人工发送 unlock code”的流程。对于通过 Google Play 分发的应用,应使用 Google Play Billing Library 处理一次性购买和订阅,由服务端验证购买凭据并维护授权状态。应用只负责展示商品、发起购买,以及读取服务端签发的短期离线授权。
还要注意以下几点:
- 仅在客户端记录首次运行时间,无法实现可靠的试用机制。
- 用户可以通过修改系统时间、清除应用数据、重新安装或修改 APK,绕过纯本地限制。
- 平板没有蜂窝网络不影响计费,只要能偶尔连接 Wi-Fi 即可,但首次购买必须联网。
- 设备长期完全离线时,应用无法持续向 Google Play 确认订阅是否退款、取消或到期,只能使用有期限的离线授权。
- “一次性购买应用,再按年收取 license”可以实现,但通常更适合直接设计为订阅,用首期价格或优惠表示首次收费,流程也更简单。
推荐的授权架构
较稳妥的流程如下:
- 应用使用 Google Play Billing Library 展示商品并发起购买。
- 购买成功后,应用将
purchaseToken、商品 ID 和用户标识发送给自己的服务器。 - 服务器调用 Google Play Developer API 验证购买状态。
- 验证成功后,服务器确认交易,并向应用签发有有效期的授权凭据。
- 应用在本地保存授权凭据,离线时检查签名和有效期。
- 服务器通过 Real-time Developer Notifications 或定期查询,处理续订、退款、撤销和宽限期等状态变化。
不要只根据客户端收到的 Purchase 对象永久解锁。客户端环境由用户控制,检查逻辑和本地数据都可能遭到修改。
如果产品完全不使用服务器,也可以通过 Billing Library 查询当前购买,并依靠 Google Play 的本地缓存提供有限的离线体验。不过,这种方案的安全性较低,授权状态也无法及时更新。
商品应该如何设计
Google Play 计费通常包含两类商品。
一次性商品
适合永久解锁某项功能:
- 商品类型为
INAPP。 - 购买后长期有效。
- 非消耗型商品不要调用
consumeAsync()。 - 必须 acknowledge 购买,否则交易可能被自动撤销。
订阅商品
适合年度 license:
- 商品类型为
SUBS。 - 可以配置年度 base plan。
- 可以设置首期优惠、免费试用或介绍价格。
- 续订、宽限期、暂停和退款状态应以服务端验证结果为准。
如果实际收费方式是“支付首年费用,以后每年续费”,通常直接创建一个年度订阅最简单。首次付款金额与后续续费金额不同时,可以通过订阅 offer 或介绍价格设置。
如果必须收取两笔独立费用,例如:
- 一次性软件购买:
product_lifetime_access - 年度服务许可:
license_yearly
则要分别创建一次性商品和订阅。应用可以按以下条件判断最终授权:
unlocked = ownsOneTimeProduct && hasActiveSubscription
这种模式会带来更多失败场景,例如用户只完成一笔购买。购买界面需要清楚说明两项费用,并为“只购买了一项”的状态提供补购和恢复入口。Google Play Billing 不会自动将两种商品合并成一笔不可分割的交易。
客户端接入 Billing Library
依赖版本应以接入时 Google Play 官方文档的要求为准,不要长期写死旧版本:
dependencies {
implementation("com.android.billingclient:billing-ktx:<current-version>")
}
创建客户端并建立连接:
private lateinit var billingClient: BillingClient
fun initializeBilling(context: Context) {
billingClient = BillingClient.newBuilder(context)
.setListener(::onPurchasesUpdated)
.enablePendingPurchases()
.build()
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(
billingResult: BillingResult
) {
if (billingResult.responseCode ==
BillingClient.BillingResponseCode.OK
) {
queryExistingPurchases()
}
}
override fun onBillingServiceDisconnected() {
// 稍后以退避策略重连,不要在这里立即无限循环。
}
})
}
查询当前拥有的一次性商品和订阅:
private fun queryExistingPurchases() {
val productParams = QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.INAPP)
.build()
billingClient.queryPurchasesAsync(productParams) {
billingResult, purchases ->
if (billingResult.responseCode ==
BillingClient.BillingResponseCode.OK
) {
purchases.forEach(::submitPurchaseForVerification)
}
}
val subscriptionParams = QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.SUBS)
.build()
billingClient.queryPurchasesAsync(subscriptionParams) {
billingResult, purchases ->
if (billingResult.responseCode ==
BillingClient.BillingResponseCode.OK
) {
purchases.forEach(::submitPurchaseForVerification)
}
}
}
处理购买回调:
private fun onPurchasesUpdated(
billingResult: BillingResult,
purchases: List<Purchase>?
) {
when (billingResult.responseCode) {
BillingClient.BillingResponseCode.OK -> {
purchases.orEmpty().forEach { purchase ->
if (purchase.purchaseState ==
Purchase.PurchaseState.PURCHASED
) {
submitPurchaseForVerification(purchase)
}
}
}
BillingClient.BillingResponseCode.USER_CANCELED -> {
// 用户取消,保持原授权状态。
}
else -> {
// 显示可重试提示,不要因为临时计费错误立即删除有效授权。
}
}
}
submitPurchaseForVerification() 应将 purchase.purchaseToken、商品 ID 和应用登录用户提交给服务器。客户端不能直接把 purchaseState == PURCHASED 作为永久授权依据。
遇到 PENDING 状态时,不要提前授予权益。交易进入 PURCHASED 状态并通过验证后,才能解锁。
服务端验证与确认购买
服务器收到 purchaseToken 后,应执行以下操作:
- 确认请求来自已登录用户。
- 使用 Google Play Developer API 查询该 token。
- 校验包名、商品 ID、购买状态和有效期限。
- 防止同一个 token 绑定到多个业务账户。
- 将购买记录关联到用户账户。
- 确认或消费交易。
- 返回由服务器签发的授权凭据。
一次性非消耗型商品应确认,但不能消费。订阅也要在规定时间内完成确认。确认操作可以放在客户端,但由验证服务器处理通常更可靠,也更容易保证幂等性。
服务端应保存购买 token,不能只记录一个 isPaid=true。订阅可能发生续订、取消、退款、撤销、暂停或进入宽限期,单个永久布尔值无法准确表示这些状态。
离线平板如何授权
没有蜂窝网络不妨碍使用 Google Play Billing,购买时连接 Wi-Fi 即可。需要处理的是购买后的离线使用。
建议由服务器签发短期授权,例如:
{
"userId": "user-123",
"packageName": "com.example.app",
"entitlements": ["core", "yearly-license"],
"issuedAt": 1789232400,
"expiresAt": 1791824400,
"licenseRevision": 17
}
服务器用私钥签名,应用内只保存公钥。应用离线时需要验证:
- 签名是否正确;
packageName是否匹配;- 授权项目是否符合要求;
- 是否仍在离线有效期内;
- 是否绑定了正确的用户或设备。
私钥绝不能放进 APK。
离线有效期需要兼顾安全性和可用性。可以设置一段有限的离线宽限时间,并在到期前提醒用户联网。具体天数由业务决定。时间设置得越长,用户在退款或订阅到期后继续使用的窗口也越长。
设备长期完全离线时,无法实现强一致的订阅验证。这是系统本身的限制,换用其他 API 也无法解决。此时只能选择:
- 接受较长的离线宽限期;
- 要求设备定期联网;
- 使用企业级离线许可证,同时接受许可证被复制或破解的风险;
- 改为永久买断,减少持续验证的需求。
queryPurchasesAsync() 可以用来恢复 Google Play 账户当前拥有的购买,但不能把 Play 客户端缓存当作永久可靠的离线许可证数据库。
试用期限不要只依赖设备时间
下面的方案只能拦住普通用户,不能作为可靠的授权机制:
首次启动时间 + X 天 < 当前系统时间
用户可以通过以下操作绕过限制:
- 将系统时间调回过去;
- 清除应用数据;
- 卸载后重新安装;
- 恢复旧备份;
- 修改 SharedPreferences 或数据库;
- 修改 APK 并移除检查代码。
如果应用允许用户登录,建议由服务器记录试用开始和结束时间,并按服务器时间判断。客户端可以缓存服务器签发的试用授权,以支持短期离线使用。
如果必须实现纯本地试用,可以同时记录墙上时钟和单调时钟:
data class TrialClock(
val firstWallTimeMillis: Long,
val lastWallTimeMillis: Long,
val lastElapsedRealtimeMillis: Long
)
fun appearsClockRolledBack(
state: TrialClock,
nowWallTimeMillis: Long = System.currentTimeMillis(),
nowElapsedRealtimeMillis: Long = SystemClock.elapsedRealtime()
): Boolean {
val elapsedDelta =
nowElapsedRealtimeMillis - state.lastElapsedRealtimeMillis
val expectedMinimumWallTime =
state.lastWallTimeMillis + elapsedDelta
return nowElapsedRealtimeMillis >= state.lastElapsedRealtimeMillis &&
nowWallTimeMillis + 5 * 60_000L < expectedMinimumWallTime
}
SystemClock.elapsedRealtime() 不受手动修改系统时间的影响,适合检测同一次开机期间的时间倒退,但设备重启后无法单独解决问题。把数据存入加密存储只能提高篡改成本,仍然无法阻止用户清除数据或重新安装应用。
如果 Google Play 的订阅商品支持符合业务要求的免费试用 offer,应优先使用平台管理的试用。这样可以由 Play 统一处理试用资格、开始时间、续订和付款。试用资格和适用范围仍要以当时 Play Console 的配置规则为准。
锁定时机
只在启动时检查授权,会漏掉以下情况:
- 应用持续运行到试用期结束之后;
- 应用从后台恢复时,授权已经到期;
- 订阅在应用运行期间被撤销;
- 用户切换了账户。
比较合适的检查点包括:
- 应用冷启动;
- 应用回到前台;
- 用户开始新的受限操作;
- 收到服务端授权更新后。
可以允许用户完成已经开始的提交,但不再允许创建新任务:
fun accessDecision(
entitlement: Entitlement,
operationAlreadyStarted: Boolean
): AccessDecision {
if (entitlement.isValid) {
return AccessDecision.ALLOW
}
return if (operationAlreadyStarted) {
AccessDecision.ALLOW_FINISH_ONLY
} else {
AccessDecision.REQUIRE_PURCHASE
}
}
网络暂时不可用时,不要立即删除本地授权。系统应区分“已经确认失效”和“暂时无法验证”:
VALID 正常使用
OFFLINE_GRACE 允许使用,并提示稍后联网
EXPIRED 禁止创建新内容,允许保存或导出现有内容
REVOKED 停止付费权益
UNKNOWN 保留最近可信状态,限制高风险操作
续订状态的处理
年度订阅不能只按本地计算的“购买日期加一年”来判断。实际状态还可能受以下情况影响:
- 自动续订已经关闭,但当前付费周期尚未结束;
- 支付失败并进入宽限期;
- 账户处于暂停状态;
- 用户升级或降级订阅方案;
- 订单被退款或撤销;
- 用户重新购买订阅;
- 续订日期因平台规则发生变化。
服务器需要保存经过验证的到期时间和订阅状态,再通过实时通知或定期查询更新授权。用户关闭自动续订后,通常仍可使用到当前已付款周期结束,不应立即锁定。
必须提供的恢复流程
应用至少要提供“恢复购买”或“刷新授权”入口。刷新流程如下:
- 调用
queryPurchasesAsync()查询当前购买。 - 将所有相关 token 提交给服务器。
- 服务器重新验证并恢复账户权益。
- 下载新的离线授权。
用户更换平板、重新安装应用或清除数据后,不应被迫联系开发者索取 unlock code。授权最好绑定业务账户和 Google Play 购买记录,而不是只绑定某台设备的硬件标识。
实施建议
如果没有必须拆分收费的商业原因,可以采用以下方案:
- 应用免费下载;
- 通过年度订阅解锁完整功能;
- 使用订阅 offer 为首个周期设置免费试用或首期价格;
- 由服务端验证订阅;
- 为平板签发有期限的离线授权;
- 在启动、回到前台和创建新任务前检查授权;
- 授权到期时,仍允许用户保存、导出或完成已经开始的工作。
与“先购买程序,再单独购买 license”相比,这种方案少一笔交易,也能减少用户只完成其中一笔付款、恢复购买失败,以及两种授权状态不一致的问题。人工 unlock code 可以作为无法接入 Google Play 时的企业部署备用方案,但不适合用作普通 Play 用户的主要购买流程。
备注:内容仅供参考。