StoreKit 2订阅续期事务在Transaction.all/updates中丢失求助
问题背景
iOS 15+环境下使用StoreKit 2实现订阅服务,新订阅开通正常,但有不到1%的用户反馈续期后有效订阅未被识别,表现为续期对应的StoreKit事务未出现在本地记录中。排查发现:强制重启应用、调用AppStore.sync()无效;重启设备仅对部分用户有效;重装应用可彻底解决问题,且无法在测试设备复现该问题。
现有核心实现逻辑:通过StoreManager统一处理StoreKit交互,初始化时遍历Transaction.all加载历史事务,同时监听Transaction.updates;用事务identifier作为键,将已购项目存储在purchasedItems字典中,所有字典写入操作通过MainActor绑定的updatePurchasedItemFor()执行;通过遍历字典判断用户订阅状态。
问题分析
结合代码与排查结果,问题大概率源于以下几点:
- 事务验证错误被静默忽略:
loadAllTransactions中使用try?跳过验证失败的事务,若续期事务因网络、本地缓存异常等原因验证失败,会直接丢失,未加入本地存储。 - 订阅状态判断未兜底官方接口:仅依赖本地存储的事务列表,未使用苹果推荐的
Transaction.currentEntitlements作为兜底,当本地事务不完整时无法获取正确状态。 - 事务存储逻辑未聚焦最新有效订阅:以事务ID为键存储所有历史事务,但判断订阅状态时遍历全部条目,若最新续期事务未被加入,可能误判为未订阅。
针对性修复方案
1. 修复事务加载的错误处理
移除try?并捕获验证错误,避免丢失事务:
func loadAllTransactions() { Task(priority: .userInitiated) { @MainActor in do { for await result in Transaction.all { let transaction = try checkVerified(result) await updatePurchasedItemFor(transaction) } } catch { Analytics.logError(error, forActivity: "加载全部事务失败") } } }
2. 用Transaction.currentEntitlements作为订阅状态兜底
将同步计算属性改为异步方法,优先通过官方接口获取当前有效订阅,再回退到本地存储:
func fetchSubscriberState() async -> SubscriberState { // 优先检查官方当前权益接口 do { for await result in Transaction.currentEntitlements { let transaction = try checkVerified(result) if let expirationDate = transaction.expirationDate, expirationDate > Date() { return .subscribed(expirationDate: expirationDate, isInTrial: transaction.offerType == .introductory) } } } catch { Analytics.logError(error, forActivity: "检查当前权益失败") } // 回退到本地存储的事务 for (_, item) in purchasedItems { if let expirationDate = item.expirationDate, expirationDate > Date() { return .subscribed(expirationDate: expirationDate, isInTrial: item.isInTrial) } } return .notSubscribed }
3. 优化事务存储,聚焦最新有效订阅
新增按原始事务ID存储最新订阅的字典,确保同一订阅仅保留最新有效事务:
class StoreManager { // ... 原有代码 ... private(set) var purchasedItems: [UInt64: PurchasedItem] = [:] // 新增:按原始事务ID存储最新的有效订阅 private(set) var latestSubscriptionsByOriginalId: [UInt64: PurchasedItem] = [:] @MainActor func updatePurchasedItemFor(_ transaction: Transaction) async { let item = PurchasedItem(productId: transaction.productID, originalPurchaseDate: transaction.originalPurchaseDate, transactionId: transaction.id, originalTransactionId: transaction.originalID, expirationDate: transaction.expirationDate, isInTrial: transaction.offerType == .introductory) if transaction.revocationDate == nil { purchasedItems[transaction.id] = item // 更新同一原始订阅的最新条目 if let originalId = transaction.originalID { let shouldUpdate = latestSubscriptionsByOriginalId[originalId].flatMap { existing in item.expirationDate ?? .distantFuture > existing.expirationDate ?? .distantFuture } ?? true if shouldUpdate { latestSubscriptionsByOriginalId[originalId] = item } } } else { purchasedItems[transaction.id] = nil // 若被撤销的是最新条目,重新查找同一订阅的其他有效事务 if let originalId = transaction.originalID, latestSubscriptionsByOriginalId[originalId]?.transactionId == transaction.id { latestSubscriptionsByOriginalId[originalId] = purchasedItems.values .filter { $0.originalTransactionId == originalId && ($0.expirationDate ?? .distantFuture) > Date() } .max { $0.expirationDate ?? .distantPast < $1.expirationDate ?? .distantPast } } } NotificationCenter.default.post(name: StoreManager.purchasesDidUpdateNotification, object: self) } // ... 原有代码 ... }
后续判断订阅状态时,可直接遍历latestSubscriptionsByOriginalId,效率更高且更准确。
4. 增强手动同步逻辑
提供包含重新加载事务的同步方法,解决AppStore.sync()无效的问题:
func syncAndReloadPurchases() async { do { try await AppStore.sync() // 同步后重新加载所有事务 await loadAllTransactions() } catch { Analytics.logError(error, forActivity: "同步并重新加载事务失败") } }
5. 补充日志与用户反馈收集
在所有事务处理的错误分支添加详细日志,同时在应用内提供日志导出功能,当用户反馈问题时,收集日志定位具体故障点(如事务验证失败、未获取到续期事务等)。
关于StoreKit 2潜在bug的应对
若上述修复后仍有问题,可继续跟进苹果的bug报告,同时在应用内添加临时的订阅状态申诉入口,让用户提供相关信息协助苹果排查问题。
内容的提问来源于stack exchange,提问作者Theo

