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 模拟器或真机上重新验证。
仅凭空数组无法确定问题最早在哪个具体版本中被彻底修复,因此不要依赖未经验证的某个小版本号。更稳妥的验证顺序是:
- 在最新可用的 Xcode StoreKit 本地环境中测试。
- 在 Watch 真机的 Sandbox 环境中测试。
- 通过 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 提交反馈。
备注:内容仅供参考。
