PAYATHON 2026

libGDX 中使用 gdx-pay 管理可购买解锁角色

支付小周

结论

永久解锁角色应设置为应用商店的非消耗型商品,购买记录由 Google Play、App Store 等商店保存。libGDX 项目可以用 gdx-pay 统一购买流程,不必仅为内购分别编写 Android 和 iOS 版本。不过,商店后台配置、签名、测试账号和部分平台特有行为仍要分开处理。

本地 Preferences 可以缓存“当前可用角色”的状态,但不能作为购买记录的唯一依据:

  • 正常启动时先读取本地缓存,让已解锁角色可以离线使用。
  • 商店服务可用后,查询或恢复购买,再更新本地缓存。
  • 游戏卸载后,本地 Preferences 会被删除。重新安装后,首次恢复通常需要连接商店。
  • 如果需要更强的防作弊能力、跨平台同步或账号级权益,应增加服务端收据验证和账号系统。
  • 换用 SQLite 不能解决购买可信度问题,因为它和 Preferences 一样,都是本地数据。

商品类型应选择非消耗型

永久解锁角色不应使用消耗型商品。消耗型商品通常用于金币、体力和一次性道具,消费后可以再次购买。永久解锁的角色则应由商店持续记录其所有权。

可以为每个角色创建独立 SKU,例如:

character.knight
character.mage
character.archer

也可以销售一个角色包:

character.pack.all

如何拆分取决于定价和运营需求。商品上线后不宜随意修改 SKU,因为恢复购买依赖稳定的商品标识。

是否使用 gdx-pay

如果游戏的购买模式比较常规,使用 gdx-pay 通常更合适。它将支付服务安装、购买发起、结果监听和购买恢复等操作抽象到公共代码中,可以减少 Android 与 iOS 两套业务逻辑。

不过,gdx-pay 无法消除所有平台差异。以下工作仍要分别完成:

  • 在 Google Play Console 和 App Store Connect 中创建商品。
  • 配置应用签名、包名和 Bundle ID。
  • 设置测试账号和沙盒环境。
  • 准备平台要求的支付说明、恢复入口和审核材料。
  • 处理底层 Google Play Billing 与 StoreKit 的兼容性。

是否采用 gdx-pay,还要确认项目使用的版本能否支持目标平台及商店要求的计费接口。相关 API 可能随版本变化,应以项目实际依赖版本提供的接口为准。如果只发布到一个平台,或者需要使用很特殊的平台功能,直接封装原生计费 API 可能更省事。

推荐的状态设计

状态最好分成三层,不要只维护一个 unlocked=true。

游戏规则层

记录角色是否通过游戏进度解锁:

progressUnlocked

商店权益层

记录商店是否确认玩家拥有对应商品:

purchaseOwned

最终可用状态

角色能否使用,应由游戏规则统一计算:

boolean isCharacterUnlocked(CharacterId id) {
    return isDefaultCharacter(id)
        || progressStore.isUnlocked(id)
        || entitlementStore.isOwned(productIdFor(id));
}

这种设计可以同时支持免费角色、任务解锁、付费解锁和活动赠送,也不会把不同来源都塞进同一个布尔值。

本地缓存可以继续使用 Preferences

如果只需保存少量布尔值和商品 ID,没有必要引入数据库。Preferences 足以保存本地权益缓存:

public final class EntitlementStore {
    private static final String PREFIX = "owned.";
    private final Preferences preferences;

    public EntitlementStore() {
        preferences = Gdx.app.getPreferences("entitlements");
    }

    public boolean isOwned(String productId) {
        return preferences.getBoolean(PREFIX + productId, false);
    }

    public void setOwned(String productId, boolean owned) {
        preferences.putBoolean(PREFIX + productId, owned);
        preferences.flush();
    }
}

不要在启动时把所有商品先写成 false,然后等待商店查询。网络状况较差时,这会暂时锁回玩家已经购买的角色。更稳妥的流程是:

  1. 立即使用上一次确认过的本地状态。
  2. 异步查询商店所有权。
  3. 查询成功后合并结果并更新状态。
  4. 查询失败时保留原有缓存,稍后重试。

本地状态可以改善离线体验,但也容易被修改。混淆、加密或签名校验只能增加篡改成本,不能让客户端数据成为可信凭据。

购买成功后的处理

购买流程不能只依赖“购买按钮返回成功”的那一刻。支付回调可能因程序退出、网络中断或系统切换而延迟。每次重新连接商店后,都应能根据未完成交易或已有购买记录重新建立权益。

可以把业务逻辑集中到一个幂等方法中:

public void grantEntitlement(String productId) {
    if (!entitlementStore.isOwned(productId)) {
        entitlementStore.setOwned(productId, true);
    }

    refreshCharacterAvailability();
}

幂等意味着同一笔购买即使触发多次回调,结果也只是“角色已拥有”,不会重复发放错误的奖励。这也是永久角色更适合非消耗型商品的原因。

下面是一个不依赖具体 gdx-pay 版本的流程示意。方法名需要替换为项目所用版本的实际 API:

void onPurchaseSucceeded(String productId, String receiptOrToken) {
    if (!isKnownCharacterProduct(productId)) {
        return;
    }

    // 如有服务端,在这里提交 receipt/token 验证。
    grantEntitlement(productId);
}

