PAYATHON 2026

iOS React Native 中 RevenueCat 无法触发支付

支付小周

结论

问题更可能出在购买流程发生异常后,错误被代码静默吞掉,而不是按钮本身没有响应。

当前实现有以下问题:

  • Purchases.getOfferings() 写在 try...catch 外。审核环境中的请求一旦失败,函数会直接中断,界面也不会显示任何提示。
  • 代码没有检查 offerings.current 和 availablePackages 是否存在。
  • catch 没有记录错误,因此无法判断问题来自商品配置、网络、StoreKit,还是 RevenueCat 配置。
  • 即使购买失败,代码仍会执行 this.setState({isPremium: true}),导致用户被错误标记为 Premium。
  • customerInfo, productIdentifier = ... 使用的是 JavaScript 逗号表达式,并非解构赋值,无法正确取得购买结果。
  • 如果 AsyncStorage 中缺少用户数据,或者数据结构不符合预期,代码也会在发起购买前报错。

建议先修正代码,加入用户可见的错误提示和诊断日志,再检查 App Store Connect、RevenueCat Offering,以及审核环境使用的账号和网络条件。

修正购买代码

下面的写法处理了用户数据、Offering、Package 和购买请求中可能出现的异常。具体返回字段仍应以当前使用的 react-native-purchases 版本类型定义为准。

async buyProduct() {
  if (this.state.isLoading) {
    return;
  }

  this.setState({ isLoading: true });

  try {
    const userJson = await AsyncStorage.getItem('user');
    const user = userJson ? JSON.parse(userJson) : null;
    const email = user?.user?.email;

    if (email) {
      await Purchases.setAttributes({ email });
    }

    const offerings = await Purchases.getOfferings();
    const currentOffering = offerings.current;

    if (!currentOffering) {
      throw new Error('No current RevenueCat offering is available.');
    }

    const selectedPackage = currentOffering.availablePackages?.[0];

    if (!selectedPackage) {
      throw new Error('The current offering contains no available packages.');
    }

    const purchaseResult = await Purchases.purchasePackage(selectedPackage);
    const customerInfo = purchaseResult.customerInfo;

    const isPremium =
      Object.keys(customerInfo.entitlements.active).length > 0;

    this.setState({ isPremium });

    if (!isPremium) {
      Alert.alert(
        'Purchase not activated',
        'The purchase completed, but no active entitlement was found.'
      );
    }
  } catch (error) {
    console.error('RevenueCat purchase failed:', {
      message: error?.message,
      code: error?.code,
      userCancelled: error?.userCancelled,
      underlyingErrorMessage: error?.underlyingErrorMessage,
    });

    this.setState({ isPremium: false });

    if (!error?.userCancelled) {
      Alert.alert(
        'Unable to complete purchase',
        error?.message || 'Please try again later.'
      );
    }
  } finally {
    this.setState({ isLoading: false });
  }
}

请求期间还应禁用按钮,并显示明确的加载状态,避免审核人员点击后只看到静止的页面:

<View style={{ width: '90%', marginTop: 20 }}>
  <TouchableOpacity
    style={[
      styles.button,
      {
        alignSelf: 'center',
        backgroundColor: '#eeeeee',
        padding: 10,
        width: '100%',
        borderRadius: 20,
        marginBottom: 10,
        opacity: this.state.isLoading ? 0.6 : 1,
      },
    ]}
    disabled={this.state.isLoading}
    onPress={this.buyProduct}
  >
    <Text
      style={{
        color: '#333',
        textAlign: 'center',
        fontWeight: 'bold',
      }}
    >
      {this.state.isLoading
        ? 'Loading…'
        : global.I18n.t('payNow')}
    </Text>
  </TouchableOpacity>
</View>

如果使用 class component,还要确认 buyProduct 已正确绑定。例如:

this.buyProduct = this.buyProduct.bind(this);

也可以直接将它定义为箭头函数:

buyProduct = async () => {
  // ...
};

不要依赖第一个 Package

使用 availablePackages[0],会让购买逻辑依赖数组顺序。更稳妥的做法是根据 RevenueCat Package 类型或产品标识选择目标套餐:

const selectedPackage =
  offerings.current?.monthly ||
  offerings.current?.availablePackages?.find(
    item => item.product.identifier === 'your_product_identifier'
  );

这些字段是否存在,取决于 RevenueCat 后台的 Package 配置。选中套餐后仍需检查空值,不能直接将结果传给 purchasePackage()。

