news 2026/5/10 4:24:51

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/5/4 19:18:39

跨界学习:测试与开发的融合

在快速迭代的软件开发浪潮中&#xff0c;测试与开发的关系正经历深刻重塑。传统的“测试在后、开发在前”的线性模式已难以应对市场对速度与质量的双重需求。 一、思维融合&#xff1a;从质检岗到质量共建者 1.1 破除“上下游”对立意识 传统团队中&#xff0c;测试常被视作…

作者头像 李华
网站建设 2026/5/5 23:54:48

COMSOL MXene超材料吸收器的性能研究:高效能量转换与吸收机制探索

comsol MXene超材料吸收器。打开COMSOL的时候&#xff0c;总感觉这软件像是个三维乐高乐园——尤其是当你想用MXene这种二维材料搭个超材料吸收器的时候。先别急着点开电磁波模块&#xff0c;咱们先搞明白MXene这货在微波段的奇葩表现&#xff1a;介电常数实部负数&#xff0c;…

作者头像 李华
网站建设 2026/5/2 12:29:53

乐迪信息:煤矿井下高风险行为识别:AI 摄像机自动预警违规攀爬

在煤矿开采这一高危行业中&#xff0c;井下作业环境复杂多变&#xff0c;各类风险隐患无处不在。违规攀爬行为作为其中极具危险性的一种&#xff0c;严重威胁着矿工的生命安全以及煤矿的生产秩序。传统的监管方式往往依赖人工巡查&#xff0c;不仅效率低下、容易出现监管盲区&a…

作者头像 李华
网站建设 2026/5/6 2:33:25

【翻译】【SOMEIP-SD】Page43- Page46

文章目录5.1.2.4.7 IPv4 SD Endpoint Option5.1.2.4.7 IPv4 SD Endpoint Option IPv4 SD Endpoint Option 用于发送SOME/IP-SD实例的endpoints信息&#xff0c;同时也预示着该option中包含的IP地址和Port号不能被其他SOMEIP服务端和客户端使用。 SOME/IP-SD实例的作用是在ECU之…

作者头像 李华
网站建设 2026/5/6 0:27:36

【MicroPython编程-ESP32篇】-Web页面显示BME280传感器数据

Web页面显示BME280传感器数据 文章目录 Web页面显示BME280传感器数据 1、BME280介绍 2、软件准备 3、硬件准备与接线 4、代码实现 4.1 BME280驱动库实现 4.2 连接WiFi 4.3 Web服务器实现 在本文中,将介绍如何将BME280 传感器模块与 ESP32一起使用,并通过MicroPython 固件获取…

作者头像 李华
网站建设 2026/5/9 11:16:06

考虑光伏出力利用率的电动汽车充电站能量调度策略。 程序注释详细 针对间歇性能源利用的问题

考虑光伏出力利用率的电动汽车充电站能量调度策略。 程序注释详细 针对间歇性能源利用的问题&#xff0c;构建电动汽车的充放电灵活度指标&#xff0c;用以评估电动汽车参与光伏充电站能量调度的能力&#xff1b; 令充电站在饥饿模式或饱和模式下运行&#xff0c;并根据当前运行…

作者头像 李华