iOS-自动更新订阅IAP浅谈(设置和测试)

本文由CocoaChina译者Leon(社区ID)翻译
作者:Jaz Garewal
原文:How to Set Up and Test an Auto-Renewable Subscription for an iOS App
转载请保留原文内容和所有链接。


自动更新的订阅是iOS内购形式的一种。它可以让app在一个时间段内提供内容或功能。我在之前的帖子中聊过自动更新订阅类型的IAP的准则,还聊过Apple以后可能做出的改进。现在让我们来看一下如何实际地在App中设置并测试自动更新的订阅。

在iTunes Connect后台设置自动更新的订阅

事实上,实现自动更新的订阅的流程和其他IAP并没有什么不同。有许多关于其他类型IAP的教程,比如:这一篇,主要讲的是非更新的IAP类型(你可以直接跳到教程中段,讲"添加订阅到你的产品列表中")。设置自动更新订阅和非自动更新订阅的不同之处在于:自动更新订阅的每个订阅周期都在一个IAP实体中设置,而非自动更新订阅的每个订阅周期都需要单独创建一个产品(比如3个月订阅、6个月订阅等),每个周期都有针对的一个product ID。

在App中实现自动更新的内购项目

我们发现,使用IAP helper类来实现自动更新订阅式内购是最好的选择,这和实现其他类型的内购,在策略上并无不同。但是,自动更新订阅式内购对收据(receipt)管理有着额外的要求,我们可以再创建特定的helper类,来分别处理这方面的逻辑。这样,helper类就可以处理各种各样的内购项目,而不局限于自动更新式订阅内购。在该教程中,简洁起见,我们把所有的方法都放在同样的一个IAP helper类中。而在实际操作中,我们建议把收据管理的代码放在单独的类中。考虑到收据交换的过程中,会涉及到私钥,也可能将这部分的逻辑放到server处理。稍后我们会具体讲解。

对于IAPHelper类来说,我们需要import StoreKit的framework,并声明其遵循SKProductsRequestDelegate和SKPaymentTransactionObserver协议:

import UIKit
import StoreKit
class IAPHelper: NSObject, SKProductsRequestDelegate, SKPaymentTransactionObserver

然后声明初始属性(后面我们再添加其他属性):

var productID:NSSet = NSSet(object: "string")
var productsRequest:SKProductsRequest = SKProductsRequest()
var products = String : SKProduct

在App中,IAPHelper需要成为一个单例。在Swift 1.2中,有个简洁的做法,比Objective-C中的dispatch_once方式更简单。

1
static let sharedInstance = IAPHelper()

很简单吧,Objective-C中的单例实现相当繁琐,而使用Swift,单例简洁到超乎想象。

现在属性都准备好了,让我们看看方法的实现。

首先,我们需要从App Store上请求产品的标识,然后启动产品请求。因此,需要如下的public方法:

func requestProductsWithProductIdentifiers(productIdentifiers: NSSet) {
 
let productIdentifiers:NSSet = NSSet(objects: "com.ourApp.monthly", "com.ourApp.annually")
 
productsRequest = SKProductsRequest(productIdentifiers: productIdentifiers as Set)
productsRequest.delegate = self;
productsRequest.start()
 
}

设置了SKProductsRequest的delegate后,还需要一个方法从respose中提取信息,实现以下的delegate方法:

func productsRequest(request: SKProductsRequest!, didReceiveResponse response: SKProductsResponse!) {
 
    if let productsFromResponse = response.products as? [SKProduct] {
     
        for product in productsFromResponse {
        products[product.productIdentifier] = product
        }
    }
 }

现在,准备工作已经做好了,需要通过IAPHelper的shareInstance,调用requestProducts方法。

首先,我们需要添加一个新的属性--一个闭包。通过它传递购买的状态,根据状态,再进行特别处理。闭包有一个Bool参数(true表示成功,false表示失败),还有可选的String类型参数,在购买失败时,可以传递一些错误信息。

var purchaseCompleted: ((Bool, String ) -> Void)

购买产品的第一步,要通过一个方法获取购买的请求,比如点击一个按钮,来启动订阅的购买。

func beginPurchaseFor(productIDString: String, purchaseSucceeded:(Bool, String?) -> Void) {
 
    if SKPaymentQueue.canMakePayments() {
     
        if let product: SKProduct = products[productIDString] {
         
            purchaseCompleted = purchaseSucceeded
            var payment = SKPayment(product: product)
            SKPaymentQueue.defaultQueue().addTransactionObserver(self)
            SKPaymentQueue.defaultQueue().addPayment(payment);
        } else {
            purchaseSucceeded(false, "Product \(productIDString) not found")
        }
     } else {
        purchaseSucceeded(false, "User cannot make payments")
    }
 }

