在iOS应用程序中实现自动更新订阅的指南

图片


大家好! 我叫Denis,我正在开发Apphud ,该服务用于分析iOS应用程序中的自动可更新订阅。


在本文中,我将告诉您如何在iOS 12和iOS 13中配置,实施和验证自动更新订阅。此外,我将告诉您一些并非所有开发人员都考虑在内的细微之处和陷阱。


在App Store Connect上设置订阅


如果您已经具有捆绑ID和创建的应用程序,则可以跳过这些步骤。 如果您是第一次创建应用程序,请执行以下操作:


您必须在Apple Developer Portal上创建一个明确的Bundle ID(应用程序ID) 。 打开一个名为“ 证书,标识符和配置文件”的页面,转到“ 标识符”选项卡。 在2019年6月,Apple最终根据ASC(App Store Connect的缩写)更新了门户的布局。


2019年Apple Developer Portal的新设计
2019年Apple Developer Portal的新设计



显式捆绑包ID通常以域样式( com.apphud.subscriptionstest )指定。 在“ 功能”部分,您会注意到In App Purchases旁边的复选标记已被选中。 创建Bundle IDApp ID )后,转到App Store Connect。


测试用户(沙盒用户)


要测试将来的购买,您将需要创建一个测试用户。 为此,请转到“ 用户和访问”选项卡中的“ ASC”,然后转到“ 沙箱测试器”。


用户沙箱创建表单
用户沙箱创建表单


创建测试仪时,您可以指定任何不存在的数据,最重要的是,不要忘记电子邮件和密码!

在本文结尾处,我将讨论如何使用测试凭据来测试购买。


另一个重要步骤是在“ 协议,税收和银行业务 ”部分中设置合同和银行数据。 如果您没有付费应用程序的协议,那么您将无法测试自动续订的订阅!


之后,您可以在App Store Connect中创建一个新应用程序。 指定唯一的名称,然后选择您的捆绑包ID作为包ID


包裹ID是您的捆绑ID
包裹ID是您的捆绑ID


创建应用程序后,立即转到“ 功能”选项卡


如果您已经创建了应用程序,则可以继续从此处阅读。

创建自动续订的过程包括以下几个阶段:


1.创建一个订阅标识符并创建一个订阅组 订阅组是具有不同期间和价格的订阅的集合,但是在应用程序中打开相同的功能。 另外,在订阅组中,您只能激活一次免费试用期,并且只能激活一个订阅。 如果希望您的应用程序同时具有两个不同的订阅,则将需要创建两组订阅。


2.填写订阅数据:持续时间,在App Store中的显示名称(不要与名称混淆)和描述。 如果将第一个订阅添加到该组,则需要指出订阅组的显示名称。 请记住,要更频繁地保存更改,ASC可能会随时冻结并停止响应。


订阅页面
订阅画面


3.填写订阅价格。 有两个阶段:创建价格和特价。 以任何货币表示实际价格,然后自动为所有其他国家/地区重新计算。 介绍性优惠:您可以在此处为用户提供免费试用期或预付费折扣。 促销活动是在2019年最近出现在App Store上的:促销活动使您可以为未订阅和想要返回的用户提供特别折扣。


共享密钥生成


在包含所有已创建订阅的列表的页面上,您将看到应用程序按钮的共享密钥 。 这是在iOS应用程序中验证支票所需的特殊行。 我们将需要验证检查以确定订阅的状态。


共享密钥可以有两种类型:应用程序的唯一密钥或帐户的单个密钥。 重要提示:如果您已经在App Store中拥有该应用程序,则无论如何都不要重新创建密钥,否则用户将无法验证支票,并且您的应用程序将无法按预期运行。


在此示例中,创建了三个订阅组和3个年度订阅。
在此示例中,创建了三个订阅组和3个年度订阅。


复制所有订阅的ID和共享密钥,这将在以后的代码中派上用场。


软件部分


让我们开始实践。 做一个完整的购物经理需要什么? 至少应执行以下操作:


  1. 结帐


  2. 检查订阅状态


  3. 检查更新


  4. 事务恢复(不要与更新支票混淆!)



结帐


