当前位置: 首页 > news >正文

iOS In-App Purchase 自动续订订阅完整实现指南

前言

自动续订订阅(Auto-Renewable Subscriptions)是 iOS 应用最常见的变现模式之一,适用于流媒体服务、云存储、会员权益等场景。相比一次性购买,订阅模式能够为开发者提供稳定的现金流,同时也为用户提供持续更新的服务体验。

本文将从零开始,全面讲解自动续订订阅的实现,涵盖 App Store Connect 配置、客户端代码实现、服务端验证、状态管理等核心环节。


自动续订订阅基础概念

1. 订阅类型对比

类型特点适用场景
自动续订订阅自动扣费续订,直到用户取消视频会员、音乐服务、云存储
非续订订阅固定时长,到期不自动续订赛季通行证、限时服务
消耗型使用后消失,可重复购买游戏金币、虚拟道具
非消耗型一次购买,永久拥有去广告、功能解锁

2. 订阅生命周期

┌─────────────────────────────────────────────────────────────────────┐ │ 订阅生命周期 │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ 首次订阅 ──► 免费试用期 ──► 付费周期 ──► 自动续订 ──► ... │ │ │ │ │ │ │ │ │ │ │ ├──► 续订成功 ──► 继续 │ │ │ │ │ │ │ │ │ │ │ ├──► 续订失败 ──► 宽限期 │ │ │ │ │ │ │ │ │ │ │ │ │ └──► 计费重试期 │ │ │ │ │ │ │ │ │ │ │ │ │ └──► 过期 │ │ │ │ │ │ │ │ │ │ │ └──► 用户取消 ──► 到期过期 │ │ │ │ │ │ │ └───────────┴────────────┴──► 退款 ──► 立即失效 │ │ │ └─────────────────────────────────────────────────────────────────────┘

3. 关键术语解释

  • 订阅组(Subscription Group):同一组内的订阅互斥,用户只能订阅其中一个
  • 服务等级(Service Level):组内订阅的优先级,决定升降级行为
  • 宽限期(Grace Period):续订失败后,仍保留服务的宽限时间(最长16天)
  • 计费重试期(Billing Retry):Apple 尝试重新扣费的时间段(最长60天)
  • Original Transaction ID:订阅链的唯一标识,首次购买时生成

App Store Connect 配置

1. 创建订阅组

  1. 登录 App Store Connect
  2. 选择您的 App →订阅订阅组
  3. 点击+创建新的订阅组
订阅组结构示例: Premium 会员订阅组 ├── 年度会员 (com.yourapp.premium.yearly) - Level 1 ├── 季度会员 (com.yourapp.premium.quarterly) - Level 2 └── 月度会员 (com.yourapp.premium.monthly) - Level 3

2. 配置订阅产品

对于每个订阅产品,需要配置:

配置项说明示例
产品 ID唯一标识符com.yourapp.premium.monthly
订阅时长1周到1年1个月
价格选择价格等级等级6(¥18)
推介促销优惠首次订阅优惠首月免费试用
促销优惠挽留/获客优惠3个月5折
优惠代码自定义优惠码WELCOME2024

3. 设置服务器通知(Server-to-Server Notifications)

App Store Connect → 应用 → App 信息 → App Store Server Notifications

配置 V2 通知端点:

生产环境 URL: https://api.yourapp.com/apple/notifications 沙盒环境 URL: https://api-sandbox.yourapp.com/apple/notifications

4. 获取共享密钥

App Store Connect → 用户和访问 → 共享密钥

共享密钥用于验证收据,请妥善保管! 示例:a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6

客户端实现

1. 项目配置

启用 In-App Purchase 能力
Xcode → Project → Targets → Signing & Capabilities → + Capability → In-App Purchase
StoreKit 配置文件(用于本地测试)
  1. File → New → File → StoreKit Configuration File
  2. 添加订阅产品配置
  3. Scheme → Edit Scheme → Run → Options → StoreKit Configuration

2. StoreKit 1 完整实现