通过第一行的方法签名可以看到,第二个参数是作为回调方法来使用的,它和我们的purchaseCompleted属性一样。

方法中,我们先验证该设备是否可以进行IAP购买,如果可以,进一步验证是不是有一个productID和传入的匹配。若验证都通过,就设置purchaseSucceeded参数到purchaseCompleted属性,这样就可以将状态通过purchaseCompleted属性进行回传。接下来,我们创建一个SKPayment对象,并将self(我们的IAPHelper实例)设置为交易观察者(transaction observer),然后将payment添加到default的支付队列(payment queue)中。

上述过程中,如果有一项检查失败(如设备不允许内购或者product ID不匹配),我们都可以通过purchaseSucceeded回调传递false的状态,并附上错误的详细信息(如"产品(代码xxx)不存在"或者"用户不允许进行内购买")。通过这些,再判断下一步的处理。

假定产品购买成功,得通过一个方法来监听购买的响应事件。在这里,需要实现SKPaymentTransactionObserver协议的paymentQueue方法。但是这里我把详细说明分成两部分,首先,看看成功购买的处理:

func paymentQueue(queue: SKPaymentQueue!, updatedTransactions transactions: [AnyObject]!) {
    for transaction: AnyObject in transactions {
     
        if let trans:SKPaymentTransaction = transaction as? SKPaymentTransaction {
         
            switch trans.transactionState {
                case .Purchased:
                SKPaymentQueue.defaultQueue().finishTransaction(trans)
                if let receiptURL = NSBundle.mainBundle().appStoreReceiptURL where
                NSFileManager.defaultManager().fileExistsAtPath(receiptURL.path!) 
{
                    if let purchaseSuccessCallback = purchaseCompleted {
                        purchaseSuccessCallback(true, nil)
                    }
                     
               self.isPurchasing = false
               self.receiptValidation()
        } else {
         
            if !isRefreshingReceipt {
             
                self.isPurchasing = false
                isRefreshingReceipt = true
                let request = SKReceiptRefreshRequest(receiptProperties: nil)
                request.delegate = self
                request.start()
                 
                if let _ = purchaseCompleted {
                 
                    purchaseCompleted**(true, nil)
                }
            }
        }
            break

假定购买过程顺利,我们高兴地得到了"已购买"状态,不用再去纠结标准IAP实现的细节。在上面的代码中你可以看到,我们需要那个收据信息。交易成功后,app可以通过appStoreReceiptURL得到用户的本地收据地址,如果可以得到收据的话,我们就可以继续进行下一步receiptValidation,这是设置、管理用户订阅的前提。在这个时间点,我们通过purchaseSuccessCallBack属性回调的信息有:bool类型的true值,表明购买已成功;nil的error message。这些好消息将被发送给IAPHelper的使用者,供其后期处理。

如果,万一,购买成功,但是还是得不到收据信息,我们可以通过新建SKReceiptRefreshRequest的方式来在后面踢一脚。

这里我们先跳过paymentQueue,看一下SKReceiptRefreshRequest的requestDidFinish delegate方法。

if let appStoreReceiptURL = NSBundle.mainBundle().appStoreReceiptURL where
    NSFileManager.defaultManager().fileExistsAtPath(appStoreReceiptURL.path!) {
     
        if 
NSFileManager.defaultManager().fileExistsAtPath(appStoreReceiptURL.path!) {
 
            self.receiptValidation()
             
            if let purchaseSuccessCallback = purchaseCompleted {
             
                purchaseSuccessCallback(true, nil)
                 
            }
         }
    } else {
        if let purchaseSuccessCallback = purchaseCompleted {
            purchaseSuccessCallback(false, "Cannot find receipt")
        }
    }
}

当在requestDidFinish方法中获得响应时,我们可以检查appStoreReceiptURL路径上是否有相关文件存在。若存在,我们通知那些正在等待回调的对象,告知它们购买已经成功,同时,可以继续进行收据的有效性验证。

如果这时还是无法找到收据,接着还是得返回错误的处理方法,传递false的Bool值,还有"无法找到收据"的错误信息--我们的IAPHelper在哭诉。有点像虚拟的收银机被卡住了,无法打印。这样,相关的对象可以处理这些IAPHelper收集的错误。

哦,这个话题太沉重了,我们来看点高兴的。

接下来,看看如果我们的paymentQueue相关的delegate方法受到"Failed"状态,该如何处理。什么?不是说高兴的事么?我感觉世界都变得阴暗起来了。

不管怎样,让我们继续完成我们的func paymentQueue(queue: SKPaymentQueue!, updatedTransactions transactions: [AnyObject]!)方法吧:

case .Failed:
        
        self.isPurchasing = false
         
            if let _ = purchaseCompleted {
             
                var errorMessage: String?
                 
                    if let _ = transaction.error {
                     
                        errorMessage = transaction.error.localizedDescription
                         
                     }
                      
                 purchaseCompleted(false, errorMessage)
                  
            }
             
        SKPaymentQueue.defaultQueue().finishTransaction(trans)
        SKPaymentQueue.defaultQueue().removeTransactionObserver(self)
        break
         
       default:
       break
           }
       }
    }
}