检查 App Store Connect 配置

本地或 TestFlight 可以购买,并不表示 App Review 使用的沙盒环境一定能加载同一商品。需要逐项确认:

  1. Bundle ID、RevenueCat iOS API Key 和 App Store Connect 中的应用对应同一个应用。
  2. RevenueCat Product 使用的 product identifier 与 App Store Connect 完全一致。
  3. Product 已加入正确的 Offering,而且该 Offering 已设为 Current。
  4. Offering 中确实有可购买的 Package。
  5. 订阅商品处于可提交或可审核的有效状态,并与当前 App 版本一起提交审核。实际可选状态取决于 App Store Connect 当前流程。
  6. 付费应用协议、税务信息和银行信息没有待处理问题。
  7. 商品价格、销售地区和本地化信息均已配置完整。
  8. 审核人员所在的 storefront 可以获取该商品。
  9. RevenueCat entitlement 已正确关联到对应 Product。

还要在提交页面确认,相关 In-App Purchase 已附加到当前版本。首次提交新订阅时尤其容易漏掉这一步。

排除账号和后端条件

审核团队使用的账号、网络和设备状态通常与开发者不同。如果购买页面依赖自己的登录系统,还需要测试以下情况:

  • 新注册且从未购买过的账号;
  • 用户资料不完整,或者 AsyncStorage 中没有缓存数据;
  • IPv6 网络、较慢的网络或请求超时;
  • 后端是否限制地区、IP、账号角色或测试账号;
  • iPad 布局中是否存在透明视图、Loading 遮罩或 Modal 拦截触摸;
  • 隐私同意、登录状态或远程配置是否阻止 Offering 加载。

重点检查 isLoading 对应的遮罩层。如果请求失败后没有可靠地将它恢复为 false,不可见或半透明的全屏视图可能会一直拦截后续点击。

增加诊断日志

初始化 RevenueCat 时可以启用详细日志,但不要记录密码、完整收据或其他敏感信息:

import Purchases, { LOG_LEVEL } from 'react-native-purchases';

Purchases.setLogLevel(LOG_LEVEL.DEBUG);

同时记录以下非敏感状态:

const offerings = await Purchases.getOfferings();

console.log('RevenueCat offerings diagnostics:', {
  hasCurrentOffering: Boolean(offerings.current),
  packageCount: offerings.current?.availablePackages?.length ?? 0,
  packageIdentifiers:
    offerings.current?.availablePackages?.map(
      item => item.identifier
    ) ?? [],
});

开发者可能无法直接读取生产版本中的日志,因此可以把购买阶段和错误码发送到现有的崩溃报告或遥测平台,但必须遵守隐私政策。建议区分这些阶段:

purchase_button_pressed
customer_attributes_updated
offerings_loaded
package_selected
purchase_started
purchase_completed
purchase_failed

这些记录可以帮助确认审核人员的点击是否到达 JavaScript,以及购买流程停在哪一步。

改进订阅状态判断

如果应用只通过某个特定 entitlement 授予权限,最好直接检查该标识,不要把任意 active entitlement 都视为 Premium:

async checkSubscriptionStatus() {
  try {
    const customerInfo = await Purchases.getCustomerInfo();
    const isPremium = Boolean(
      customerInfo.entitlements.active['premium']
    );

    this.setState({ isPremium });
    return isPremium;
  } catch (error) {
    console.error('Error fetching customer info:', error);
    this.setState({ isPremium: false });
    return false;
  }
}

将 'premium' 替换为 RevenueCat 后台实际配置的 entitlement identifier。

重新提交审核时提供的信息

修复后,可以在 App Review Notes 中说明:

  • 打开付费墙的具体路径;
  • 可供审核使用的账号和登录步骤;
  • 点击按钮后应出现系统购买弹窗;
  • 用于测试的订阅商品名称;
  • 购买是否依赖网络;
  • 已修复购买异常不显示提示的问题;
  • 必要时附上真机录屏。

如果仍然被拒,可以请审核团队提供点击时间、截图或录屏,并确认当时是否出现过加载状态或错误提示。完成上述修改后,即使 StoreKit 或 Offering 在审核环境中失败,页面也会显示错误。诊断记录则能帮助区分两种情况:按钮事件没有触发,或者购买请求已经触发但随后失败。

备注:内容仅供参考。