PAYATHON 2026

android market 程序如何实现试用锁定与付费解锁

支付老李

结论

不要采用“付款后联系开发者,再由人工发送 unlock code”的流程。对于通过 Google Play 分发的应用,应使用 Google Play Billing Library 处理一次性购买和订阅,由服务端验证购买凭据并维护授权状态。应用只负责展示商品、发起购买,以及读取服务端签发的短期离线授权。

还要注意以下几点:

  • 仅在客户端记录首次运行时间,无法实现可靠的试用机制。
  • 用户可以通过修改系统时间、清除应用数据、重新安装或修改 APK,绕过纯本地限制。
  • 平板没有蜂窝网络不影响计费,只要能偶尔连接 Wi-Fi 即可,但首次购买必须联网。
  • 设备长期完全离线时,应用无法持续向 Google Play 确认订阅是否退款、取消或到期,只能使用有期限的离线授权。
  • “一次性购买应用,再按年收取 license”可以实现,但通常更适合直接设计为订阅,用首期价格或优惠表示首次收费,流程也更简单。

推荐的授权架构

较稳妥的流程如下:

  1. 应用使用 Google Play Billing Library 展示商品并发起购买。
  2. 购买成功后,应用将 purchaseToken、商品 ID 和用户标识发送给自己的服务器。
  3. 服务器调用 Google Play Developer API 验证购买状态。
  4. 验证成功后,服务器确认交易,并向应用签发有有效期的授权凭据。
  5. 应用在本地保存授权凭据,离线时检查签名和有效期。
  6. 服务器通过 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 后,应执行以下操作:

  1. 确认请求来自已登录用户。
  2. 使用 Google Play Developer API 查询该 token。
  3. 校验包名、商品 ID、购买状态和有效期限。
  4. 防止同一个 token 绑定到多个业务账户。
  5. 将购买记录关联到用户账户。
  6. 确认或消费交易。
  7. 返回由服务器签发的授权凭据。

一次性非消耗型商品应确认,但不能消费。订阅也要在规定时间内完成确认。确认操作可以放在客户端,但由验证服务器处理通常更可靠,也更容易保证幂等性。

服务端应保存购买 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           保留最近可信状态,限制高风险操作

续订状态的处理

年度订阅不能只按本地计算的“购买日期加一年”来判断。实际状态还可能受以下情况影响:

  • 自动续订已经关闭,但当前付费周期尚未结束;
  • 支付失败并进入宽限期;
  • 账户处于暂停状态;
  • 用户升级或降级订阅方案;
  • 订单被退款或撤销;
  • 用户重新购买订阅;
  • 续订日期因平台规则发生变化。

服务器需要保存经过验证的到期时间和订阅状态,再通过实时通知或定期查询更新授权。用户关闭自动续订后,通常仍可使用到当前已付款周期结束,不应立即锁定。

必须提供的恢复流程

应用至少要提供“恢复购买”或“刷新授权”入口。刷新流程如下:

  1. 调用 queryPurchasesAsync() 查询当前购买。
  2. 将所有相关 token 提交给服务器。
  3. 服务器重新验证并恢复账户权益。
  4. 下载新的离线授权。

用户更换平板、重新安装应用或清除数据后,不应被迫联系开发者索取 unlock code。授权最好绑定业务账户和 Google Play 购买记录,而不是只绑定某台设备的硬件标识。

实施建议

如果没有必须拆分收费的商业原因,可以采用以下方案:

  • 应用免费下载;
  • 通过年度订阅解锁完整功能;
  • 使用订阅 offer 为首个周期设置免费试用或首期价格;
  • 由服务端验证订阅;
  • 为平板签发有期限的离线授权;
  • 在启动、回到前台和创建新任务前检查授权;
  • 授权到期时,仍允许用户保存、导出或完成已经开始的工作。

与“先购买程序,再单独购买 license”相比,这种方案少一笔交易,也能减少用户只完成其中一笔付款、恢复购买失败,以及两种授权状态不一致的问题。人工 unlock code 可以作为无法接入 Google Play 时的企业部署备用方案,但不适合用作普通 Play 用户的主要购买流程。

备注:内容仅供参考。