importStoreKit// MARK: - 订阅产品标识符structSubscriptionProducts{staticletmonthlyID="com.yourapp.premium.monthly"staticletquarterlyID="com.yourapp.premium.quarterly"staticletyearlyID="com.yourapp.premium.yearly"staticletallProductIDs:Set<String>=[monthlyID,quarterlyID,yearlyID]}// MARK: - 订阅管理器classSubscriptionManager:NSObject,ObservableObject{// MARK: - 单例staticletshared=SubscriptionManager()// MARK: - 发布属性@Publishedvarproducts:[SKProduct]=[]@PublishedvarpurchasedProductIDs:Set<String>=[]@PublishedvarisSubscribed:Bool=false@PublishedvarisLoading:Bool=false@PublishedvarerrorMessage:String?// MARK: - 私有属性privatevarproductsRequest:SKProductsRequest?privatevarpurchaseCompletionHandler:((Result<SKPaymentTransaction,Error>)->Void)?privatevarrestoreCompletionHandler:((Result<[SKPaymentTransaction],Error>)->Void)?// MARK: - 初始化privateoverrideinit(){super.init()startObservingPaymentQueue()}deinit{stopObservingPaymentQueue()}// MARK: - 支付队列观察funcstartObservingPaymentQueue(){SKPaymentQueue.default().add(self)}funcstopObservingPaymentQueue(){SKPaymentQueue.default().remove(self)}// MARK: - 请求产品信息funcfetchProducts(){guard!isLoadingelse{return}isLoading=trueerrorMessage=nilletrequest=SKProductsRequest(productIdentifiers:SubscriptionProducts.allProductIDs)request.delegate=selfrequest.start()productsRequest=requestprint("🛒 开始请求产品信息...")}// MARK: - 购买订阅funcpurchase(_product:SKProduct,completion:@escaping(Result<SKPaymentTransaction,Error>)->Void){guardSKPaymentQueue.canMakePayments()else{completion(.failure(SubscriptionError.paymentsNotAllowed))return}purchaseCompletionHandler=completion isLoading=trueletpayment=SKPayment(product:product)SKPaymentQueue.default().add(payment)print("💳 发起购买:\(product.productIdentifier)")}// MARK: - 恢复购买funcrestorePurchases(completion:@escaping(Result<[SKPaymentTransaction],Error>)->Void){restoreCompletionHandler=completion isLoading=trueSKPaymentQueue.default().restoreCompletedTransactions()print("🔄 开始恢复购买...")}// MARK: - 验证收据funcvalidateReceipt(completion:@escaping(Result<ReceiptValidationResponse,Error>)->Void){guardletreceiptURL=Bundle.main.appStoreReceiptURL,FileManager.default.fileExists(atPath:receiptURL.path),letreceiptData=try?Data(contentsOf:receiptURL)else{completion(.failure(SubscriptionError.noReceiptFound))return}letreceiptString=receiptData.base64EncodedString()// 发送到您的服务器进行验证ReceiptValidator.validate(receipt:receiptString){resultinDispatchQueue.main.async{switchresult{case.success(letresponse):self.processValidationResponse(response)completion(.success(response))case.failure(leterror):completion(.failure(error))}}}}// MARK: - 处理验证响应privatefuncprocessValidationResponse(_response:ReceiptValidationResponse){guardletlatestReceipt=response.latestReceiptInfo?.firstelse{isSubscribed=falsereturn}// 检查订阅是否有效ifletexpiresDateMs=latestReceipt.expiresDateMs,letexpiresDate=Double(expiresDateMs){letexpiration=Date(timeIntervalSince1970:expiresDate/1000)isSubscribed=expiration>Date()ifisSubscribed{purchasedProductIDs.insert(latestReceipt.productId)}}}// MARK: - 获取格式化价格funcformattedPrice(forproduct:SKProduct)->String{letformatter=NumberFormatter()formatter.numberStyle=.currency formatter.locale=product.priceLocalereturnformatter.string(from:product.price)??"\(product.price)"}// MARK: - 获取订阅周期描述funcsubscriptionPeriodDescription(forproduct:SKProduct)->String{guardletperiod=product.subscriptionPeriodelse{return""}letunit:Stringswitchperiod.unit{case.day:unit=period.numberOfUnits==1?"天":"\(period.numberOfUnits)天"case.week:unit=period.numberOfUnits==1?"周":"\(period.numberOfUnits)周"case.month:unit=period.numberOfUnits==1?"月":"\(period.numberOfUnits)个月"case.year:unit=period.numberOfUnits==1?"年":"\(period.numberOfUnits)年"@unknowndefault:unit=""}returnunit}// MARK: - 获取免费试用描述funcfreeTrialDescription(forproduct:SKProduct)->String?{guardletintroPrice=product.introductoryPrice,introPrice.paymentMode==.freeTrialelse{returnnil}letperiod=introPrice.subscriptionPeriodletunit:Stringswitchperiod.unit{case.day:unit="\(period.numberOfUnits)天"case.week:unit="\(period.numberOfUnits)周"case.month:unit="\(period.numberOfUnits)个月"case.year:unit="\(period.numberOfUnits)年"@unknowndefault:returnnil}return"免费试用\(unit)"}}// MARK: - SKProductsRequestDelegateextensionSubscriptionManager:SKProductsRequestDelegate{funcproductsRequest(_request:SKProductsRequest,didReceive response:SKProductsResponse){DispatchQueue.main.async{self.isLoading=falseself.products=response.products.sorted{$0.price.compare($1.price)==.orderedAscending}print("✅ 获取到\(response.products.count)个产品")if!response.invalidProductIdentifiers.isEmpty{print("⚠️ 无效产品ID:\(response.invalidProductIdentifiers)")}}}funcrequest(_request:SKRequest,didFailWithError error:Error){DispatchQueue.main.async{self.isLoading=falseself.errorMessage=error.localizedDescriptionprint("❌ 请求产品失败:\(error.localizedDescription)")}}}// MARK: - SKPaymentTransactionObserverextensionSubscriptionManager:SKPaymentTransactionObserver{funcpaymentQueue(_queue:SKPaymentQueue,updatedTransactions transactions:[SKPaymentTransaction]){fortransactionintransactions{switchtransaction.transactionState{case.purchasing:print("🔄 购买中:\(transaction.payment.productIdentifier)")case.purchased:print("✅ 购买成功:\(transaction.payment.productIdentifier)")handlePurchased(transaction)case.failed:print("❌ 购买失败:\(transaction.error?.localizedDescription??"未知错误")")handleFailed(transaction)case.restored:print("🔄 恢复成功:\(transaction.payment.productIdentifier)")handleRestored(transaction)case.deferred:print("⏸ 购买延迟(等待审批):\(transaction.payment.productIdentifier)")handleDeferred(transaction)@unknowndefault:print("⚠️ 未知交易状态")}}}funcpaymentQueueRestoreCompletedTransactionsFinished(_queue:SKPaymentQueue){DispatchQueue.main.async{self.isLoading=falseprint("✅ 恢复购买完成")letrestoredTransactions=queue.transactions.filter{$0.transactionState==.restored}self.restoreCompletionHandler?(.success(restoredTransactions))self.restoreCompletionHandler=nil}}funcpaymentQueue(_queue:SKPaymentQueue,restoreCompletedTransactionsFailedWithError error:Error){DispatchQueue.main.async{self.isLoading=falseself.errorMessage=error.localizedDescriptionprint("❌ 恢复购买失败:\(error.localizedDescription)")self.restoreCompletionHandler?(.failure(error))self.restoreCompletionHandler=nil}}// MARK: - 处理购买成功privatefunchandlePurchased(_transaction:SKPaymentTransaction){// 验证收据validateReceipt{[weakself]resultinswitchresult{case.success:self?.purchaseCompletionHandler?(.success(transaction))case.failure(leterror):self?.purchaseCompletionHandler?(.failure(error))}self?.purchaseCompletionHandler=nilself?.isLoading=false}// 完成交易SKPaymentQueue.default().finishTransaction(transaction)}// MARK: - 处理购买失败privatefunchandleFailed(_transaction:SKPaymentTransaction){DispatchQueue.main.async{self.isLoading=falseifleterror=transaction.erroras?SKError{switcherror.code{case.paymentCancelled:self.purchaseCompletionHandler?(.failure(SubscriptionError.paymentCancelled))default:self.purchaseCompletionHandler?(.failure(error))}}self.purchaseCompletionHandler=nil}SKPaymentQueue.default().finishTransaction(transaction)}// MARK: - 处理恢复privatefunchandleRestored(_transaction:SKPaymentTransaction){purchasedProductIDs.insert(transaction.payment.productIdentifier)SKPaymentQueue.default().finishTransaction(transaction)}// MARK: - 处理延迟privatefunchandleDeferred(_transaction:SKPaymentTransaction){DispatchQueue.main.async{self.isLoading=falseself.purchaseCompletionHandler?(.failure(SubscriptionError.paymentDeferred))self.purchaseCompletionHandler=nil}}}// MARK: - 错误定义enumSubscriptionError:LocalizedError{casepaymentsNotAllowedcasepaymentCancelledcasepaymentDeferredcasenoReceiptFoundcaseinvalidReceiptcaseserverErrorvarerrorDescription:String?{switchself{case.paymentsNotAllowed:return"当前设备不允许应用内购买"case.paymentCancelled:return"购买已取消"case.paymentDeferred:return"购买需要授权,请等待审批"case.noReceiptFound:return"未找到购买凭证"case.invalidReceipt:return"购买凭证无效"case.serverError:return"服务器验证失败"}}}// MARK: - 收据验证响应模型structReceiptValidationResponse:Codable{letstatus:IntletlatestReceiptInfo:[LatestReceiptInfo]?letpendingRenewalInfo:[PendingRenewalInfo]?enumCodingKeys:String,CodingKey{casestatuscaselatestReceiptInfo="latest_receipt_info"casependingRenewalInfo="pending_renewal_info"}}structLatestReceiptInfo:Codable{letproductId:StringlettransactionId:StringletoriginalTransactionId:StringletpurchaseDateMs:StringletexpiresDateMs:String?letisTrialPeriod:String?letisInIntroOfferPeriod:String?enumCodingKeys:String,CodingKey{caseproductId="product_id"casetransactionId="transaction_id"caseoriginalTransactionId="original_transaction_id"casepurchaseDateMs="purchase_date_ms"caseexpiresDateMs="expires_date_ms"caseisTrialPeriod="is_trial_period"caseisInIntroOfferPeriod="is_in_intro_offer_period"}}structPendingRenewalInfo:Codable{letautoRenewProductId:StringletautoRenewStatus:StringletexpirationIntent:String?letgracePeriodExpiresDateMs:String?enumCodingKeys:String,CodingKey{caseautoRenewProductId="auto_renew_product_id"caseautoRenewStatus="auto_renew_status"caseexpirationIntent="expiration_intent"casegracePeriodExpiresDateMs="grace_period_expires_date_ms"}}

