PAYATHON 2026

In-app-billing 的 Play Store 缓存为何很快失效?

支付小周

结论

不要把 Play Store 的购买缓存作为 premium 权限唯一的离线凭据。

In-app Billing v3 的 queryInventory 通常会通过 Google Play 返回本地缓存的购买信息,但 Google 没有承诺缓存的固定有效期,也不保证它在所有离线状态下都可用。如果 Play Store 数据被清理、账号状态发生变化、Google Play 服务异常、缓存刷新失败,或者 Billing Service 暂时不可用,查询都可能失败。

需要注意的是,查询失败不代表用户没有购买。只有查询成功,而且结果明确表明用户未拥有该商品时,才可以考虑撤销本地 premium 状态。断网或服务异常时直接降级,往往正是问题的来源。

为什么离线几小时后会失效

queryInventory 依赖设备上的 Google Play Store 及其 Billing Service。即使购买记录曾经缓存到本地,应用也无法控制缓存何时刷新、失效或被删除。以下情况都可能导致离线查询拿不到有效结果:

  • Play Store 尚未完成初始化,或者 Billing Service 连接失败。
  • 系统或用户清理了 Play Store、Google Play 服务或应用的数据。
  • 当前 Google 账号已切换、退出或需要重新验证。
  • 查询超时,或者服务暂时不可用。
  • 购买记录需要联网同步,而本地数据不足。
  • 应用没有区分”查询异常”、“空结果”和”明确未购买”。

因此,不能假定缓存一定会保留数小时、数天或更久。Billing v3 也没有公开的缓存有效期参数可供应用设置或依赖。

推荐的处理方式

1. 成功购买后保存本地 entitlement

购买完成并通过签名验证后,在本地保存用户已解锁 premium 的状态。应用下次启动时先读取该状态,让已经购买的用户可以离线使用。

private static final String PREFS_NAME = "entitlements";
private static final String KEY_PREMIUM = "premium_enabled";

private void setPremiumEnabled(Context context, boolean enabled) {
    context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
            .edit()
            .putBoolean(KEY_PREMIUM, enabled)
            .apply();
}

private boolean isPremiumEnabled(Context context) {
    return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
            .getBoolean(KEY_PREMIUM, false);
}

应用启动时,可以先根据本地状态开放相应功能,再在后台尝试通过 Google Play 刷新购买信息:

boolean premiumEnabled = isPremiumEnabled(this);
applyPremiumState(premiumEnabled);

refreshPurchasesFromGooglePlay();

2. 区分”未购买”和”无法查询”

查询结果至少要分为三种状态:

  1. 查询成功,并确认用户拥有该商品。
  2. 查询成功,并确认用户未拥有该商品。
  3. 查询失败,当前购买状态未知。

第三种状态不应触发降级:

private void handleInventoryResult(IabResult result, Inventory inventory) {
    if (result.isFailure()) {
        // 临时无法确认,继续沿用最后一次已验证的本地状态。
        applyPremiumState(isPremiumEnabled(this));
        return;
    }

    Purchase purchase = inventory.getPurchase("premium_upgrade");
    boolean ownsPremium = purchase != null
            && verifyDeveloperPayloadOrOtherChecks(purchase);

    setPremiumEnabled(this, ownsPremium);
    applyPremiumState(ownsPremium);
}

这里的 verifyDeveloperPayloadOrOtherChecks 只是示意。实际校验方式要与应用现有的购买流程和安全设计一致,不能在所有场景下都只用 purchase != null 完成安全校验。

3. 只在得到可信结果后更新状态

可以采用”最后一次可信状态”策略:

查询成功且验证通过       -> 保存 premium
查询成功且明确未拥有     -> 根据业务规则撤销 premium
断网、超时、服务不可用   -> 保留原状态,稍后重试

即使查询成功但列表为空,也要谨慎处理。例如,Play Store 刚刚切换账号或本地状态还没有完成同步时,立即永久撤销权限可能误伤已经购买的用户。可以设置宽限期,等设备联网后再次确认。

4. 高价值权益使用服务端验证

SharedPreferences 很容易被 root、备份恢复或应用篡改工具修改,不适合作为高价值权益唯一的安全依据。安全要求较高时,可以:

  • 将购买 token 发送到自己的服务端。
  • 由服务端调用 Google Play Developer API 验证购买。
  • 保存最近一次成功验证的 entitlement 和验证时间。
  • 离线时提供一定的宽限期。
  • 恢复联网后重新验证,并同步退款、取消或撤销状态。

服务端验证可以提高防篡改能力,但仍然要有离线策略。否则一旦网络出现故障,合法用户还是可能无法使用已经购买的功能。

一次性商品和订阅需要区别处理

如果 premium 是永久解锁的一次性非消耗型商品,通常可以长期保存最近一次确认的 entitlement。除非检测到退款、撤销或账号变化,否则不要因为一次临时查询失败就立即降级。

如果 premium 来自订阅,就不能无限期沿用旧状态,因为订阅可能到期、取消或被撤销。可以保存最近一次验证结果,并设置合理的离线宽限期。宽限期多长取决于业务和风险判断,并不是 Billing v3 提供的固定缓存期限。

永久解锁商品也不应被错误地 consume。商品一旦被消费,后续购买查询通常不会再将它作为当前拥有的非消耗型商品返回。

注意事项

  • 不要通过清除 Play Store 数据来模拟普通断网。两种操作对购买缓存的影响不同。
  • 记录 Billing Service 的连接结果、queryInventory 返回码和异常信息,以便判断结果究竟是”没有购买”还是”查询失败”。
  • 校验购买数据及签名,不要只相信本地保存的布尔值。
  • 不要在客户端中硬编码可用于伪造验证结果的敏感服务端凭据。
  • Billing v3 已经是旧接口。仍在维护的应用应计划迁移到当前受支持的 Google Play Billing Library,但迁移不能替代 entitlement 持久化和离线容错设计。

Play Store 缓存可以减少查询成本,但它不是具有固定有效期保证的离线授权机制。可靠的做法是保存最近一次可信的购买状态,并将临时查询失败视为”未知”,而不是”未购买”。

备注:内容仅供参考。