You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

StoreKit 2:如何通过.subscriptionStatusTask识别订阅组内有效订阅

问题

我用SubscriptionStoreView搭配.onInAppPurchaseCompletion、.subscriptionStatusTask、.manageSubscriptionSheet实现同一订阅组内的月付、年付自动续期订阅(无其他内购类型)。

现在卡在.subscriptionStatusTask的使用上:怎么在这个闭包里准确识别用户实际购买的订阅?之前我通过返回值映射,读取transaction.payloadValue判断,但当月付升级年付后,transaction.payloadValue.productID还是显示月付(推测是月付尚未到期),但我的应用需要根据用户是否购买年付来开放更多功能,仅靠.subscriptionStatusTask该怎么实现?

补充测试现象:月付升级年付后,prodId仍显示月付,但Debug→StoreKit→Manage Transactions里显示月付将于9-7-24到期,年付会续订;取消年付时,提示可使用至2024年9月7日,取消后交易记录显示月付但不再续订。

还有几个疑问:

  • 通过.manageSubscriptionSheet变更订阅时,购买是否即时生效?
  • 是否需要读取续订信息来提前开放年付功能,用户取消续订则恢复月付权限?
  • 还有哪些要点我未考虑到?

附测试代码:

struct ContentViewSO2: View {
@State private var paywallShown = false
@State private var isPro = false
@State private var presentingSubscriptionSheet = false
@State private var showManageSubscriptionButton = false

@State private var subType = ""

var body: some View {
    VStack(spacing: 75){
        Text("Testing Store Paywall and Subscription Status Sheet")
            .multilineTextAlignment(.center)
            .foregroundColor(.black)
            .font(.system(size: 18, weight: .heavy, design: .rounded))
            .padding(.vertical, 75)
            .subscriptionStatusTask(for: "C5FF4E1D") { taskState in
                if let value = taskState.value {
                    
                    //.. check to see if there was EVER a subscription in order to know whether or not to present the .manageSubscriptionSheet. It seems that if there was never a subscription, the .manageSubscriptionSheet will not appear... it just clocks
                    
                    if value.count > 0 {
                        showManageSubscriptionButton = true
                    } else {
                        showManageSubscriptionButton = false
                    }
                    
                    let _: [()] = value.map { status in
                        //.. transaction = what's currently purchased???????
                        
                        let statusTransaction = status.transaction
                        let payloadValue =  try? status.transaction.payloadValue
                        let prodId = payloadValue?.productID
                        print("product id = \(prodId)")
                        
                        if prodId == "com.HR.test.yearly" {
                            subType = "yearly"
                        } else if prodId == "com.HR.test.monthly" {
                            subType = "monthly"
                        } else {
                            subType = "none"
                        }
                        
                        //.. if there is a value that's not .revoked and not .expired
                        isPro = !value
                            .filter { $0.state != .revoked && $0.state != .expired }
                            .isEmpty
                        
                        //.. for more debugging
                        let renewalState = status.state
                        switch renewalState {
                        case .revoked:
                            print("renewalState = revoked")
                        case .expired:
                            print("renewalState = expired")
                        case .subscribed:
                            print("renewalState = subscribed")
                        case .inBillingRetryPeriod:
                            print("renewalState = inBillingRetryPeriod")
                        case .inGracePeriod:
                            print("renewalState = inGracePeriod")
                        default:
                            print("renewalState = ???")
                        }
                    }
                }
            }
        isPro ?
        Text("IS PRO FROM .subscriptionStatusTask")
            .foregroundColor(.white)
            .background(Color.green)
        :
        Text("IS NOT PRO FROM .subscriptionStatusTask")
            .foregroundColor(.white)
            .background(Color.red)
        
        switch subType {
        case "monthly":
            Text("Monthly Subscription")
                .padding(.horizontal, 25)
                .foregroundColor(.white)
                .background(Color.blue)
        case "yearly":
            Text("Yearly Subscription")
                .padding(.horizontal, 25)
                .foregroundColor(.white)
                .background(Color.teal)
        default:
            Text("No Subscription")
                .padding(.horizontal, 25)
                .foregroundColor(.white)
                .background(Color.red)
        }
        
        //.. check to see if there was EVER a subscription in order to know whether to present the .manageSubscriptionSheet. It seems that if there was never a subscription, the .manageSubscriptionSheet will not appear... it just clocks. If there has not been any subscription EVER, show paywall instead of manage subscription sheet

        if !showManageSubscriptionButton {
            Button(action: {
                paywallShown = true
            }, label: {
                Text("Press to test paywall")
            })
            .buttonStyle(.borderedProminent)
            .sheet(isPresented: $paywallShown) {
                HRShop()
            }
        } else {
            Button(action: {
                self.presentingSubscriptionSheet = true
            }, label: {
                Text("Show .manageSubScriptionSheet")
            })
            .manageSubscriptionsSheet(
                isPresented: $presentingSubscriptionSheet,
                subscriptionGroupID: "C5FF4E1D"
            )
        }
    }
    Spacer()
}
}

