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,然后等待商店查询。网络状况较差时,这会暂时锁回玩家已经购买的角色。更稳妥的流程是:
- 立即使用上一次确认过的本地状态。
- 异步查询商店所有权。
- 查询成功后合并结果并更新状态。
- 查询失败时保留原有缓存,稍后重试。
本地状态可以改善离线体验,但也容易被修改。混淆、加密或签名校验只能增加篡改成本,不能让客户端数据成为可信凭据。
购买成功后的处理
购买流程不能只依赖“购买按钮返回成功”的那一刻。支付回调可能因程序退出、网络中断或系统切换而延迟。每次重新连接商店后,都应能根据未完成交易或已有购买记录重新建立权益。
可以把业务逻辑集中到一个幂等方法中:
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 缓存已确认的权益。启动后再异步恢复购买。等到防作弊、退款管理或跨设备同步成为实际需求时,再增加账号系统和服务端验证。
备注:内容仅供参考。
