PAYATHON 2026

StoreKit 2 在 WatchOS 获取订阅状态为空

支付阿杰

结论

问题通常不在商品查询或购买代码,而在 Xcode 13 的 StoreKit 本地测试环境与 watchOS 8 上 StoreKit 2 的兼容性。交易虽然会显示在 Xcode 的 StoreKit Transactions 中,但 Watch app 再次启动后,Product.SubscriptionInfo.status 可能无法恢复订阅组状态,因此返回空数组。

建议先升级 Xcode 和 watchOS,再重新测试。如果必须兼容 watchOS 8,不要只用 sub.status 判断用户是否拥有订阅权益。可以通过 Transaction.currentEntitlements 恢复当前权益,同时监听 Transaction.updates。

为什么购买成功,status 仍然为空

下面这些对象的含义不同:

  • Product.products(for:) 能返回商品,说明 StoreKit 配置和商品 ID 基本可用。
  • Xcode 的 StoreKit Transactions 中能看到交易,说明本地购买流程已经创建交易。
  • product.subscription 表示该商品包含订阅信息。
  • product.subscription.status 查询的是商品所属订阅组的状态,并非直接读取 Xcode 界面中的交易记录。

在早期 watchOS 8 与 Xcode 13 的本地 StoreKit 测试组合中,订阅交易和订阅组状态可能无法正确恢复。结果就是购买已经成功,交易也能看到,但重新启动后 status 仍为空。相同代码在 iOS 15 上运行正常,也说明问题更可能来自 watchOS 的本地测试环境,而非 Swift 并发调用方式。

空数组只表示当前 StoreKit 环境没有返回订阅状态,不能据此断定用户从未购买或订阅已经失效。

建议的处理方式

1. 先排除配置问题

检查以下内容:

  • StoreKit Configuration 文件是否关联到实际运行的 Watch App Scheme,而不只是 iOS companion app 的 Scheme。
  • 商品类型是否确实为 Auto-Renewable Subscription。
  • 相关订阅商品是否归入正确的 Subscription Group。
  • 代码中的 product ID 是否与 StoreKit Configuration 完全一致。
  • 测试前是否清除了旧交易,或重置了 StoreKit 测试会话,以免旧配置留下的状态造成干扰。
  • Watch app 的 bundle、签名和 In-App Purchase 能力配置是否与实际目标一致。

如果商品可以正常查询和购买,配置大概率没有根本问题。不过,Scheme 仍然值得检查,因为 iOS 与 Watch target 可以使用不同的运行配置。

2. 升级测试环境

Xcode 13 和初始版本的 watchOS 8 已经比较旧。应改用支持项目部署目标的较新 Xcode,并在较新的 watchOS 模拟器或真机上重新验证。

仅凭空数组无法确定问题最早在哪个具体版本中被彻底修复,因此不要依赖未经验证的某个小版本号。更稳妥的验证顺序是:

  1. 在最新可用的 Xcode StoreKit 本地环境中测试。
  2. 在 Watch 真机的 Sandbox 环境中测试。
  3. 通过 TestFlight 验证真实 App Store 环境。

如果问题只出现在 Xcode simulated Store,而 Sandbox 或 TestFlight 表现正常,便可以确认这是本地测试环境造成的差异。

使用当前权益判断订阅是否有效

如果业务只关心用户当前是否拥有订阅权益,Transaction.currentEntitlements 通常比 subscription.status 更直接:

import StoreKit

func activeSubscriptionProductIDs() async -> Set<String> {
    var productIDs = Set<String>()

    for await result in Transaction.currentEntitlements {
        guard case .verified(let transaction) = result else {
            continue
        }

        guard transaction.productType == .autoRenewable else {
            continue
        }

        if transaction.revocationDate != nil {
            continue
        }

        if let expirationDate = transaction.expirationDate,
           expirationDate <= Date() {
            continue
        }

        productIDs.insert(transaction.productID)
    }

    return productIDs
}

调用方式如下:

let activeProductIDs = await activeSubscriptionProductIDs()

if activeProductIDs.contains(product.id) {
    // 解锁订阅权益
} else {
    // 当前没有从已验证交易中发现有效权益
}

只能信任 .verified 交易。.unverified 表示交易未通过 StoreKit 验证,不能用来解锁付费功能。

持续监听交易变化

除了在启动时读取当前权益,还要监听交易更新,以便在购买、续订、退款或撤销后及时刷新状态:

import StoreKit

func observeTransactionUpdates() -> Task<Void, Never> {
    Task {
        for await result in Transaction.updates {
            guard case .verified(let transaction) = result else {
                continue
            }

            await refreshSubscriptionEntitlements()
            await transaction.finish()
        }
    }
}

应用启动后应保留这个任务,并在合适的生命周期节点取消:

private var transactionUpdatesTask: Task<Void, Never>?

func startObservingTransactions() {
    transactionUpdatesTask = observeTransactionUpdates()
}

func stopObservingTransactions() {
    transactionUpdatesTask?.cancel()
    transactionUpdatesTask = nil
}

购买完成后,也要处理并结束已经验证的交易:

let result = try await product.purchase()

switch result {
case .success(let verification):
    guard case .verified(let transaction) = verification else {
        return
    }

    await refreshSubscriptionEntitlements()
    await transaction.finish()

case .pending:
    break

case .userCancelled:
    break

@unknown default:
    break
}

仍然需要 subscription.status 时

Transaction.currentEntitlements 可以回答”现在是否应该解锁权益”,但不能完整替代 Product.SubscriptionInfo.Status。如果需要区分宽限期、账单重试等订阅状态,仍需尝试读取 status:

do {
    let statuses = try await sub.status

    if statuses.isEmpty {
        // watchOS 8 / 本地 StoreKit 环境可能无法返回订阅组状态。
        // 回退到 Transaction.currentEntitlements 判断当前权益。
        let activeProductIDs = await activeSubscriptionProductIDs()
        let hasEntitlement = activeProductIDs.contains(product.id)
        // 根据 hasEntitlement 更新界面和权限。
    } else {
        // 验证并处理 status.transaction 和 status.renewalInfo。
    }
} catch {
    // 记录错误,并使用当前权益作为降级路径。
}

处理非空状态时,也不能跳过验证:

for status in statuses {
    guard case .verified(let transaction) = status.transaction,
          case .verified(let renewalInfo) = status.renewalInfo else {
        continue
    }

    switch status.state {
    case .subscribed:
        break
    case .inGracePeriod:
        break
    case .inBillingRetryPeriod:
        break
    case .expired:
        break
    case .revoked:
        break
    default:
        break
    }

    _ = transaction
    _ = renewalInfo
}

注意事项

  • 不要把 statuses.isEmpty 直接理解为订阅已经过期。
  • 商品能够查询成功,并不能证明订阅状态一定正常。
  • 本地 StoreKit 测试、Sandbox 和 TestFlight 的行为不一定完全一致。
  • 判断权益时,需要检查交易验证结果、expirationDate 和 revocationDate。
  • subscription.status 用于获取订阅生命周期状态,Transaction.currentEntitlements 更适合在启动时恢复当前权益。
  • 如果问题在最新环境和 Watch 真机 Sandbox 中仍能稳定复现,应整理最小示例、Xcode/watchOS 版本和 StoreKit Configuration,并向 Apple 提交反馈。

备注:内容仅供参考。