react-native-iap 订阅支付不稳定,如何正确实现?
结论
react-native-iap 能够稳定处理订阅支付,但购买流程不能被当成一次普通的 await requestSubscription() 请求。App Store 和 Google Play 会通过异步事件返回购买结果。在此期间,应用可能关闭、进入后台,也可能在支付完成后断网。因此,整个流程必须能够恢复和重复执行,并保证幂等。
当前实现不稳定,问题通常不在收据验证本身,而在于购买流程没有完整闭环:
- 商店连接尚未建立,应用就发起了购买。
- 购买监听器注册过晚,或被 React 组件重复注册。
- 用户连续点击,造成多个购买请求重叠。
- Android 没有使用当前商品对应的
offerToken。 - 收据验证成功后,没有正确调用
finishTransaction()。 - 应用没有处理上次启动遗留的未完成交易。
- 应用把
requestSubscription()的 Promise 当成了最终购买结果。 - 商店后台配置、测试账号或商品状态不完整。
如果团队愿意自行维护服务端验证、交易状态和不同商店之间的差异,可以继续使用 react-native-iap。如果想减少这部分维护工作,可以考虑 RevenueCat。不过,RevenueCat 无法解决商品配置错误、测试账号异常或原生支付弹窗打不开等问题。
正确理解购买流程
完整的订阅流程如下:
- App 启动后连接商店。
- 在用户点击购买前,注册购买成功和失败监听。
- 从商店读取商品,不要只依赖本地写死的商品信息。
- 用户点击后只发起一次购买请求,同时锁定按钮。
- 监听器收到交易后,将凭证发送给后端。
- 后端向 Apple 或 Google 验证凭证,并幂等地写入订阅权益。
- 后端确认成功后,客户端调用
finishTransaction()。 - App 每次启动时,都要继续处理可能遗留的未完成交易。
- 订阅权益以后端状态为准,不能只看前端的某一次购买回调。
requestSubscription() 只表示应用尝试启动商店的购买流程。不要根据它的返回值直接开通权益,也不要在调用结束后立即判断支付成功或失败。
建议的实现结构
下面沿用问题中的 useIAP 风格。不同版本的 react-native-iap 对 requestSubscription 参数的定义可能不同,应以项目当前安装版本的 TypeScript 类型和对应版本文档为准,不要混用其他版本的示例。
import React from 'react';
import {Alert, Button} from 'react-native';
import {useIAP} from 'react-native-iap';
export function SubscribeButton() {
const {
connected,
subscriptions,
getSubscriptions,
requestSubscription,
currentPurchase,
currentPurchaseError,
finishTransaction,
} = useIAP();
const [isPurchasing, setIsPurchasing] = React.useState(false);
const processingPurchases = React.useRef(new Set<string>());
React.useEffect(() => {
if (!connected) {
return;
}
getSubscriptions({
skus: ['subscription_monthly'],
}).catch(error => {
console.warn('Failed to load subscriptions', error);
});
}, [connected, getSubscriptions]);
React.useEffect(() => {
if (!currentPurchase) {
return;
}
const processPurchase = async () => {
const purchaseKey =
currentPurchase.transactionId ??
currentPurchase.purchaseToken ??
currentPurchase.productId;
if (processingPurchases.current.has(purchaseKey)) {
return;
}
processingPurchases.current.add(purchaseKey);
try {
const result = await verifyPurchaseOnBackend(currentPurchase);
if (!result.valid) {
throw new Error('Purchase verification failed');
}
/*
* 只有后端已经验证并持久化交易后,才结束商店交易。
* 对订阅不要使用 consumable: true。
*/
await finishTransaction({
purchase: currentPurchase,
isConsumable: false,
});
setIsPurchasing(false);
Alert.alert('订阅成功');
} catch (error) {
/*
* 验证失败或网络异常时不要贸然 finishTransaction。
* 保留交易,之后启动 App 时可再次处理。
*/
console.warn('Failed to process purchase', error);
setIsPurchasing(false);
Alert.alert('正在确认付款', '付款结果暂未确认,请稍后重试。');
} finally {
processingPurchases.current.delete(purchaseKey);
}
};
void processPurchase();
}, [currentPurchase, finishTransaction]);
React.useEffect(() => {
if (!currentPurchaseError) {
return;
}
setIsPurchasing(false);
/*
* 应根据错误码区分用户取消、已有订阅、网络错误、
* 商品不可用和开发配置错误,而不是统一提示“支付失败”。
*/
console.warn('Purchase error', currentPurchaseError);
}, [currentPurchaseError]);
const subscribe = async () => {
if (!connected || isPurchasing) {
return;
}
const subscription = subscriptions.find(
item => item.productId === 'subscription_monthly',
);
if (!subscription) {
Alert.alert('商品暂不可用', '请稍后重试。');
return;
}
setIsPurchasing(true);
try {
const offerToken = selectAndroidOfferToken(subscription);
await requestSubscription({
sku: subscription.productId,
...(offerToken
? {
subscriptionOffers: [
{
sku: subscription.productId,
offerToken,
},
],
}
: {}),
});
} catch (error) {
/*
* 有些启动阶段的错误会直接抛出;最终交易结果仍应由监听器处理。
*/
console.warn('Unable to start subscription', error);
setIsPurchasing(false);
}
};
return (
<Button
title={isPurchasing ? '处理中…' : '订阅'}
disabled={!connected || isPurchasing}
onPress={subscribe}
/>
);
}
async function verifyPurchaseOnBackend(purchase: unknown) {
const response = await fetch('https://example.com/iap/verify', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({purchase}),
});
if (!response.ok) {
throw new Error(`Verification request failed: ${response.status}`);
}
return response.json() as Promise<{valid: boolean}>;
}
function selectAndroidOfferToken(subscription: any): string | undefined {
/*
* 根据业务规则,从 Google Play 返回的 base plan / offer 中选择。
* 不要随意取第一个 token,也不要保存并长期复用旧 token。
*/
return subscription.subscriptionOfferDetails?.[0]?.offerToken;
}
这段代码用于说明流程,不表示所有 react-native-iap 版本都有完全相同的字段名称。如果 TypeScript 报参数错误,应按照当前版本的类型调整调用结构,不要用 as any 绕过类型检查。
监听器应当长期存在
购买监听必须在请求发起前生效。不要等用户点击按钮后才创建监听器,也不要把监听器放在频繁挂载、卸载的弹窗页面里。
可以把 useIAP 放在应用顶层的 Provider、支付服务组件或登录后的根页面。这样即使购买页已经关闭,应用仍能处理交易事件。
React 开发环境中的 Strict Mode 可能让 effect 额外执行。业务逻辑需要使用交易 ID 或 purchase token 去重,不能假定购买回调只会执行一次。
finishTransaction() 的调用时机
finishTransaction() 很容易被用错。
调用顺序应当是:
收到购买事件
→ 提交后端
→ 后端验证 Apple/Google 凭证
→ 后端幂等写入交易和权益
→ 客户端 finishTransaction
不要在后端完成验证前结束交易。否则一旦网络中断,就可能出现商店已经扣款,但服务器没有开通权益的情况。
后端也必须保证幂等。相同的 transactionId、originalTransactionId 或 Google Play purchase token 即使被提交多次,也只能写入一笔交易,同时每次都应返回相同的成功结果。支付系统出现重复回调很正常,不应将其视为异常。
如果后端已经成功记录交易,但客户端在调用 finishTransaction() 前退出,那么下次收到同一笔交易时,后端应再次返回成功,客户端再完成该交易。
Android 订阅的重点
Google Play 订阅通常包含 subscription、base plan 和 offer。发起购买时使用的 offerToken 必须来自本次查询得到的商品详情,并与用户选择的计划对应。
需要检查以下项目:
- App 的
applicationId是否与 Play Console 中的应用一致。 - 测试安装包是否来自 Google Play 测试轨道。直接安装本地 APK,可能无法完整模拟结算环境。
- 测试账号是否已加入许可测试人员名单和对应的测试轨道。
- 订阅商品及 base plan 是否已启用。
- 商品是否在测试账号所在地区销售。
- App 当前的签名和版本是否符合测试轨道配置。
- 用户是否已经拥有该订阅,或正在升级、降级、替换订阅。
- 商品查询返回的 offer 是否符合资格条件。
不要把 offerToken 写死在代码或数据库中。Google Play 返回的可用 offer 可能因账号资格、地区和商品配置而变化。
iOS Sandbox 无法支付时的排查顺序
如果 iOS Sandbox 始终无法完成支付,应先检查配置和测试环境,不要反复修改 React Hook。
按以下顺序检查:
- App 的 Bundle ID 是否与 App Store Connect 中的应用一致。
- Xcode Target 是否已启用 In-App Purchase capability。
- 订阅商品是否属于正确的 subscription group。
- 商品 ID 是否与代码完全一致,包括大小写。
- 付费应用协议、税务和银行信息是否处于可用状态。具体要求可能随 App Store Connect 状态变化。
- 是否使用真实设备测试商店 Sandbox。如果使用 Xcode StoreKit Configuration,需要明确它属于本地 StoreKit 测试,不要与 App Store Sandbox 混为一谈。
- 测试设备是否登录了 Sandbox 测试账号。不要把测试账号当成普通 Apple Account 使用。
- 新建或修改的商品配置是否已经同步。App Store Connect 的变更有时不能立即反映在测试环境中。
getSubscriptions()能否返回目标商品。如果查不到商品,应先解决配置问题,此时调用requestSubscription()通常也不会成功。- 测试账号是否存在同组订阅、未完成交易、Ask to Buy 或其他待处理状态。
建议分别为商店连接、商品查询、购买点击、请求抛错、购买事件、后端验证和 finishTransaction() 记录结构化日志。通过这些日志,可以判断问题出现在支付弹窗打开前、商店处理过程中,还是交易回调之后。
日志中不要上传完整收据、purchase token、账号信息或其他敏感数据。
为什么支付弹窗有时不显示
支付弹窗没有出现,不一定是 react-native-iap 丢失了请求。常见原因有:
- 商店连接尚未就绪。
- 商品查询失败,却使用了本地构造的错误商品对象。
- 商品对当前账号或地区不可售。
- Android 的 base plan 或 offer 不可用。
- 之前的购买流程尚未结束。
- 用户已经拥有该订阅。
- 用户重复点击按钮,导致多个请求同时执行。
- 页面卸载后,监听器随之丢失。
- 原生商店服务、网络或测试账号状态异常。
- 调用参数来自另一个版本的文档。
购买按钮只能在 connected === true 且应用成功查询到商品后启用,并在购买流程结束前保持禁用。
不要只依赖一次客户端回调维护订阅状态
购买成功只表示发生了一笔交易,客户端无法据此永久判断订阅是否有效。续费、退款、撤销、宽限期、账单重试和取消订阅,都可能发生在 App 未运行时。
生产环境应由后端保存订阅状态,并接入 Apple App Store Server Notifications、Google Play Real-time Developer Notifications,或定期查询商店服务器。客户端登录后,再从后端读取当前权益。
恢复购买也不能直接解锁功能。恢复得到的交易仍需提交后端验证,然后根据后端返回的订阅状态更新权益。
是否应该换成 RevenueCat
RevenueCat 可以封装 Apple 和 Google 的收据验证、订阅状态同步、服务器通知,以及一部分跨平台差异。以下情况可以考虑使用:
- 团队不想长期维护两套商店服务端接口。
- 产品需要处理退款、宽限期、账单重试和跨设备恢复。
- 团队希望较快获得订阅分析、用户权益和 webhook。
- 当前问题主要集中在交易状态同步和服务端生命周期管理。
如果问题来自 Bundle ID、商品状态、测试账号、签名、base plan 或 offer 配置,换用 RevenueCat 后仍要修复这些配置。迁移还会涉及 SDK、用户身份映射、历史订阅同步、成本和供应商依赖,需要结合团队的维护能力决定。
可以先把当前流程调整为”顶层监听、单次请求、后端幂等验证、验证后结束交易、启动时恢复未完成交易”,并确认两个商店都能稳定返回商品查询结果。如果团队之后仍不想维护完整的订阅生命周期,再评估是否接入 RevenueCat。
备注:内容仅供参考。