void onPurchaseRestoreSucceeded(Collection<String> ownedProductIds) {
    for (String productId : ownedProductIds) {
        if (isKnownCharacterProduct(productId)) {
            grantEntitlement(productId);
        }
    }
}

void onStoreUnavailable(Throwable error) {
    // 不清除已经缓存的权益,继续允许离线使用。
    scheduleRetry();
}

只有平台确认购买成功,或者服务端验证成功后,才能写入付费权益。用户点击购买按钮、进入支付页面或收到“处理中”状态,都不能视为已经拥有商品。

卸载重装后如何恢复

卸载应用通常会清除 Preferences、SQLite 数据库和其他应用私有文件。因此,只保存在本地的解锁记录都会丢失。

重新安装后,应通过商店的“查询现有购买”或“恢复购买”功能找回非消耗型商品记录。最好同时提供两种恢复方式:

  • 游戏启动或商店连接成功后自动查询。
  • 在设置或商店页面提供明确的“恢复购买”按钮。

启动流程可以设计为:

读取本地权益缓存
        ↓
进入游戏,不阻塞主界面
        ↓
异步连接应用商店
        ↓
查询已有非消耗型购买
        ↓
验证并合并权益
        ↓
保存本地缓存并刷新角色列表

首次安装或卸载重装后,本地没有缓存。如果设备离线,应用通常无法从商店重新获取购买记录,也就暂时无法确认角色所有权。不要依赖商店客户端可能保留的缓存,因为其行为会因平台、系统版本和账号状态而异。

此时可以显示:

暂时无法连接商店。联网后可自动恢复,或在设置中点击“恢复购买”。

玩家并不需要每次启动都联网。购买记录成功恢复并重新写入本地后,后续离线启动可以直接使用缓存中的角色权益。

是否每次启动都重新验证

可以在启动后异步同步,但不能因此阻塞游戏,也不应在同步失败时立即撤销本地权益。

客户端游戏可以采用以下策略:

  • 启动时读取本地缓存。
  • 商店连接成功后查询购买。
  • 应用从后台返回时,根据需要再次同步。
  • 玩家进入商店页面时再次同步。
  • 玩家主动点击“恢复购买”时强制同步。
  • 同步失败时保留最近一次成功确认的状态。

如果查询结果中没有某个历史商品,不要急于立即锁定角色。商店短暂故障、账号未登录或查询结果不完整都可能导致误判。如果需要处理退款和撤销购买,最好让服务端获取可靠状态并设置宽限期,而不是因为一次查询失败就删除本地权益。

什么时候需要服务端验证

纯客户端方案适合单机游戏或风险较低的商品:

商店确认购买 → 写入 Preferences → 离线使用

遇到以下情况时,更适合增加服务端:

  • 角色价值较高,作弊会明显影响运营。
  • 游戏包含排行榜、交易或多人竞技。
  • 需要识别退款、撤销和异常订单。
  • 希望在 Android 与 iOS 之间共享权益。
  • 玩家需要用游戏账号在多台设备之间同步。
  • 需要客服补发、封禁或审计购买记录。

典型流程如下:

客户端完成购买
        ↓
把 purchase token 或 receipt 发送给服务端
        ↓
服务端向对应商店验证
        ↓
服务端记录账号与商品权益
        ↓
客户端同步权益并写入本地缓存

服务端不能只相信客户端提交的 productId 和 owned=true。它需要验证商店签发的购买凭据,并防止同一凭据被多个账号重复绑定。

跨平台共享权益还会涉及商店政策和产品设计,不能靠复制本地状态实现。上线前应核对目标商店当时的规则。

Preferences 与 SQLite 的取舍

对于几个或几十个角色,Preferences 更简单,也符合数据规模。SQLite 的优势是处理复杂查询、关联关系和大量结构化数据,而不是提高安全性。

两者都有相同的限制:

  • 卸载后通常会被删除。
  • 在可控设备上可能被读取或修改。
  • 不能替代商店购买记录。
  • 无法单独证明一笔交易真实存在。

因此,没有必要为了购买功能专门引入 SQLite。可靠的数据来源应该是应用商店,或者保存了商店验证结果的服务端。Preferences 只负责缓存和离线体验。

实施时容易忽略的问题

  • 永久角色必须配置为非消耗型商品,购买后不能将其消费。
  • 商品 SKU 应保持稳定,并在两个平台建立明确映射。
  • 恢复购买必须是幂等操作。
  • 商店连接失败时不能清空本地已确认的权益。
  • 购买处于取消、待处理或失败状态时,不能提前解锁。
  • 应提供可见的“恢复购买”入口,尤其是 iOS 版本。
  • 必须使用商店沙盒或测试轨道验证购买、取消、重装和换设备流程。
  • 测试需要覆盖购买回调到达前应用被关闭的情况。
  • 如果游戏进度解锁和付费解锁并存,应分别保存解锁来源。
  • 本地时间不能作为判断购买有效性的可信依据。

对于大多数 libGDX 单机游戏,一套实用的方案是使用 gdx-pay 对接平台商店,将角色配置为非消耗型商品,以商店查询结果为购买依据,并用 Preferences 缓存已确认的权益。启动后再异步恢复购买。等到防作弊、退款管理或跨设备同步成为实际需求时,再增加账号系统和服务端验证。

备注:内容仅供参考。