本文采用StoreKit2+Swift,不兼容Storekit1,最低适配版本iOS15以上.
基础时序图
applePay.png
在第一步请求购买前,我们可能需要先向自己的业务服务器请求创建订单,创建后再向苹果打开支付流程。
需要在苹果后台上配置的
-
配置商品
image.png
在这里配置订阅和消耗和非消耗类商品
- 商务:
入口:https://appstoreconnect.apple.com/business
这一步主要是填收款的银行卡和报税表 -
Issuer ID和秘钥ID
image.png
这一步同样会创建一个.p8的秘钥文件,保存好,只能下载一次,这个后面会给后台用
- 创建沙盒账户
image.png
这一步是沙盒环境购买时用到的测试账号,我们在开发者->沙盒APPLE账户(拉到最底下)登录测试员,后面每次在测试环境中购买,就用这个号了
官方文档
image.png -
后端会给我们一个苹果回调的接口,分沙盒环境和生产环境,我们把它配置在App信息->App Store 服务器通知
image.png
后台需要我们提供的
- product id 第1步
- bundle ID
- key ID 第3步
- issuer ID 第3步
- 私钥那个文件 第3步
以上就是全部需要的配置了
写代码
- Storekit2有一个新的配置文件,可以同步下来创建的商品(可选)
image.png
image.png
image.png
这里可以模拟一些错误,方便测试
image.png
在Edit Scheme-> Options中可以选中这个配置文件,这样每次购买就会变成Xcode环境,方便我们写代码的时候调试,如果要切到沙盒测试环境,记得把这里换成None(笔者这里跟后台联调的时候忘记把这里选成None,导致一直是Xcode环境,怎么样都没有弹出沙盒账号)
image.png
Xcode这里获取到的transactionId是一个个位数字的字符串,例如"1","2","3","4",如果发现是这种transactionId,可以检查一下是不是出于Xcode环境。
import StoreKit
import Foundation
class HSSK2Manager {
// MARK: - Singleton
static let shared = HSSK2Manager()
// MARK: - Properties
private(set) var products: [Product] = []
private(set) var isLoading = false
// MARK: - Callbacks
var onProductsLoaded: (([Product]) -> Void)?
var onLoadingStateChanged: ((Bool) -> Void)?
var onPurchaseCompleted: ((PurchaseResult) -> Void)?
var onError: ((PurchaseError) -> Void)?
// MARK: - Private Properties
private var productIDs: Set<String> = []
private var updateListenerTask: Task<Void, Error>?
// MARK: - Initialization
private init() {
startTransactionListener()
}
deinit {
updateListenerTask?.cancel()
}
// MARK: - Public Methods
func preload() {
self.productIDs = [
// 配置你的productIds
]
loadProducts()
}
/// 加载商品信息
func loadProducts() {
guard !isLoading else { return }
guard products.isEmpty else {
onProductsLoaded?(products)
return
}
setLoading(true)
Task {
do {
let products = try await Product.products(for: productIDs)
let sortedProducts = products.sorted { $0.price < $1.price }
await MainActor.run {
self.products = sortedProducts
self.setLoading(false)
self.onProductsLoaded?(sortedProducts)
}
} catch {
await MainActor.run {
self.setLoading(false)
let purchaseError = PurchaseError.loadProductsFailed(error)
self.onError?(purchaseError)
}
}
}
}
private func startTransactionListener() {
updateListenerTask = Task.detached {
for await result in Transaction.updates {
do {
let transaction = try self.checkVerified(result)
await MainActor.run {
self.onPurchaseCompleted?(.success(transaction))
}
} catch {
print("Transaction verification failed: \(error)")
await MainActor.run {
self.onError?(.verificationFailed(error))
}
}
}
}
}
func purchase(_ productId: String, appAccountToken: String) async throws -> Transaction {
if products.isEmpty {
guard let product = try await Product.products(for: [productId]).first else {
throw PurchaseError.productNotFound
}
return try await purchase(product, appAccountToken: appAccountToken)
} else {
guard let product = products.first(where: { $0.id == productId }) else {
throw PurchaseError.productNotFound
}
return try await purchase(product, appAccountToken: appAccountToken)
}
}
/// 购买指定商品
func purchase(_ product: Product, appAccountToken: String) async throws -> Transaction {
let uuid = Product.PurchaseOption.appAccountToken(UUID.init(uuidString: appAccountToken)!)
let result = try await product.purchase(options: [uuid])
switch result {
case .success(let verificationResult):
return try checkVerified(verificationResult)
case .userCancelled:
throw PurchaseError.userCancelled
case .pending:
throw PurchaseError.purchasePending
@unknown default:
throw PurchaseError.unknownError
}
}
// MARK: - Private Methods
private func setLoading(_ loading: Bool) {
isLoading = loading
onLoadingStateChanged?(loading)
}
/// 验证交易签名
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified(_, let error):
throw PurchaseError.verificationFailed(error)
case .verified(let safe):
return safe
}
}
}
// MARK: - Supporting Types
enum PurchaseResult {
case success(Transaction)
case cancelled
case pending
case failed(PurchaseError)
}
enum PurchaseError: Error, LocalizedError {
case productNotFound
case loadProductsFailed(Error)
case purchaseFailed(Error)
case verificationFailed(Error)
case userCancelled
case purchasePending
case unknownError
var errorDescription: String? {
switch self {
case .loadProductsFailed(let error):
return "load products failed: \(error.localizedDescription)"
case .purchaseFailed(let error):
return "purchase failed: \(error.localizedDescription)"
case .verificationFailed(let error):
return "verifify failed: \(error.localizedDescription)"
case .unknownError:
return "unknown error occurred"
case .userCancelled:
return "user cancelled the purchase"
case .purchasePending:
return "purchase is pending, please complete it later"
case .productNotFound:
return "product not found, please check the product ID"
}
}
}
// 外界调用
private func requestProduct(_ productId: String) {
self.productId = productId
Task {
do {
let transaction = try await HSSK2Manager.shared.purchase(productId)
// 打印transaction
print("Transaction successful: \(transaction)")
await MainActor.run {
handleTransaction(transaction)
}
} catch {
purchaseFailed(with: error as? PurchaseError)
}
}
private func handleTransaction(_ transaction: Transaction) {
// 和服务器校验订单
await checkOrderWithServer(transaction.id)
// 一定要调用,不然属于掉单,会在二次购买时提示重复购买
await transaction.finish()
}
这份代码只有消耗型商品,没有订阅类型的商品,其中有两个比较重要的部分
- 购买流程,
urchase(_ productId: String, appAccountToken: String)
,第二个参数appAccountToken
一般是后台创建的订单id,我们把这个转成uuid传给苹果服务器,购买完成后,苹果会校验checkVerified
,如果订单ok,则传递给服务器去校验,服务器会根据前面的订单id去苹果服务器校验,没问题再通过接口返回给客户端,客户端再调用await transaction.finish()
,完成这个订单,这一句非常关键,如果漏掉这一句,重新点击购买同一个productId的商品,是不会弹出购买弹窗的。 - 掉单处理,如果苹果服务器迟迟没有发数据给我们,或者服务端去苹果那边校验迟迟没有成功,这时我们只要不调用
await transaction.finish()
,在苹果看来我们的订单就没有算完成,我们创建一个分离的线程去监听它,也就是startTransactionListener()
,这个函数最好在app启动的时候就监听,方便苹果在用户拉起app的时候就可以获取到之前没有完成的订单,这个Transaction.updates
会把之前没有完成的订单都重新发给我们,重走校验流程。
切换到沙盒测试
如果我们在Xcode环境购买正常,就可以和后台联调了,先把Edit Scheme->Option->Storekit Configuration置为None
image.png