这么说,方法失败了?这不是Apple的API吗?它运转正常,顺风顺水,但还是失败了。一定是打开方式不对。。。接下来,退回去,看着镜子,想想该对Apple大神做个什么表情。手腕上的Apple Watch已经不见了,你知道是怎么回事。

生气归生气,还是得看看错误处理。收到错误后,先通过localizedDescription拿到本地化的错误信息。将false和这个错误信息都回调回去(满满的都是泪)。接下来告诉SKPaymentQueue.defaultQueue()支付战役已经结束,我们输了。

以上内容将贯穿整个自动更IAP的实现过程。下面我们来看看为何我们需要收据信息,并学习如何进行处理。

自动更新IAP的订阅管理

在深入研究代码之前,让我们先来理解一下自动更新IAP中订阅管理的工作原理,看看它对App的意义在何处。

Apple并没有在iOS系统中提供任何关于订阅详情的API或REST接口,也没有提供可以查看订阅更新或者取消的回调方法。它只提供了一个API:当你传递用户本地的收据和一个"shared secret"(共享秘钥,在iTunes Connect后台创建)时,返回一个包含用户购买历史的JSON数据,它里面就包含了订阅的详细信息。

我们需要通过某种方式来持续检测订阅,通过周期性的获取这个JSON数据,解析并查看其中的过期时间是否改变,以此来检测订阅是否更新或者取消。如果过期时间已经改变,则订阅被更新;如果过期时间未改变,且当前已经超过了过期时间,则可视为订阅未被更新。这种情况下,可以假定订阅已经被取消(稍后可以通过解析返回数据,获得取消日期--cancellation date,来做进一步判定),也可以认为从Apple返回的支付信息出现错误。不管是哪种情况,都可以认为当前的订阅是无效的,对于这个用户来说,App中的订阅内容是不可用的。

以上逻辑可以在本地处理,也可以自己设置服务器,或者使用公开的后端服务,比如Parse。

说的够多了,上代码!

错了,现在还不到写代码的时候,首先,需要在iTunes Connect后台创建shared secret。先跳转到app的IAP设置部分,在IAP列表下面,你会看到"View or generate a shared secret",点击即可:

iOS-自动更新订阅IAP浅谈(设置和测试)_第1张图片

好了,现在可以看看收据处理的代码了。

第一个方法是:

if let receiptPath = NSBundle.mainBundle().appStoreReceiptURL?.path where
    NSFileManager.defaultManager().fileExistsAtPath(receiptPath), let receiptData 
= NSData(contentsOfURL: 
    NSBundle.mainBundle().appStoreReceiptURL!), let receiptDictionary = ["receipt-
data" : 
    receiptData.base64EncodedStringWithOptions(nil), "password" : "your shared 
secret"], let requestData = 
    NSJSONSerialization.dataWithJSONObject(receiptDictionary, options: nil, 
error:&error) as NSData! {
 
        let storeURL = NSURL(string: 
"https://sandbox.itunes.apple.com/verifyReceipt")!
        var storeRequest = NSMutableURLRequest(URL: storeURL)
        storeRequest.HTTPMethod = "POST"
        storeRequest.HTTPBody = requestData
         
        let session = NSURLSession(configuration: 
NSURLSessionConfiguration.defaultSessionConfiguration())
 
        session.dataTaskWithRequest(storeRequest, completionHandler: { (data: 
NSData!, response: NSURLResponse!,
        connection: NSError!) → Void in
         
           if let jsonResponse: NSDictionary = 
NSJSONSerialization.JSONObjectWithData(data, options:
           NSJSONReadingOptions.MutableContainers, error: &error) as? 
NSDictionary, let expirationDate: NSDate = 
           self.expirationDateFromResponse(jsonResponse) {
            
               self.updateIAPExpirationDate(expirationDate)
                
           }
       })
    }
}