解决方案

1. 优先读取latestSubscriptionInfo而非当前交易

用户升级/降级订阅时,当前生效的订阅未到期,但StoreKit已记录下一次续订的产品信息。要从SubscriptionStatus的latestSubscriptionInfo字段获取用户当前的订阅意愿(即将生效的产品),而不是仅依赖当前交易的payloadValue.productID。

修改后的.subscriptionStatusTask逻辑:

.subscriptionStatusTask(for: "C5FF4E1D") { taskState in
    guard let statuses = taskState.value else {
        showManageSubscriptionButton = false
        isPro = false
        subType = "none"
        return
    }
    
    showManageSubscriptionButton = !statuses.isEmpty
    
    // 筛选有效订阅状态(未过期、未撤销)
    let activeStatuses = statuses.filter {
        $0.state != .revoked && $0.state != .expired
    }
    
    isPro = !activeStatuses.isEmpty
    
    // 遍历有效状态,优先用latestSubscriptionInfo判断订阅类型
    for status in activeStatuses {
        if let latestInfo = status.latestSubscriptionInfo {
            let prodId = latestInfo.productID
            print("Latest subscription product ID: \(prodId)")
            
            subType = prodId == "com.HR.test.yearly" ? "yearly" : "monthly"
        } else {
            // 无续订计划时,回退到当前交易的产品ID
            if let payloadValue = try? status.transaction.payloadValue {
                subType = payloadValue.productID == "com.HR.test.yearly" ? "yearly" : "monthly"
            } else {
                subType = "none"
            }
        }
    }
    
    // 调试订阅状态
    for status in statuses {
        switch status.state {
        case .revoked: print("renewalState = revoked")
        case .expired: print("renewalState = expired")
        case .subscribed: print("renewalState = subscribed")
        case .inBillingRetryPeriod: print("renewalState = inBillingRetryPeriod")
        case .inGracePeriod: print("renewalState = inGracePeriod")
        default: print("renewalState = ???")
        }
    }
}

2. 订阅变更的即时性与权限处理

  • 即时开放权限:用户通过.manageSubscriptionSheet完成升级后,latestSubscriptionInfo会立即更新为年付产品ID,可直接据此开放年付功能,无需等待当前月付到期。
  • 取消续订后恢复权限:用户取消年付续订时,latestSubscriptionInfo会变回月付产品ID,此时需将权限降级为月付级别。

3. 需补充的要点

  • 状态优先级:存在多个订阅状态时,latestSubscriptionInfo代表用户当前的订阅意愿,优先级高于当前生效的交易。
  • 跨设备同步:.subscriptionStatusTask会自动同步其他设备的订阅变更,逻辑需依赖StoreKit返回的状态,避免依赖本地缓存。
  • 全场景测试:覆盖升级、降级、取消续订、账单重试期、宽限期等所有订阅状态,确保权限判断准确。
  • 错误处理:当前代码中try?会忽略解析错误,实际开发中需添加错误捕获,避免因交易解析失败导致状态判断错误。

内容的提问来源于stack exchange,提问作者KatM

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.19 22:45:54