在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 ID ( App 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