整个购买过程可以分为两个阶段:接收产品( SKProduct类)和初始化购买过程( SKPayment类)。 首先,我们必须指定SKPaymentTransactionObserver协议的委托。


 // Starts products loading and sets transaction observer delegate @objc func startWith(arrayOfIds : Set<String>!, sharedSecret : String){ SKPaymentQueue.default().add(self) self.sharedSecret = sharedSecret self.productIds = arrayOfIds loadProducts() } private func loadProducts(){ let request = SKProductsRequest.init(productIdentifiers: productIds) request.delegate = self request.start() } public func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) { products = response.products DispatchQueue.main.async { NotificationCenter.default.post(name: IAP_PRODUCTS_DID_LOAD_NOTIFICATION, object: nil) } } func request(_ request: SKRequest, didFailWithError error: Error){ print("error: \(error.localizedDescription)") } 

IAP_PRODUCTS_DID_LOAD_NOTIFICATION通知用于更新应用程序中的UI。


接下来,我们编写一个初始化购买的方法:


 func purchaseProduct(product : SKProduct, success: @escaping SuccessBlock, failure: @escaping FailureBlock){ guard SKPaymentQueue.canMakePayments() else { return } guard SKPaymentQueue.default().transactions.last?.transactionState != .purchasing else { return } self.successBlock = success self.failureBlock = failure let payment = SKPayment(product: product) SKPaymentQueue.default().add(payment) } 

