威立山

记录心路历程

0%

iOS应用内购买IAP

iOS应用内购买IAP

最近苹果针对iOS应用软件开发者的指引政策进行了修改,,第一次明确指出用户支付小费或者打赏,必须通过苹果的支付渠道,也就是不能绕开苹果iOS管理机制。微信在iOS版本取消了打赏功能,知乎则是向苹果低了头…
苹果在相关文档中提到,如果开发者想在iOS软件提供下述功能(比如用户订阅、游戏内虚拟货币、获取高端会员内容、或者提供完整的功能版本等),则开发者必须使用软件内购买。

本文主要以自动订阅类型产品为主,其他类型产品为辅

准备

In-App Purchase, 简称IAP,允许在iOS app与macOS app中出售商品。

  • 新建一个app,然后添加App 内购买项目,主要分四种内购买项目类型。
  1. 消耗品(Consumable products):会越用越少的,比如游戏内金币等。
  2. 不可消耗品(Non-consumable products):比如游戏中跑车,简单来说就是一次购买,终身可用(用户可随时从App Store restore)
  3. 自动更新订阅品(Auto-renewable subscriptions):和不可消耗品的不同点是有失效时间。比如一整年的付费周刊。在这种模式下,开发者定期投递内容,用户在订阅期内随时可以访问这些内容。订阅快要过期时,系统将自动更新订阅。
  4. 非自动更新订阅品(Non-renewable subscriptions):一般使用场景是从用户从IAP购买后,购买信息存放在自己的开发者服务器上。失效日期/可用是由开发者服务器自行控制的,而非由App Store控制,这一点与自动更新订阅品有差异。
  • 添加APP内购买项目
    内购项目

  • 设置税务和银行卡信息

    银行卡

  • 添加沙盒测试账号
    沙盒测试账号

使用StoreKit API

  1. 获取产品列表,展现的产品可以后台配置(Consumable可消耗商品举例)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    // MARK: - FETCH AVAILABLE IAP PRODUCTS
    func fetchAvailableProducts() {
    Toast(text: "fetching available products").show()
    // Put here your IAP Products ID's
    let productIdentifiers = NSSet(objects:
    COINS_PRODUCT_ID,PREMIUM_PRODUCT_ID
    )

    productsRequest = SKProductsRequest(productIdentifiers: productIdentifiers as! Set<String>)
    productsRequest.delegate = self
    productsRequest.start()
    }

    // MARK: - REQUEST IAP PRODUCTS
    func productsRequest (_ request:SKProductsRequest, didReceive response:SKProductsResponse) {
    if (response.products.count > 0) {
    Toast(text: "fetch \(response.products.count) product success").show()

    iapProducts = response.products

    // 1st IAP Product (Consumable) ------------------------------------
    let firstProduct = response.products[0] as SKProduct

    // Get its price from iTunes Connect
    let numberFormatter = NumberFormatter()
    numberFormatter.formatterBehavior = .behavior10_4
    numberFormatter.numberStyle = .currency
    numberFormatter.locale = firstProduct.priceLocale
    let price1Str = numberFormatter.string(from: firstProduct.price)

    // Show its description
    consumableLabel.text = firstProduct.localizedDescription + "\nfor just \(price1Str!)"
    }
    }

  2. 购买产品,首先检查设备是否支持内购买,然后将交易加入支付队列

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    // MARK: - MAKE PURCHASE OF A PRODUCT
    func canMakePurchases() -> Bool { return SKPaymentQueue.canMakePayments() }
    func purchaseMyProduct(product: SKProduct) {
    if self.canMakePurchases() {
    Toast(text: "begin purchase for \(product.localizedTitle)").show()

    let payment = SKPayment(product: product)
    SKPaymentQueue.default().add(self)
    SKPaymentQueue.default().add(payment)

    print("PRODUCT TO PURCHASE: \(product.productIdentifier)")
    productID = product.productIdentifier


    // IAP Purchases dsabled on the Device
    } else {
    UIAlertView(title: "IAP Tutorial",
    message: "Purchases are disabled in your device!",
    delegate: nil, cancelButtonTitle: "OK").show()
    }
    }
  3. 处理支付结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// MARK:- IAP PAYMENT QUEUE
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
for transaction:AnyObject in transactions {
if let trans = transaction as? SKPaymentTransaction {
let msg = self.descFromTransactionState(trans.transactionState);
if(msg != nil){
Toast(text: msg).show()
}

switch trans.transactionState {

case .purchased:
SKPaymentQueue.default().finishTransaction(transaction as! SKPaymentTransaction)

guard let receiptUrl = Bundle.main.appStoreReceiptURL else {
SKPaymentQueue.default()
return
}

let receipt = try? Data.init(contentsOf: receiptUrl)

//自动订阅类型需要验证receipt 发到 apple
guard let receiptBase64String = receipt?.base64EncodedString() else {
return
}
let dict = ["receipt-data": receiptBase64String]
let url:URL? = URL(string:"https://sandbox.itunes.apple.com/verifyReceipt")
var request = URLRequest.init(url: url!)
request.httpMethod = "POST"
let data = try? JSONSerialization.data(withJSONObject: dict, options: JSONSerialization.WritingOptions.prettyPrinted)
request.httpBody = data

URLSession.shared.dataTask(with: request, completionHandler: { (data, response, error) in
if(data != nil){
let JSONObj = try? JSONSerialization.jsonObject(with: data!, options: .allowFragments)
let str = String(data: data!, encoding: .utf8)
print(str!)
print(JSONObj!)
}

}).resume()

break

case .failed:
SKPaymentQueue.default().finishTransaction(transaction as! SKPaymentTransaction)
break
case .restored:
SKPaymentQueue.default().finishTransaction(transaction as! SKPaymentTransaction)
break

default: break
}}}
}