方法中声明了optional类型的NSError变量。接下来是温习Swift 1.2中optional类型的chaining capabilities的好机会。按照链式的结构,先判断是否存在appStoreReceiptURL(在购买完成时,我们已经验证过了这个属性,但是Swift中,多一层检查,就多一重保障)。接下来,检查这个path下是否有文件存在,如果存在的话,就通过该文件创建receiptData对象。然后,再填上shared secret,就创建了一个receiptDictionary JSON数据。

上述过程完成后,就可以用requestData创建NSMutableRequest。

在本例子中,我们通过沙箱URL来验证收据,在实际的环境中,你需要把"sandbox"换成"buy"。

我们将requestData数据发送给Apple,等待它对completionHandler的回调。回调收到后,继续通过optional chain方式检查返回数据:如果第一部分(这里就是data转到JSON)存在的话,就用expirationDateFromResponse方法在里面找到expiration date数据。

进展顺利的话,我们就可以在适当的时机处理该过期时间--检查该时间和我们之前存储的是否一致(如果一致,表明订阅已更新),若过期时间等于或者早于当前日期,则意味着,订阅未更新,很可能已经被取消,可以通过JSON数据中的Cancellation Date进一步检查。

现在我们来创建expirationDateFromResponse,从JSON返回数据中解析过期时间:

func expirationDateFromResponse(jsonResponse: NSDictionary) → NSDate? {
 
   if let receiptInfo: NSArray = jsonResponse["latest_receipt_info"] as? NSArray {
    
      let lastReceipt = receiptInfo.lastObject as! NSDictionary
      var formatter = NSDateFormatter()
      formatter.dateFormat = "yyyy-MM-dd HH:mm:ss VV"
       
      let expirationDate: NSDate = 
formatter.dateFromString(lastReceipt["expires_date"] as! String) as NSDate!
 
      return expirationDate
   } else {
      return nil
   }
}

这个方法中,先看看"latestreceiptinfo"的key是否对应NSArray类型的数据。如果有的话,我们从数组中获取最后一个对象,再从这个对象中查找key"expires_date"对应的对象,将其转换成NSDate类型。上述过程无误的话,就可以返回expirationDate对象。过程中出错的话,就直接返回nil。

接下来,我们来学习如何测试自动更新订阅IAP。

测试自动更新类IAP

完成收据验证代码只是第一步,问题是如何验证我们的验证代码?答案是:测试。你的代码依赖于外部的模块(IAP服务器),而你无法控制,只能通过一些工具来进行测试。

之前提到过,有两个URL可以用来验证收据,一个针对测试环境,另外一个针对实际环境。沙箱模式下购买自动更新订阅类IAP和测试其他类型的IAP过程类似。只需要在iTunes Connect后台创建沙箱测试账户(Users and Roles功能下),可以创建多个测试账户,以备测试之需。然后就可以用沙箱账户在设备上完成内购。

测试自动更新类IAP时,有一个不同之处:该购买是有周期的。订阅会于5次更新后作废。看到这里你肯定会想:"等等,要是我设置了每月的订阅,要测试过期得等到5个月后?"实际上,自动更新的IAP在沙箱环境下,周期是会加速的,更新是按分钟或小时计算。如果你要测试一月的订阅,更新周期是5分钟。因此要测试过期(5次更新后),等待25分钟即可。

iOS-自动更新订阅IAP浅谈(设置和测试)_第2张图片

另外,需要注意的一点是:在测试环境下,订阅并不是每次都自动更新。我估算大概有三分之一的概率,月度订阅会在5分钟后被标记一次,然后,就直接过期,并不会被自动更新。记得我提醒过你多创建几个测试账户吗?那就是为了方便在测试订阅更新时使用。要是你需要测试过期处理的代码,就一直用过期的那个账户,这样就可以将等待时间减少到原来的五分之一。

也就是说,在测试一周自动更新订阅时,每次都等15分钟并不明智。另外,如何测试订阅取消?坏消息是,你无法测试。当前,测试环境的订阅无法手动进行取消。因此,最好的方式是:创建多个沙箱测试账号,用一个先测试,再用其他测试。等第一个快要过期时,切换过去测试。

结论备注

学到这里,你不仅知道了如何实现自动更新类IAP,还知道了如何进行测试。该教程有一点没有讲到:如何将收据验证码,和IAP的shared secret key保存到服务器。这是我们后面需要研究的。现在,希望现有知识可以帮助你顺利实现订阅功能。

你可能感兴趣的:(译文,IOS开发,名文收藏)