Android 游戏支付后切换后台导致 payment callback 取消
结论
不要把支付是否成功完全依赖于一次 payment callback,也不要在 onStop() 或应用切入后台时主动取消支付请求。
支付流程应设计为可恢复的状态机:发起支付时保存订单信息;收到回调后进行处理;应用重新回到前台时,主动查询尚未完成的购买记录,并向业务服务器核验订单。这样,即使回调因 Activity 重建、进程回收或支付 SDK 页面切换而丢失,系统仍能继续完成发货和确认流程。
“先进入后台卡片页面再返回就能成功”通常只是因为生命周期切换的时序发生了变化,不能作为可靠的解决方案。
可能原因
支付页面通常由系统、应用商店或第三方支付 SDK 的 Activity 承载。游戏进入后台后,可能出现以下情况:
- 游戏 Activity 执行
onPause()、onStop(),业务代码随之取消请求或注销监听。 - 支付回调绑定在旧 Activity 上,而应用返回前台时 Activity 已经重建。
- 游戏引擎失去焦点后暂停主线程,回调未能及时分发。
- 支付 SDK 将页面退出解释为用户取消,但支付平台可能已经生成订单。
- Android 在内存紧张时终止游戏进程,原本保存在内存中的订单状态随之丢失。
launchMode、intent-filter或onNewIntent()配置有误,支付结果被返回到新的 Activity 实例。- SDK 连接在后台断开,应用返回前台后没有重新连接并补查订单。
具体原因取决于项目使用的是 Google Play Billing、厂商商店 SDK,还是支付宝、微信等第三方渠道。应先检查支付 SDK 返回的错误码和 Android 生命周期日志,不能只根据“callback 被取消”就认定订单失败。
处理步骤
1. 发起支付前持久化订单
打开支付页面前,先由服务器创建业务订单,并在本地记录订单号和当前状态。不要只把订单号保存在 Activity 字段或游戏引擎内存中。
建议至少保存:
- 业务订单号
- 商品 ID
- 支付渠道
- 发起时间
- 当前状态,如
CREATED、PAYING、PAID、DELIVERED - 渠道返回的 purchase token 或 transaction ID
即使应用被销毁,也应能根据这些数据恢复查询。
2. 不要在生命周期回调中取消支付
检查 onPause()、onStop()、OnApplicationPause 等逻辑,避免执行类似操作:
override fun onStop() {
super.onStop()
// 不要因为进入后台就把业务订单标记为取消。
// paymentRequest?.cancel()
// orderRepository.markCancelled(orderId)
}
进入后台只表示当前界面不可见,不能证明用户取消了支付,也不能证明支付平台没有扣款。
只有渠道返回明确的取消结果,并且服务器查询也确认订单未支付时,才能把业务订单转为取消状态。
3. 回到前台后主动补查
支付回调只是实时通知,不能作为唯一的事实来源。应用恢复到前台时,应重新连接支付服务,查询当前账号下已经购买但尚未完成业务处理的订单。
下面以 Google Play Billing 为例说明处理思路。不同版本的 Billing Library 方法签名可能不同,应以项目实际依赖的版本为准:
class PaymentManager(
private val billingClient: BillingClient,
private val orderRepository: OrderRepository
) : PurchasesUpdatedListener {
override fun onPurchasesUpdated(
billingResult: BillingResult,
purchases: MutableList<Purchase>?
) {
when (billingResult.responseCode) {
BillingClient.BillingResponseCode.OK -> {
purchases.orEmpty().forEach(::processPurchase)
}
BillingClient.BillingResponseCode.USER_CANCELED -> {
// 这里只表示本次 UI 流程报告取消。
// 若本地存在待确认订单,仍应向服务端或渠道补查。
reconcilePendingOrders()
}
else -> {
// 网络错误、服务断开等情况不应直接判定订单失败。
reconcilePendingOrders()
}
}
}
fun onAppForeground() {
if (!billingClient.isReady) {
connectAndQuery()
} else {
queryExistingPurchases()
}
}
private fun connectAndQuery() {
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(result: BillingResult) {
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
queryExistingPurchases()
}
}
override fun onBillingServiceDisconnected() {
// 记录断开状态,稍后重试。
}
})
}
private fun queryExistingPurchases() {
val params = QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.INAPP)
.build()
billingClient.queryPurchasesAsync(params) { result, purchases ->
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
purchases.forEach(::processPurchase)
}
}
}
private fun processPurchase(purchase: Purchase) {
if (purchase.purchaseState != Purchase.PurchaseState.PURCHASED) {
return
}
// 不要只凭客户端字段发货。
// 将 purchaseToken 和业务订单号发送给业务服务器核验。
orderRepository.verifyWithServer(
purchaseToken = purchase.purchaseToken,
orderId = findOrderId(purchase)
)
}
private fun reconcilePendingOrders() {
orderRepository.queryPendingOrders()
queryExistingPurchases()
}
private fun findOrderId(purchase: Purchase): String {
return orderRepository.findOrderIdByPurchaseToken(purchase.purchaseToken)
}
}
可以通过进程级生命周期监听应用返回前台:
class App : Application(), DefaultLifecycleObserver {
lateinit var paymentManager: PaymentManager
override fun onCreate() {
super.onCreate()
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
}
override fun onStart(owner: LifecycleOwner) {
paymentManager.onAppForeground()
}
}
如果支付 SDK 不支持客户端补查,应调用该渠道提供的服务端订单查询接口。如果渠道既没有补查接口,也没有可靠的服务端通知,就只能按照其 SDK 文档提供的恢复机制处理,不能自行假设取消回调等同于支付失败。
4. 由服务器确认最终支付状态
推荐采用以下流程:
- 客户端请求业务服务器创建订单。
- 客户端携带订单信息拉起支付。
- 客户端收到回调后,把凭证提交给服务器。
- 服务器调用支付渠道接口验证凭证或查询订单。
- 服务器以订单号、purchase token 或 transaction ID 进行幂等处理。
- 确认支付成功后发货,并记录发货状态。
- 客户端重新进入前台时,从服务器同步订单结果。
服务端伪代码如下:
if transaction does not exist:
verify transaction with payment provider
if verification failed:
return PENDING or FAILED
if transaction has not been delivered:
grant product
mark transaction as delivered
return SUCCESS
必须通过数据库唯一约束或事务,保证同一笔支付只发货一次。支付回调和前台补查可能同时到达,不能依赖客户端来避免重复请求。
5. 正确处理确认或消耗
以 Google Play Billing 为例,支付验证和发货完成后,还需要根据商品类型调用 acknowledgePurchase() 或 consumeAsync()。不要在服务端完成验证前确认或消耗,也不要因为收到重复回调而重复发货。
private fun acknowledgeIfNeeded(purchase: Purchase) {
if (purchase.isAcknowledged) {
return
}
val params = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.purchaseToken)
.build()
billingClient.acknowledgePurchase(params) { result ->
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
// 保留待确认状态,稍后重试。
}
}
}
确认和消耗的规则取决于商品类型及支付渠道,不能混用。
检查 Activity 和回调配置
如果支付结果通过 Intent 或链接返回,还需要检查入口 Activity:
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handlePaymentResult(intent)
}
同时确认:
launchMode是否符合支付 SDK 的要求。- 支付回调 Activity 是否已在
AndroidManifest.xml中正确声明。 intent-filter的 scheme、host 和 path 是否完全匹配。- 使用
Activity Result API时,launcher 是否在 Activity 或 Fragment 的创建阶段注册。 - Activity 重建后,支付监听器是否重新注册。
- 游戏引擎恢复时,原生层是否缓存并重新投递尚未消费的支付结果。
不要为了保留回调而盲目设置 android:configChanges 或禁止 Activity 重建。这些做法可能暂时掩盖问题,但无法解决进程被系统终止后产生的数据丢失。
建议增加的日志
至少记录以下信息,以便判断取消发生在哪一层:
orderId
productId
payment provider
Activity instance identifier
onCreate/onStart/onResume/onPause/onStop/onDestroy
payment callback response code
purchase token or transaction ID
server verification result
acknowledge/consume result
日志中不要输出完整的支付凭证、用户隐私信息或密钥。
注意事项
- “取消回调”不一定表示支付失败,必须结合服务端订单状态判断。
- 不要直接在客户端发放高价值商品,因为客户端状态可能被篡改。
- 不要因为超时或应用切入后台就立即创建新订单,否则可能造成重复扣款或重复发货。
- 补查应设置重试和退避策略,避免每次
onResume()都发起高频请求。 - 分别处理
PENDING、PURCHASED、CANCELED、FAILED等状态,不要简单合并为成功和失败两种结果。 - 测试应覆盖 Activity 重建、进程被杀、断网、重复回调、支付过程中切入后台,以及支付完成后立即切入后台等场景。
支付回调用来及时传递结果,持久化和订单查询用来恢复流程,服务端验证则负责判断支付最终是否成功。这样,整个支付流程就不会依赖某一次前后台切换的时序。
备注:内容仅供参考。