SKPaymentTransactionObserver委托看起来像这样:


 extension IAPManager: SKPaymentTransactionObserver { public func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { for transaction in transactions { switch (transaction.transactionState) { case .purchased: SKPaymentQueue.default().finishTransaction(transaction) notifyIsPurchased(transaction: transaction) break case .failed: SKPaymentQueue.default().finishTransaction(transaction) print("purchase error : \(transaction.error?.localizedDescription ?? "")") self.failureBlock?(transaction.error) cleanUp() break case .restored: SKPaymentQueue.default().finishTransaction(transaction) notifyIsPurchased(transaction: transaction) break case .deferred, .purchasing: break default: break } } } private func notifyIsPurchased(transaction: SKPaymentTransaction) { refreshSubscriptionsStatus(callback: { self.successBlock?() self.cleanUp() }) { (error) in // couldn't verify receipt self.failureBlock?(error) self.cleanUp() } } func cleanUp(){ self.successBlock = nil self.failureBlock = nil } } 

成功订阅后,将调用委托方法,在该方法中,事务具有purchased状态。


但是,如何确定订阅的到期日期? 为此,请单独向Apple请求。


检查订阅状态


使用对Apple的verifyReceipt POST请求验证了verifyReceipt ,我们将加密的支票作为base64编码的字符串作为参数发送,并且在响应中,我们以JSON格式收到了相同的支票。 在数组中,键latest_receipt_info将列出每个订阅的每个期间的所有交易,包括试用期。 我们只能解析答案并获取每个产品的当前到期日期。


在WWDC 2017上,他们增加了使用verifyReceipt请求中的exclude-old-transactions密钥仅接收每个订阅的当前检查的verifyReceipt

 func refreshSubscriptionsStatus(callback : @escaping SuccessBlock, failure : @escaping FailureBlock){ // save blocks for further use self.refreshSubscriptionSuccessBlock = callback self.refreshSubscriptionFailureBlock = failure guard let receiptUrl = Bundle.main.appStoreReceiptURL else { refreshReceipt() // do not call block yet return } #if DEBUG let urlString = "https://sandbox.itunes.apple.com/verifyReceipt" #else let urlString = "https://buy.itunes.apple.com/verifyReceipt" #endif let receiptData = try? Data(contentsOf: receiptUrl).base64EncodedString() let requestData = ["receipt-data" : receiptData ?? "", "password" : self.sharedSecret, "exclude-old-transactions" : true] as [String : Any] var request = URLRequest(url: URL(string: urlString)!) request.httpMethod = "POST" request.setValue("Application/json", forHTTPHeaderField: "Content-Type") let httpBody = try? JSONSerialization.data(withJSONObject: requestData, options: []) request.httpBody = httpBody URLSession.shared.dataTask(with: request) { (data, response, error) in DispatchQueue.main.async { if data != nil { if let json = try? JSONSerialization.jsonObject(with: data!, options: .allowFragments){ self.parseReceipt(json as! Dictionary<String, Any>) return } } else { print("error validating receipt: \(error?.localizedDescription ?? "")") } self.refreshSubscriptionFailureBlock?(error) self.cleanUpRefeshReceiptBlocks() } }.resume() } 

在该方法的开头,您可以看到存在一个检查,该检查是否存在该检查的本地副本。 本地检查可能不存在,例如,是否通过iTunes安装了该应用程序。 如果没有检查,我们将无法执行verifyReceipt请求。 我们需要首先获取当前的本地检查,然后再次尝试对其进行验证。 使用SKReceiptRefreshRequest类完成检查的更新:


 private func refreshReceipt(){ let request = SKReceiptRefreshRequest(receiptProperties: nil) request.delegate = self request.start() } func requestDidFinish(_ request: SKRequest) { // call refresh subscriptions method again with same blocks if request is SKReceiptRefreshRequest { refreshSubscriptionsStatus(callback: self.successBlock ?? {}, failure: self.failureBlock ?? {_ in}) } } func request(_ request: SKRequest, didFailWithError error: Error){ if request is SKReceiptRefreshRequest { self.refreshSubscriptionFailureBlock?(error) self.cleanUpRefeshReceiptBlocks() } print("error: \(error.localizedDescription)") } 

检查更新是refreshReceipt()函数中实现的。 如果检查成功更新,则将requestDidFinish(_ request : SKRequest)委托方法requestDidFinish(_ request : SKRequest) ,该方法refreshSubscriptionsStatus调用refreshSubscriptionsStatus方法。


购买信息的解析是如何实现的? 我们返回一个JSON对象,其中有一个嵌套的事务数组(通过latest_receipt_info键)。 我们遍历该数组,使用expires_date键获取到期日期,如果该日期尚未到达,则将其保存。


 private func parseReceipt(_ json : Dictionary<String, Any>) { // It's the most simple way to get latest expiration date. Consider this code as for learning purposes. Do not use current code in production apps. guard let receipts_array = json["latest_receipt_info"] as? [Dictionary<String, Any>] else { self.refreshSubscriptionFailureBlock?(nil) self.cleanUpRefeshReceiptBlocks() return } for receipt in receipts_array { let productID = receipt["product_id"] as! String let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd HH:mm:ss VV" if let date = formatter.date(from: receipt["expires_date"] as! String) { if date > Date() { // do not save expired date to user defaults to avoid overwriting with expired date UserDefaults.standard.set(date, forKey: productID) } } } self.refreshSubscriptionSuccessBlock?() self.cleanUpRefeshReceiptBlocks() } 

我给出了一个简单的示例,说明如何提取订阅的当前到期日期。 没有错误处理,例如,没有检查退货(添加了取消日期 )。


要确定订阅是否处于活动状态,只需将当前日期与用户默认值(按产品密钥)中的日期进行比较即可。 如果它不存在或少于当前日期,则该订阅被视为无效。


 func expirationDateFor(_ identifier : String) -> Date?{ return UserDefaults.standard.object(forKey: identifier) as? Date } let subscriptionDate = IAPManager.shared.expirationDateFor("YOUR_PRODUCT_ID") ?? Date() let isActive = subscriptionDate > Date() 

事务恢复在一行SKPaymentQueue.default().restoreCompletedTransactions() 。 此函数通过func paymentQueue(**_** queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction])调用委托方法func paymentQueue(**_** queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction])恢复所有已完成的事务。


从事务恢复更新支票有什么区别?


两种方法都有助于恢复您的购买数据。 但是它们有什么区别? wwdc video有一个很棒的桌子:


从WWDC恢复购买的两种方法的差异表
从WWDC恢复购买的两种方法的差异表


在大多数情况下,您只需要使用SKReceiptRefreshRequest() ,因为我们仅对收到用于后续到期日期计算的支票感兴趣。


对于自动续订的订阅,交易本身对我们而言并不重要,因此仅使用支票更新就足够了。 但是,在某些情况下,您需要使用事务恢复方法:应用程序在购买时下载内容(Apple托管的内容),还是仍然支持iOS 7以下的版本。


购物测试(沙盒测试)


以前,要测试购买,您必须从iPhone设置的App Store登录。 这非常不方便(例如,整个Apple音乐库被删除了)。 但是,现在不需要这样做:沙箱帐户现在与主帐户分开存在。



与在App Store中进行实际购买相比,购买过程相似,但是有一些要点:


  • 您将始终需要通过系统窗口输入登录密码。 仍不支持使用Touch ID / Face ID进行购买。


  • 如果在正确输入登录名和密码后,系统一次又一次要求输入登录密码, 请单击“取消” ,最小化应用程序,然后重试。 看起来像胡话,但它适用于许多人。 但是有时在第二次输入密码后,该过程仍然继续。


  • 您将无法以任何方式测试退订过程。


  • 订阅期的持续时间远少于实际时间。 并且每天更新不超过6次。



实际持续时间测试时间
1个星期3分钟
1个月5分钟
2个月10分钟
3个月15分钟
6个月30分钟
1年1小时

iOS 13中的StoreKit有什么新功能?


在新版本中-仅SKStorefront类,它提供有关用户在App Store中注册的国家/地区的信息。 这对于在不同国家/地区使用不同订阅的开发人员可能有用。 以前,每个人都通过地理位置或设备区域进行检查,但这并未给出准确的结果。 现在,在App Store中查找国家/地区非常容易: SKPaymentQueue.default().storefront?.countryCode 。 如果在购买过程中App Store中的国家/地区发生变化,则还会添加方法委托。 在这种情况下,您可以自己继续或取消购买过程。


使用订阅时的陷阱


  • Apple不建议直接从设备检查支票。 他们在WWDC (从5:50开始)多次谈论了这一点,并且在文档中对此进行了说明 。 这是不安全的,因为攻击者可以使用中间人攻击来拦截数据。 检查检查的正确方法是使用服务器进行本地验证。
  • 检查到期日期存在问题。 如果您不使用服务器,则可以将设备上的系统时间更改为较旧的时间,然后我们的代码将给出错误的结果-订阅将被视为活动状态。 如果这不适合您,那么您可以使用发布准确世界时间的任何服务。
  • 并非所有用户都可以免费试用。 用户可以在一段时间后重新安装该应用程序,并且该应用程序将显示试用版照常可用。 更新检查,验证并在JSON中检查该用户是否有试用版是正确的。 许多人没有。
  • 如果用户要求退款,则cancellation_date将被添加到订阅JSON中,但expires_date将保持不变。 因此,重要的是始终检查是否有expires_date字段的存在,这是优于expires_date更好的选择。
  • 每次启动应用程序时都不值得更新支票,因为,首先,这毫无意义,其次,用户很可能会看到Apple ID密码输入窗口。 值得更新支票,例如,当用户本人单击“恢复购物”按钮时。
  • 如何确定在什么时候值得验证支票以获取订阅的当前到期日期? 您可以在每次开始时或仅在订阅结束时验证支票。 但是,如果您仅在订阅结束时检查支票,则已发出退款的用户将可以免费使用您的应用程序,直到该期限结束为止。

结论


希望本文对您有所帮助。 我不仅尝试添加代码,还试图解释开发过程中的细微之处。 完整的类代码可在此处下载。 对于初学者和想要了解更多有关一切工作原理的开发人员的知识,本课程将非常有用。 对于实时应用程序,建议使用更严格的解决方案,例如SwiftyStoreKit


是否想在10分钟内在iOS应用中实现订阅? 整合Apphud并:
  • 仅使用一种方法进行购买;
  • 自动跟踪每个用户的订阅状态;
  • 轻松整合订阅优惠
  • 将订阅事件发送到Amplitude,Mixpanel,Slack和Telegram,并考虑到用户的本地货币;
  • 降低应用程序的客户流失率并返回未订阅的用户。

Source: https://habr.com/ru/post/zh-CN456602/


All Articles