StoreKit 框架介绍
一、StoreKit 能做什么?
- In-App Purchase
- 提供和促进内容和服务的应用内购买。
- Apple Music
- 检查用户的Apple Music功能并提供订阅服务。
- Recommendations and reviews
- 为第三方内容提供推荐,让用户对你的应用进行评价和评论。
头文件一览
#import <StoreKit/SKAdNetwork.h>
#import <StoreKit/SKArcadeService.h>
#import <StoreKit/SKCloudServiceController.h> // Apple Music
#import <StoreKit/SKCloudServiceSetupViewController.h> // Apple Music
#import <StoreKit/SKDownload.h>
#import <StoreKit/SKError.h>
#import <StoreKit/SKPayment.h>
#import <StoreKit/SKPaymentDiscount.h>
#import <StoreKit/SKPaymentQueue.h>
#import <StoreKit/SKPaymentTransaction.h>
#import <StoreKit/SKProduct.h>
#import <StoreKit/SKProductDiscount.h>
#import <StoreKit/SKProductsRequest.h>
#import <StoreKit/SKProductStorePromotionController.h>
#import <StoreKit/SKReceiptRefreshRequest.h>
#import <StoreKit/SKRequest.h>
#import <StoreKit/SKStorefront.h>
#import <StoreKit/SKStoreProductViewController.h>
#import <StoreKit/SKStoreReviewController.h> // Recommendations and reviews
#import <StoreKit/StoreKitDefines.h>
我们将主要介绍日常开发中常用的内购和 APP 推荐评价功能。
二、In-App Purchase
为什么要使用In-App Purchase
(苹果内购)的方式购买在APP内提供的服务和内容?因为这是苹果强制规定的,每产生一份交易,苹果将会从中抽取30%
的佣金费用。苹果要求:
- 如果您想要在 app 内解锁特性或功能 (解锁方式有:订阅、游戏内货币、游戏关卡、优质内容的访问权限或解锁完整版等),则必须使用 App 内购买项目。
- App 不得使用自身机制来解锁内容或功能,如许可证密钥、增强现实标记、二维码等。
- App 及对应元数据不得包含指引用户使用非 App 内购买项目机制进行购买的按钮、外部链接或其他行动号召用语。
- …
- 如果 app 允许用户购买将在 app 之外使用的商品或服务,则必须使用 App 内购买项目以外的购买方式来收取相应款项,如 Apple Pay 或传统的信用卡入口。
具体的相关规则请查看App Store 审核指南-App 内购买项目。
1、内购流程
- 用户通过触发APP内购买行为,通过
StoreKit
连接App Store
,发送用户需要购买的商品标识,开始处理支付事务; App Store
完成支付,再通过StoreKit
框架通知APP,传回用户购买的商品和收据;- 为了验证收据,你需要将收据发送到自己的服务器,通过自己的服务器与
App Store
进行验证(也可以在APP中与App Store
验证,但是不安全); - 自己的服务器验证收据有效后,通知APP进行相关UI更新操作。
- 对自动订阅类型的商品,
App Store
还会将相关续订事件发送到服务器上。
2、内购商品类型
针对不同的商品特性,需要创建不同的内购商品。App Store Connect
提供了4中类型的抽象商品。
- 消耗型项目
- 用户可以购买各种消耗型项目 (例如游戏中的生命或宝石) 以继续 app 内进程。消耗型项目只可使用一次,使用之后即失效,必须再次购买。
- 非消耗型项目
- 用户可购买非消耗型项目以提升 app 内的功能。非消耗型项目只需购买一次,不会过期 (例如修图 app 中的其他滤镜)。
- 自动续期订阅
- 用户可购买固定时段内的服务或更新的内容 (例如云存储或每周更新的杂志)。除非用户选择取消,否则此类订阅会自动续期。
- 非续期订阅
- 用户可购买有时限性的服务或内容 (例如线上播放内容的季度订阅)。此类的订阅不会自动续期,用户需要逐次续订。
用户可以在App Store Connect
中添加适合自己商品的抽象商内购品。
对于非消耗型项目和自动续期订阅,苹果允许用户通过内购恢复的方式在多个设备间同步和恢复。当用户购买自动续期订阅或非续期订阅时,您的 app 应当让用户能够在所有设备上访问这一订阅,并让用户能够恢复以前购买的项目。
3、相关API使用
设置交易观察器和付款队列
为了完成苹果内购流程,你需要设置相应的交易观察器,即时监听当前交易的状态。
class StoreObserver: NSObject, SKPaymentTransactionObserver {
....
//Initialize the store observer.
override init() {
super.init()
//Other initialization here.
}
//Observe transaction updates.
func paymentQueue(_ queue: SKPaymentQueue,updatedTransactions transactions: [SKPaymentTransaction]) {
//Handle transaction states here.
}
....
}
通过回调函数paymentQueue(_:updatedTransactions:)
,你可以通过获取SKPaymentTransaction
实例的var transactionState: SKPaymentTransactionState
字段来明确当前交易进度。
public enum SKPaymentTransactionState : Int {
case purchasing = 0 // Transaction is being added to the server queue.
case purchased = 1 // Transaction is in queue, user has been charged. Client should complete the transaction.
case failed = 2 // Transaction was cancelled or failed before being added to the server queue.
case restored = 3 // Transaction was restored from user's purchase history. Client should complete the transaction.
@available(iOS 8.0, *)
case deferred = 4 // The transaction is in the queue, but its final status is pending external action.
}
加入付款队列
let iapObserver = StoreObserver()
import UIKit
import StoreKit
class AppDelegate: UIResponder, UIApplicationDelegate {
....
// Attach an observer to the payment queue.
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
SKPaymentQueue.default().add(iapObserver)
return true
}
// Called when the application is about to terminate.
func applicationWillTerminate(_ application: UIApplication) {
// Remove the observer.
SKPaymentQueue.default().remove(iapObserver)
}
....
}
启动时就将交易观察器添加到付款队列是很重要的。这可以确保在你的 APP 的整个生命周期内,都可以监听交易的相关事件通知,即使你当前不处在 APP 内。如
- 进行内购
- 后台进程订阅
- 交易中断(如果正在进行交易时杀死 APP,那么这次交易之后的状态会在启动 APP 后通过回调函数传回)
获取 App 内购买项目
在进行交易前,你需要先检查框架是否可用。
var isAuthorizedForPayments: Bool {
return SKPaymentQueue.canMakePayments()
}
然后确保你已经在 App Store Connect 中创建了 App 内购买项目。如果还没有创建,可以参照下面链接。
假设你已经创建好了 App 内购买项目,名叫 ProductA。在购买项目前,你需要知道 ProductA 的唯一标识,这是在创建内购项目时就要求输入的字段。对于需要展示在 APP 中购买的项目,你需要维护这些项目的唯一标识。你可以将这些标识维护在 APP 本地中,也可以交给服务器通过接口请求(推荐)获取。
现在我们通过 SKProductsRequest
类来获取 App Store 上的内购项目。
public protocol SKProductsRequestDelegate : SKRequestDelegate {
// Sent immediately before -requestDidFinish:
@available(iOS 3.0, *)
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse)
}
// request information about products for your application
@available(iOS 3.0, *)
open class SKProductsRequest : SKRequest {
// Set of string product identifiers
@available(iOS 3.0, *)
public init(productIdentifiers: Set<String>)
@available(iOS 3.0, *)
weak open var delegate: SKProductsRequestDelegate?
}
@available(iOS 3.0, *)
open class SKProductsResponse : NSObject {
// Array of SKProduct instances.
@available(iOS 3.0, *)
open var products: [SKProduct] { get }
// Array of invalid product identifiers.
@available(iOS 3.0, *)
open var invalidProductIdentifiers: [String] { get }
}
可以看到,SKProductsRequest
初始化参数包含了一个标识数组,这个指的就是你创建的内购项目的商品标识。
fileprivate func fetchProducts(matchingIdentifiers identifiers: [String]) {
// Create a set for the product identifiers.
let productIdentifiers = Set(identifiers)
// Initialize the product request with the above identifiers.
productRequest = SKProductsRequest(productIdentifiers: productIdentifiers)
productRequest.delegate = self
// Send the request to the App Store.
productRequest.start()
}
productRequest
start 后是个异步操作,为了避免 productRequest
被提前释放,你需要强持有这个对象实例。
请求完成后,你可以通过代理func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse)
获取到 SKProductsResponse
对象。该对象包含了一个 SKProduct
实例的数组和一个不可用标识的数组。
// products contains products whose identifiers have been recognized by the App Store. As such, they can be purchased.
if !response.products.isEmpty {
availableProducts = response.products
}
// invalidProductIdentifiers contains all product identifiers not recognized by the App Store.
if !response.invalidProductIdentifiers.isEmpty {
invalidProductIdentifiers = response.invalidProductIdentifiers
}
SKProduct
就是我们需要的东西,包含了内购商品的名称、价格等信息。你可以通过扩展 SKProduct
来显示当地货币价格。
extension SKProduct {
/// - returns: The cost of the product formatted in the local currency.
var regularPrice: String? {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.locale = self.priceLocale
return formatter.string(from: self.price)
}
}
完成 App 内购买
根据获取到的 SKProduct
实例,我们创建一个 SKMutablePayment
对象。
open class SKMutablePayment : SKPayment {
@available(iOS 7.0, *)
open var applicationUsername: String?
@available(iOS 12.2, *)
@NSCopying open var paymentDiscount: SKPaymentDiscount?
@available(iOS 3.0, *)
open var productIdentifier: String
@available(iOS 3.0, *)
open var quantity: Int
@available(iOS 3.0, *)
open var requestData: Data?
@available(iOS 8.3, *)
open var simulatesAskToBuyInSandbox: Bool
}
// Use the corresponding SKProduct object returned in the array from SKProductsRequest.
let payment = SKMutablePayment(product: product)
payment.quantity = 2
设置购买数量为2,然后只需要简单地加入到交易队列中。
SKPaymentQueue.default().add(payment)
恢复 App 内购买项目
当用户购买了非消耗型项目、自动续期订阅、非续期订阅,并希望在其他设备上使用时,可以通过 SKPaymentQueue
的 restoreCompletedTransactions()
来恢复。
@available(iOS 3.0, *)
open func restoreCompletedTransactions()
SKPaymentQueue.default().restoreCompletedTransactions()
处理交易
还记得之前设置的交易观察器吗。当交易进行处于交易队列中时(购买内购项目还是恢复内购项目),StoreKit 就会回调交易观察器的代理方法。每个交易会有5个状态,你需要为每个状态建立对应的处理方法。
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
for transaction in transactions {
switch transaction.transactionState {
case .purchasing: break
// Do not block the UI. Allow the user to continue using the app.
case .deferred: print(Messages.deferred)
// The purchase was successful.
case .purchased: handlePurchased(transaction)
// The transaction failed.
case .failed: handleFailed(transaction)
// There're restored products.
case .restored: handleRestored(transaction)
@unknown default: fatalError(Messages.unknownPaymentTransaction)
}
}
}
值得注意的是,当交易失败是除了用户主动取消的原因外,你需要为用户显示对应的错误提示。
// Do not send any notifications when the user cancels the purchase.
if (transaction.error as? SKError)?.code != .paymentCancelled {
DispatchQueue.main.async {
self.delegate?.storeObserverDidReceiveMessage(message)
}
}
提供给购买内容和结束交易
在收到状态为SKPaymentTransactionState.purchased
或者SKPaymentTransactionState.restored
的交易后,开发者必须为用户提供已购买的功能或者内容。
对于未完成的交易,会一直存在交易队列中。StoreKit
会在 APP 再次启动或者从后台回到到前台时回调交易观察器的代理方法。因此会不断要求用户进行购买授权或者重复调用相关购买逻辑代码。
因此,在收到状态为SKPaymentTransactionState.purchased
或者SKPaymentTransactionState.restored
的交易后开发者应该关闭当前的交易。
// Finish the successful transaction.
SKPaymentQueue.default().finishTransaction(transaction)
4、收据验证
收据验证逻辑可以放在 APP 中或者服务端或者两者结合使用。购买的项目(已完成或者未完成)将会存放在收据中,直到你调用了finishTransaction(_:)
函数。如果需要维护用户的消费记录,你需要自建服务器管理用户的交易记录。对于非消耗型项目、自动续期订阅、非续期订阅项目将会永久保存在收据中。
具体采取哪种方式处理收据验证逻辑,苹果给出了以下建议:
On-device validation | Server-side validation | |
---|---|---|
Validates authenticity of receipt | Yes | Yes |
Includes renewal transactions | Yes | Yes |
Includes additional user subscription information | No | Yes |
Handles renewals without client dependency | No | Yes |
Resistant to device clock change | No | Yes |
获取收据
// Get the receipt if it's available
if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL,
FileManager.default.fileExists(atPath: appStoreReceiptURL.path) {
do {
let receiptData = try Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped)
print(receiptData)
let receiptString = receiptData.base64EncodedString(options: [])
// Read receiptData
}
catch { print("Couldn't read receipt data with error: " + error.localizedDescription) }
}
发送收据到 App Store 进行验证
- 构建 json 对象
{
"receipt-data": "base64(receiptData)",
"password": "password",
"exclude-old-transactions": true
}
- receipt-data: (byte)(Required) The Base64 encoded receipt data.
- password: (string)(Required) Your app’s shared secret (a hexadecimal string).Use this field only for receipts that contain auto-renewable subscriptions.
- exclude-old-transactions: (boolean) Set this value to true for the response to include only the latest renewal transaction for any subscriptions.Use this field only for app receipts that contain auto-renewable subscriptions.
- 构建 HTTP POST 请求
- URL:
- https://sandbox.itunes.apple.com/verifyReceipt (沙盒环境)
- https://buy.itunes.apple.com/verifyReceipt (App Store)
重要
验证收据时先调用生产 URL 地址,当返回的状态码为 21007 时再去调用沙盒环境 URL 地址,这样可以确保不必在测试、审核或者 App Store 环境中进行地址切换。
- 解析响应
响应格式可以参照苹果文档 responseBody。
三、APP 推荐和评价功能
1、应用内下载其他 APP
如果你想在自己 APP 中为用户直接提供 App Store 的购买服务,可以通过使用 SKStoreProductViewController
类来实现。该类定义如下:
/* View controller to display iTunes Store product information */
@available(iOS 6.0, *)
open class SKStoreProductViewController : UIViewController {
// Delegate for product page events
@available(iOS 6.0, *)
weak open var delegate: SKStoreProductViewControllerDelegate?
// Load product view for the product with the given parameters. See below for parameters (SKStoreProductParameter*).
// Block is invoked when the load finishes.
@available(iOS 6.0, *)
open func loadProduct(withParameters parameters: [String : Any], completionBlock block: ((Bool, Error?) -> Void)? = nil)
}
public protocol SKStoreProductViewControllerDelegate : NSObjectProtocol {
// Sent after the page is dismissed
@available(iOS 6.0, *)
optional func productViewControllerDidFinish(_ viewController: SKStoreProductViewController)
}
在初始化时,我们需要设置 SKStoreProductParameterITunesItemIdentifier
,这个参数表示需要推荐的项目在 iTunes 中的唯一标识,是一个 NSNumber
实例。我们可以通过苹果提供的链接制作工具 linkmaker.itunes.apple.com 先搜索到需要展示的内容。
我们以QQ为例。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DNaGehsE-1682489374475)(null)]
链接 https://apps.apple.com/cn/app/qq/id444934666?mt=8 中 444934666 就是我们需要的标识。
var parametersDictionary = [SKStoreProductParameterITunesItemIdentifier: product.productIdentifier]
// Create a store product view controller.
let store = SKStoreProductViewController()
store.delegate = self
/*
Attempt to load the selected product from the App Store. Display the store product view controller if success and print an error message,
otherwise.
*/
store.loadProduct(withParameters: parametersDictionary, completionBlock: {[unowned self] (result: Bool, error: Error?) in
if result {
self.present(store, animated: true, completion: {
print("The store view controller was presented.")
})
} else {
if let error = error {
print("Error: \(error.localizedDescription)")
}
}})
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4hVU71RB-1682489374396)(null)]
最后,我们还要实现 SKStoreProductViewControllerDelegate
代理方法以便在购买结束时关闭推荐页面。
/// Used to dismiss the store view controller.
func productViewControllerDidFinish(_ viewController: SKStoreProductViewController) {
viewController.presentingViewController?.dismiss(animated: true, completion: {
print("The store view controller was dismissed.")
})
}
2、应用内评级与评价
在 iOS 10.3+
之后,StoreKit 提供了 SKStoreReviewController
用于直接在 APP 内显示评级和评价弹窗。
/** Controller class to request a review from the current user */
SK_EXTERN_CLASS API_AVAILABLE(ios(10.3), macos(10.14)) API_UNAVAILABLE(watchos) __TVOS_PROHIBITED @interface SKStoreReviewController : NSObject
/** Request StoreKit to ask the user for an app review. This may or may not show any UI.
*
* Given this may not successfully present an alert to the user, it is not appropriate for use
* from a button or any other user action. For presenting a write review form, a deep link is
* available to the App Store by appending the query params "action=write-review" to a product URL.
*/
+ (void)requestReview API_AVAILABLE(ios(10.3), macos(10.14)) API_UNAVAILABLE(watchos) __TVOS_PROHIBITED;
@end
SKStoreReviewController.requestReview()
如果想要引导用户添加评论,可以通过 APP 在 App Store 中的链接上拼接参数 action=write-review
跳转 App Store 进行评价。
以 QQ 为例,其手机版 App Store 地址为
- https://apps.apple.com/cn/app/qq/id444934666?mt=8,
拼接参数后为
- https://apps.apple.com/cn/app/qq/id444934666?mt=8&action=write-review
guard let url = URL.init(string:"https://apps.apple.com/cn/app/qq/id444934666?mt=8&action=write-review") else {
return;
}
UIApplication.shared.open(url, options: [:]) { _ in
}