news 2026/8/1 23:16:25

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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:{
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/1 6:15:07

OWASP Top 10深度解析:软件测试工程师的安全防护手册

一、为什么测试工程师必须掌握OWASP Top 10&#xff1f;在数字化浪潮席卷全球的今天&#xff0c;Web应用已成为业务运营的核心载体。作为软件质量保障的关键角色&#xff0c;测试工程师的职责早已超越功能验证的范畴。OWASP&#xff08;开放Web应用安全项目&#xff09;Top 10作…

作者头像 李华
网站建设 2026/7/31 12:06:20

【总纲】IT运维面试知识图谱与高频问题汇总

说明: 此大纲面试题目,根据实际客户业务场景为切入点,根据客户业务访问链路,串联起IT运维整个技术栈体系,从而形成知识框架与体系思维,详细见如下链接,一个订单背后的技术江湖:用一次下单,串起IT运维全技术栈:https://blog.csdn.net/qq_33525062/article/details/15…

作者头像 李华
网站建设 2026/7/31 17:26:23

项目解决方案:某地管理部门工地智能监管AI识别解决方案

目录 第一章 项目背景 1.1 政策推动与监管需求 1.2 技术革新与智能化趋势 1.3 工地实际需求与挑战 第二章 方案设计概述 2.1 系统总体架构 2.2 硬件设备选型与配置 2.3 软件系统设计与开发 第三章 需要解决的问题及其复杂性 3.1 人脸比对准确性与效率问题 3.2 作业规…

作者头像 李华
网站建设 2026/8/1 22:13:37

信捷XDPLC与台达温控器组网实战手记

信捷XDPLC与3台台达DT330温控器通讯程序本体远程双设定(XJXD-4) 功能&#xff1a;通过信捷XD5&#xff0c;实现对3台台达DT330温控器设定温度&#xff0c;读取温度&#xff0c;本体远程双设定温度&#xff0c;反应灵敏&#xff0c;通讯稳定可靠。 程序采用轮询方式 器件&#x…

作者头像 李华
网站建设 2026/7/31 17:30:53

从零开始调试Rust编写的PHP扩展函数(完整工具链+实战案例)

第一章&#xff1a;从零开始调试Rust编写的PHP扩展函数使用Rust编写PHP扩展可以显著提升性能与内存安全性。然而&#xff0c;由于跨语言调用的复杂性&#xff0c;调试过程往往充满挑战。本章将指导你如何在开发环境中配置并调试由Rust实现的PHP扩展函数。环境准备 安装 PHP 开发…

作者头像 李华