使用SwiftyStoreKit库简化调用API

  • SwiftyStoreKit对StoreKit的api进行封装,调用起来更加方便。具体api请参考SwiftyStoreKit Github
  • 需要注意的购买商品时api 区分原子性和非原子性;原子性是购买成功后,购买的内容能立即传递给用户界面,而非原子性是指购买的内容需要从服务器下载,需要下载完毕,才调用完成交易的api来结束交易
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
SwiftyStoreKit.purchaseProduct("com.musevisions.SwiftyStoreKit.Purchase1", quantity: 1, atomically: false) { result in
switch result {
case .success(let product):
// fetch content from your server, then:
if product.needsFinishTransaction {
SwiftyStoreKit.finishTransaction(product.transaction)
}
print("Purchase Success: \(product.productId)")
case .error(let error):
switch error.code {
case .unknown: print("Unknown error. Please contact support")
case .clientInvalid: print("Not allowed to make the payment")
case .paymentCancelled: break
case .paymentInvalid: print("The purchase identifier was invalid")
case .paymentNotAllowed: print("The device is not allowed to make the payment")
case .storeProductNotAvailable: print("The product is not available in the current storefront")
case .cloudServicePermissionDenied: print("Access to cloud service information is not allowed")
case .cloudServiceNetworkConnectionFailed: print("Could not connect to the network")
case .cloudServiceRevoked: print("User has revoked permission to use this cloud service")
}
}
}

自动订阅类型总结

  1. 注册在AppDelegate中didFinishLaunchingWithOptions中,注册交易观察者,这样app就可以收到支付队列的通知(自动续订时会被调用)
  2. 沙盒测试周期会被加速,订阅周期为1周,在沙盒环境下只需3分钟,1个月只需5分钟…苹果考虑的挺周到的
  3. 一个沙盒测试账号一天只能被更新订阅5次,测试自动订阅时尤其注意,可以使用多个测试账号(当时我以为没能自动订阅呢)
  4. 客户端先将receipt发给服务端,服务端去验证receipt 有时返回 210007 状态码,意味着这个receipt是测试环境的,但发送到了正式环境https://buy.itunes.apple.com/verifyReceip。而且审核时,苹果人员也是用沙盒环境测试,如何切换正式和测试环境呢?解决方案:服务端先走正式环境,如果返回21007就再走沙盒环境去验证。
  5. 关于用户退款,需要服务端定期去向App Store去校验receipt,在返回来的数据中检查是否有 cancellation_date 字段,如果有值,则购买不成立。

欢迎指正😝

参考

In-App Purchases in iOS With Swift 3
苹果文档 About In-App Purchase
苹果文档 验证Receipt