3. 订阅界面实现(SwiftUI)

importSwiftUIstructSubscriptionView:View{@StateObjectprivatevarsubscriptionManager=SubscriptionManager.shared @StateprivatevarselectedProduct:SKProduct?@StateprivatevarshowAlert=false@StateprivatevaralertMessage=""@Environment(\.dismiss)privatevardismissvarbody:someView{NavigationView{ScrollView{VStack(spacing:24){// 头部headerSection// 功能特性featuresSection// 订阅选项subscriptionOptionsSection// 订阅按钮subscribeButton// 恢复购买restoreButton// 法律条款legalSection}.padding()}.navigationTitle("升级会员").navigationBarTitleDisplayMode(.inline).toolbar{ToolbarItem(placement:.navigationBarTrailing){Button("关闭"){dismiss()}}}}.onAppear{subscriptionManager.fetchProducts()}.alert("提示",isPresented:$showAlert){Button("确定",role:.cancel){}}message:{
http://www.jsqmd.com/news/94755/

相关文章:

  • 在路径规划的世界里,A星算法和人工势场法(APF)是两个经典的选手。今天,我们就来聊聊如何将这两个算法进行改进,并结合起来,实现更高效的路径规划
  • Hutool工具库实战:8大核心工具类深度解析
  • 构建高效性能自动化监控体系的五大核心策略
  • 基于开源AI智能名片链动2+1模式的S商城小程序在银发族旅拍消费中的创新应用研究
  • 回归单体架构到底是不是技术倒退
  • 两级套筒共源共栅全差分放大器实战手记
  • MATLAB代码:含先进绝热压缩空气储能系统的综合能源系统优化调度 关键词:先进绝热压缩空气储...
  • WebRTC 架构概览(整体框架篇)
  • 转动惯量和阻尼系数协同自适应控制策略。 建立 VSG 的数学模型,分析各参数对系统输出特性的影响
  • Python设计模式:访问者模式详解
  • iOS 组件化:模块拆分、依赖反转、解耦实践
  • 【Linux网络编程】UDP Socket
  • 别再迷信Agent了! Claude大佬一语道破:Agent太“聪明”反而不专业,真正的未来是“Skills”!
  • 31、Linux进程管理实战:回收机制、exec族函数与路径操作
  • 测试数据管理的自动化解决方案
  • 风储联合调频+光伏变压减载的Simulink仿真模型:‘风机虚拟惯量调频‘、‘储能下垂控制联合...
  • 信捷XDPLC与台达DT330温控器:多设备通讯启停控制程序及实用指南
  • 如何利用智能客服大脑提升服务效率?
  • 零基础转行AI产品经理:大模型学习路线与面试题库全攻略
  • AI从“玩具”到“工具”的鸿沟如何跨越?一文读懂智能体工程Agent Engineering!
  • SATT-CNN-BiLSTM:基于层结构自注意力机制的卷积连接Bi-LSTM时序预测模型
  • 自动化测试的未来:超越脚本编写
  • 云原生测试的实践与展望
  • Python设计模式:桥接模式详解
  • 告别“消失的小目标”:航拍图像检测新框架,精度飙升25.7%的秘诀
  • 测试中的区块链技术应用
  • 【保姆级教程】手把手带你读懂AI落地架构图!AI产品经理必备,每个节点都给你讲透!
  • COMSOL MXene超材料吸收器的性能研究:高效能量转换与吸收机制探索
  • 如何用Laravel 13构建动态多模态权限体系:完整代码示例曝光
  • Selenium进阶:高效UI测试实战