PAYATHON 2026

react-native-iap 订阅支付不稳定,如何正确实现?

支付小周

结论

react-native-iap 能够稳定处理订阅支付,但购买流程不能被当成一次普通的 await requestSubscription() 请求。App Store 和 Google Play 会通过异步事件返回购买结果。在此期间,应用可能关闭、进入后台,也可能在支付完成后断网。因此,整个流程必须能够恢复和重复执行,并保证幂等。

当前实现不稳定,问题通常不在收据验证本身,而在于购买流程没有完整闭环:

  • 商店连接尚未建立,应用就发起了购买。
  • 购买监听器注册过晚,或被 React 组件重复注册。
  • 用户连续点击,造成多个购买请求重叠。
  • Android 没有使用当前商品对应的 offerToken。
  • 收据验证成功后,没有正确调用 finishTransaction()。
  • 应用没有处理上次启动遗留的未完成交易。
  • 应用把 requestSubscription() 的 Promise 当成了最终购买结果。
  • 商店后台配置、测试账号或商品状态不完整。

如果团队愿意自行维护服务端验证、交易状态和不同商店之间的差异,可以继续使用 react-native-iap。如果想减少这部分维护工作,可以考虑 RevenueCat。不过,RevenueCat 无法解决商品配置错误、测试账号异常或原生支付弹窗打不开等问题。

正确理解购买流程

完整的订阅流程如下:

  1. App 启动后连接商店。
  2. 在用户点击购买前,注册购买成功和失败监听。
  3. 从商店读取商品,不要只依赖本地写死的商品信息。
  4. 用户点击后只发起一次购买请求,同时锁定按钮。
  5. 监听器收到交易后,将凭证发送给后端。
  6. 后端向 Apple 或 Google 验证凭证,并幂等地写入订阅权益。
  7. 后端确认成功后,客户端调用 finishTransaction()。
  8. App 每次启动时,都要继续处理可能遗留的未完成交易。
  9. 订阅权益以后端状态为准,不能只看前端的某一次购买回调。

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。

按以下顺序检查:

  1. App 的 Bundle ID 是否与 App Store Connect 中的应用一致。
  2. Xcode Target 是否已启用 In-App Purchase capability。
  3. 订阅商品是否属于正确的 subscription group。
  4. 商品 ID 是否与代码完全一致,包括大小写。
  5. 付费应用协议、税务和银行信息是否处于可用状态。具体要求可能随 App Store Connect 状态变化。
  6. 是否使用真实设备测试商店 Sandbox。如果使用 Xcode StoreKit Configuration,需要明确它属于本地 StoreKit 测试,不要与 App Store Sandbox 混为一谈。
  7. 测试设备是否登录了 Sandbox 测试账号。不要把测试账号当成普通 Apple Account 使用。
  8. 新建或修改的商品配置是否已经同步。App Store Connect 的变更有时不能立即反映在测试环境中。
  9. getSubscriptions() 能否返回目标商品。如果查不到商品,应先解决配置问题,此时调用 requestSubscription() 通常也不会成功。
  10. 测试账号是否存在同组订阅、未完成交易、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。

备注:内容仅供参考。