iOS通过StoreKit 2实现应用内购

本文从配置Xcode、配置应用内购,到讲解内购代码的每一个流程,基本满足简单的内购应用的效果实现。

经过一夜的优化(2024年10月23日夜),全部的内购代码除去必要的注释行,一共90行代码,希望通过简短的内购代码实现你的内购功能。

配置Xcode项目

我们在实现代码前,还需要一些配置:

在项目中点击左侧项目名称,找到TARGETS -> Signing & Capabilities -> TARGETS,点击Capability

输入In-App Purchase,然后双击将功能添加到我们的项目中去。

另外,我们还需要检查Bundle Identifier是否输入正确。

上述两个操作确认完成后,我们开始实现内购功能。

创建内购

我们需要打开App Store Connect页面或应用。

这里需要注意的是,我们的套餐ID是我们的项目,如果你已经创建了一个项目,这里通常会显示出来,如果没有显示,可能是项目没有绑定Bundle Identifier。

在Xcode中检查Bundle Identifier,如果绑定后未在App Store Connect中选取到对应的套餐ID。

创建步骤可以参考《App Store Connect创建应用》

创建完成之后,我们会来到这个App的信息编辑页面。

我们下滑到底部,可以看到“App内购买项目”模块,右侧就是我们的内购配置界面。

点击创建按钮后,显示“创建App内购买项目”,类型为消耗型和非消耗型两种,简单理解就是消耗型可以复购,非消耗型就是永久权益。因为我开发的《存钱猪猪》和《汇率仓库》两款iOS应用都是提供一个1美元的赞助内购,因此,我选择的是非消耗型。参考名称和产品ID没有特殊要求,填写即可,注意一点,那就是产品ID填写后,后续无法再使用该产品ID,及时将该内购删除。

创建完成我们的内购应用后,我们需要记下产品ID和AppleID,以备后面的内购代码使用。

接着,在配置内购信息时,选择我们的销售范围(国家)、内购商品的价格($0.99、$1.99)等等,Apple会将你选取的价格自动转换为对应国家的货币价格,最后是内购商品的本地化信息,需要填写本地化显示名称、描述等内容。

在添加本地化版本时,应当注意我们的本地化语言应该是简短的,每个语言翻译的内容或长或短,因此建议本地化的名称和描述在十个字以内,越好越好。

所以本地化信息填写完成后,我们点击右上角的储存按钮,完成储存。

后面,我们在提交Xcode项目至App Store时,该内购商品也会随着我们的Xcode项目一并提交,一起审核。

因此,配置内购项目的内容结束。

项目配置内购代码

我们将通过IAPManager类,来实现iOS应用中的应用内购(In-App Purchase,IAP)。

首先,我们需要在项目中创建一个IAPManager的Swift文件。

准备代码

导入StoreKit

在IAPManager文件中导入import StoreKit。

import StoreKit

这是StoreKit框架的声明,在iOS开发中,StoreKit时苹果提供的一个框架,用于实现应用内购买和订阅服务。

StoreKit 中常用的功能

Product:表示在应用中可供购买的商品(如虚拟货币、订阅服务)。

Transaction:表示一次购买交易,包括交易状态、购买的产品信息等。

Product.PurchaseResult:表示购买操作的结果,可能成功、取消、待处理等。

StoreKit.TransactionListener:可以用于监听交易更新(如购买、退款、续订)。

创建一个管理IAP(内购)的类

@available(iOS 15.0, *)

class IAPManager:ObservableObject {

}

@avaliable是一个可用性检查的属性,用于制定代码只在特定的系统版本及以上可用。因为StoreKit 2是从iOS15开始引入的,这个属性确保应用的某些功能或代码只会在兼容的操作系统版本上运行,防止因不兼容而导致的崩溃。

创建一个名为IAPManager的类,遵循ObservableObject协议的原因为,我会在视图部分通过@StateObject创建该实例的生命周期并进行管理。

创建单例模式和私有化构造方法

static let shared = IAPManager()

private init() {}

使用单例模式可以保证应用内购(IAP)可以被集中处理,避免多次创建实例。

因为每次处理应用内购都会创建一个新的实例,占用更多的内存和资源,这里使用单例模式可以避免这种情况。同时也不需要在每个需要内购的页面创建新的实例,直接使用IAPManager.shard即可。

而私有化构造方法则配合单例模式使用。

创建内购产品参数

@Published var productID = ["产品ID1"] // 需要内购的产品ID数组

@Published var products: [Product] = [] // 存储从 App Store 获取的内购商品信息

@Published var loadPurchased = false // 如果开始内购流程,loadPurchased为true,View视图显示加载画布

其中,productID保存的是前面创建的产品ID。我们将通过productID向App Store进行请求,请求的内购商品信息会保存到products数组中。

loadPurchased是我设置的一个变量,当这个变量为true时,表示购买商品流程开始,View视图部分会显示一个加载动画,当这个参数为false后,加载动画取消。

这个loadPurchased可以根据个人请求来增删。

如果忘记productID,可以再回到App Store Connect中查看内购商品的产品ID并补充到上面的代码中。

获取产品信息

我们创建一个loadProduct ()函数,通过async异步获取产品信息。

// 视图自动加载loadProduct()方法

func loadProduct() async {

do {

// 传入 productID 产品ID数组,调取Product.products接口从App Store返回产品信息

// App Store会返回对应的产品信息,如果数组中个别产品ID有误,只会返回正确的产品ID的产品信息

let fetchedProducts = try await Product.products(for: productID)

if fetchedProducts.isEmpty { // 判断返回的是否是否为空

// 抛出内购信息为空的错误,可能是所有的产品ID都不存在,中断执行,不会return返回products产品信息

throw StoreError.IAPInformationIsEmpty

}

DispatchQueue.main.async {

self.products = fetchedProducts // 将获取的内购商品保存到products变量

}

print("成功加载产品: \(products)") // 输出内购商品数组信息

} catch {

print("加载产品失败:\(error)") // 输出报错

}

}

这是一个获取内购商品信息的代码,可以考虑在应用首页视图或打开应用时,加载该方法。

获取App Store的内购商品信息

let fetchedProducts = try await Product.products(for: productID)

Product.products(for:)方法可以从App Store中获取对应的产品信息。

判断返回的商品信息

if fetchedProducts.isEmpty { // 判断返回的是否是否为空

// 抛出内购信息为空的错误,可能是所有的产品ID都不存在,中断执行,不会return返回products产品信息

throw StoreError.IAPInformationIsEmpty

}

添加一个数组判断,将Product.products(for:)获取的产品信息保存到fetchedProducts变量中。

为什么使用isEmpty判断,假设productID数组中三个产品ID都是正确的话,那么Product.products(for:)方法会返回三个有效的Product对象。

如果只有两个正确,则只会返回两个有效的Product对象,如果没有值或者输入的productID数组的值是错误的,返回的数组就是空数组。

如果是空数组就抛出错误,提示我们这里有问题。

DispatchQueue.main.async {

self.products = fetchedProducts // 将获取的内购商品保存到products变量

}

题外话:DispatchQueue.main.async表示在Swift中用于在主线程上一步执行代码块,因为我们的Product.products(for:) 方法是异步的,当我们异步的方法返回并赋值时,SwiftUI无法监听到products数组是否更新,当UI视图涉及该属性时,就无法同步更新视图,因此需要考虑使用该方法。

加载产品失败:IAPInformationIsEmpty

products为空

特别注意:当配置的产品ID无问题,但Product.products方法返回空的内购信息时,请考虑是否是商务/营利信息为未填写完整导致的问题。

没有网络权限也会导致无法获取内购信息:在真机运行时,应用会请求网络权限,在授权后,重新打开应用就可以获取内购商品信息。在iOS18的模拟器中,如果应用不主动调用网络权限,那么就不会获取内购商品信息,这里需要手动调用网络以获取相关权限。

处理产品的购买流程

创建一个购买流程函数,它主要负责用户发起购买时的操作、验证交易以及处理购买结果。

// purchaseProduct:购买商品的方法,返回购买结果

func purchaseProduct(_ product: Product) {

// 在这里输出要购买的商品id

print("Purchasing product: \(product.id)")

Task { @MainActor in

do {

let result = try await product.purchase()

switch result {

case .success(let verification): // 购买成功的情况,返回verification包含交易的验证信息

let transaction = try checkVerified(verification) // 验证交易

savePurchasedState(for: product.id) // 更新UserDefaults中的购买状态

await transaction.finish() // 告诉系统交易完成

print("交易成功:\(result)")

case .userCancelled: // 用户取消交易

print("用户取消交易:\(result)")

case .pending: // 购买交易被挂起

print("购买交易被挂起:\(result)")

default: // 其他情况

throw StoreError.failedVerification // 购买失败

}

} catch {

print("购买失败:\(error)")

await resetProduct() // 购买失败后重置 product 以便允许再次尝试购买

}

DispatchQueue.main.async {

self.loadPurchased = false // 隐藏内购时的加载画布

}

print("loadPurchased:\(loadPurchased)")

}

}

题外话:代码中的@MainActor跟前面的DispatchQueue.main.async同理,都是跟线程有关,不关心的话,可以忽略。

开始购买流程

let result = try await product.purchase()

调用product.purchase(for:)方法流程。因为可能会产生异常(如购买失败),因此使用try和await。

purchase()是一个异步操作,因此用户在购买过程中不会被阻断,所以需要考虑是否添加一个加载动画。

购买完成后,会返回一个Product.PurchaseResult,用result来接收。

处理购买结果

使用 switch 语句来处理不同的购买结果:

成功(.success):

case .success(let verification): // 购买成功的情况,返回verification包含交易的验证信息

let transaction = try checkVerified(verification) // 验证交易

savePurchasedState(for: product.id) // 更新UserDefaults中的购买状态

await transaction.finish() // 告诉系统交易完成

print("交易成功:\(result)")

如果购买成功,返回的 verification 包含交易的验证信息,用于进一步验证购买是否合法。

checkVerified(verification) 方法(后面会定义)验证购买结果,确保交易的有效性,防止交易信息被篡改或欺诈。

savePurchasedState(for:) 方法(后面会定义),保存购买状态(如将产品标记为已购买),通过UserDefaults根据内购商品的id为键,保存购买状态。

最后调用 transaction.finish() 结束交易,这是 StoreKit 2 的操作,表示系统可以完成该交易,解锁相应内容。

验证verification的原因

防止篡改和欺诈:即使即使 product.purchase() 返回了 .success,这只是表明用户成功进行了购买操作,但这并不意味着交易是完全可信的。攻击者可能试图伪造购买,因此需要对交易进行进一步验证,确保购买信息确实是由苹果的服务器签发的,而不是被篡改的。

验证购买有效性: StoreKit 2 使用 VerificationResult 来提供一种机制,以确保交易的合法性和完整性。通过调用 checkVerified(verification),应用可以进一步检查签名和验证信息,确保交易确实是苹果系统认可的。

checkVerified 方法会解析 VerificationResult 并检查其有效性。如果验证失败,意味着交易可能有问题,此时会抛出错误并中断流程。

调用 transaction.finish()

告诉系统交易已完成: 即使购买成功后,交易不会立即自动结束。transaction.finish() 的作用是明确告诉系统,这笔交易已经处理完毕,无需再重复。

防止重复处理: 如果应用程序没有调用 finish(),系统会认为交易未完成,并可能在后续重新尝试处理这笔交易。这可能导致用户再次收到同一笔交易的通知(例如,再次解锁已购买的内容),从而导致混乱。

标准流程: 这是 StoreKit 处理内购的一部分标准流程,通过 finish() 确保每笔交易都得到妥善处理并归档。

用户取消 (.userCancelled):

case .userCancelled: // 用户取消交易

print("用户取消交易:\(result)")

如果用户在购买过程中手动取消,不进行任何后续操作,只返回结果。

挂起状态 (.pending):

case .pending: // 购买交易被挂起

print("购买交易被挂起:\(result)")

有时购买会处于挂起状态(例如等待批准),这时应该显示信息让用户知道状态是挂起的。

不会直接完成购买流程。

其他情况 (默认处理):

default: // 其他情况

throw StoreError.failedVerification // 购买失败

任何其他非预期的情况都被视为失败,抛出 StoreError.failedVerification 错误(后面会定义这个枚举错误),处理失败流程。

小结

因此,处理购买结果的purchaseProduct方法主要用于购买内购商品,并通过返回值判断是否购买成功、用户取消交易、交易被挂起或其他问题。

如果交易成功,会进一步通过checkVerified方法进行校验。

校验完成后,通过UserDefaults保存购买状态,如果有其他的保存方法也可以,比如保存在数组当中。

let transaction = try checkVerified(verification) // 验证交易

await transaction.finish() // 告诉系统交易完成

最后是通过transaction这个校验返回的参数,调取finish方法,表示交易结束。

我们可以通过视图的按钮来调取这个方法。

HStack{

// 点击按钮

}

.onTapGesture {

iapManager.loadPurchased = true // 显示加载动画,可以忽略

// 将商品分配给一个变量

// 处理products数组的第一个商品

let productToPurchase = iapManager.products[0]

// 将products数组的第一个商品信息作为入参进行传递

// 分开调用购买操作,以免代码解析存在问题

iapManager.purchaseProduct(productToPurchase)

}

这里的loadPurchased是一个加载动画,在按钮点击时给iapManager.loadPurchased属性赋值为true,当purchaseProduct返回信息后,iapManager.loadPurchased属性赋值为false,可根据实际场景进行调整。

如果在购买的过程中因为某些原因报错,会调用resetProduct方法(后面会定义该方法),重新加载产品信息。

验证购买结果

我们还需要创建一个前面提到的checkVerfied来验证交易的结果:

// 验证购买结果

func checkVerified(_ result: VerificationResult) throws -> T {

switch result {

case .unverified: // unverified校验失败,StoreKit不能确定交易有效

print("校验购买结果失败")

throw StoreError.failedVerification

case .verified(let signedType): // verfied校验成功

print("校验购买结果成功")

return signedType // StoreKit确认本笔交易信息由苹果服务器合法签署

}

}

VerificationResult表示对产品购买交易的验证结果。

let transaction = try checkVerified(verification)

刚才在处理购买结果的时候,我们提到了通过checkVerified(verfication)方法进行校验。

1、参数 result:

这个方法接收一个泛型参数 result,类型是 VerificationResult

VerificationResult 是 StoreKit 中用于表示验证结果的枚举类型,它有两个可能的值:.verified 和 .unverified。

2、switch 语句:

通过 switch 对 result 进行匹配,方法会根据 result 的不同情况执行不同的操作。

3、case .unverified:

如果 result 是 .unverified,说明验证失败,即 StoreKit 不能确定这笔交易是有效的,可能是由于数据被篡改或伪造。

打印 “校验购买结果失败” 以提示错误,并抛出一个 StoreError.failedVerification 错误,表示这次验证失败。

4、case .verified(let signedType):

如果 result 是 .verified,说明验证成功,StoreKit 确认这笔交易信息是由苹果服务器合法签署的。

signedType 是一个与泛型 T 相同的值,是经过验证的有效数据。

方法将 signedType 返回,供调用者进一步使用(通过调取返回值的finish方法来结束交易)。

内购交易更新

完成应用的购买流程、处理结果并验证购买结果之后,就是处理应用内购买交易更新。我们将通过监听Transaction.updates流来获取新的交易并处理它们。

func handleTransactions() async {

for await result in Transaction.updates {

do {

let transaction = try checkVerified(result)

// 处理交易,例如解锁内容

savePurchasedState(for: transaction.productID)

await transaction.finish()

} catch {

print("交易处理失败:\(error)")

}

}

}

Transaction.updates:

Transaction.updates 是一个异步序列,表示应用内购买过程中正在进行的或已完成的交易更新。

每当有新的交易(例如用户购买了产品或恢复了以前的购买)时,这个流会发送一个更新。

for await result in Transaction.updates:

这里使用了 for await 循环异步地处理交易更新。

每当 Transaction.updates 提供一个新的交易 result,代码就会进入循环,处理这个新的交易。

checkVerified(result):

result 可能是一个经过签名的交易,使用 checkVerified(result) 方法来验证交易的有效性。

验证交易是确保交易数据的来源可靠,防止伪造的交易。checkVerified 方法会在交易无法验证时抛出错误。

如果验证通过,它会返回一个 transaction 对象。

处理成功的交易:

成功验证后,可以执行后续操作。例如,在代码中调用 savePurchasedState(for: transaction.productID)。

这是为了保存产品的购买状态,确保用户在应用中可以访问他们购买的内容。这通常会保存到 UserDefaults 或其他存储机制中。

await transaction.finish():

transaction.finish() 是一个重要的调用。它表示这笔交易已经处理完成,告诉 StoreKit 不再需要处理这笔交易。

如果不调用 finish(),这笔交易会一直保留在待处理的状态中,用户下次启动应用时可能会再次看到它。

Transaction.updates的用法

1、交易可能无法在购买后立即完成:

在某些情况下,交易不会立即完成,例如网络问题、用户需要进行额外身份验证,或者在 App Store 服务器遇到延迟的情况下。

即使 purchaseProduct 成功发起了购买请求,最终的交易可能会在稍后才完成。因此,使用 Transaction.updates 可以确保即使在购买后用户离开应用程序,应用仍然能够在交易更新时接收通知并完成处理。

2、处理恢复购买、挂起交易:

有时,交易会被挂起,用户需要在其他设备上完成购买,或者恢复以前未完成的购买。在这种情况下,直接依赖 purchaseProduct 是不够的,因为这些交易不会通过 purchaseProduct 触发。

通过 Transaction.updates,你可以监听到挂起或恢复的交易,并正确处理它们。

3、确保一致性和完整性:

即使购买过程出现问题(如中途取消或设备断开连接),Transaction.updates 可以确保应用程序正确跟踪和完成每笔交易,防止出现用户付费后无法获得内容的情况。

这还可以帮助在用户重新打开应用程序时恢复那些未完成的交易。

如果不使用Transaction.updates方法进行监听,就会导致相关报错。

Making a purchase without listening for transaction updates risks missing successful purchases. Create a Task to iterate Transaction.updates at launch.

Swift会提示是关于应用内购买 (IAP) 的警告信息,表明在应用启动时没有监听 Transaction.updates,可能会导致错过用户在后台成功完成的购买。为了确保不会漏掉任何交易,应该在应用启动时设置一个 Task 来监听 Transaction.updates。

管理应用内购商品的状态

最后是我们需要保存和加载用户是否购买了某个商品:

// 保存购买状态到用户偏好设置或其他存储位置

func savePurchasedState(for productID: String) {

UserDefaults.standard.set(true, forKey: productID)

print("Purchased state saved for product: \(productID)")

}

// 通过productID检查是否已完成购买

func loadPurchasedState(for productID: String) -> Bool{

let isPurchased = UserDefaults.standard.bool(forKey: productID) // UserDefaults读取购买状态

print("Purchased state loaded for product: \(productID) - \(isPurchased)")

return isPurchased // 返回购买状态

}

这种方式依赖于UserDefaults,以便下次打开应用时,仍能识别哪些商品已购买,并根据这些状态解锁相关的功能和内容。

重新加载产品信息

// 当购买失败时,会尝试重新加载产品信息。

func resetProduct() async {

self.products = []

await loadProduct() // 调取loadProduct方法获取产品信息

}

前面有提到,当处理购买过程中出现意外报错时,会重新清空当前的产品数组并重新调取获取产品信息方法。

用于处理可能因为产品信息导致购买失败的问题。

定义错误枚举类型

// 定义 throws 报错

enum StoreError: Error {

case IAPInformationIsEmpty

case failedVerification

}

这里通过枚举定义throws报错。

总结

以上是全部的StoreKit 2的知识,最开始是2024年5月30日,首次在网站上写了这篇文章,但因为Swift知识不足,只能将大概的代码罗列出来,具体逻辑还不清楚。

现在是2024年10月24日,重新梳理了StoreKit 2的基本知识点,能够解决一般应用的内购商品需求。

全部效果实现后,点击调取按钮就可以显示内购页面,购买后,可以判断购买完成并激活已购标识。

注意:本文章的内购实现为一次性内购,非重复内购商品,所以校验内购逻辑可能有所偏差。

如果需要服务器存储内购数据,可以通过收据进行保存和校验,具体内容可以参考《iOS通过StoreKit2应用收据校验交易》文章。后来经游客提醒收据校验是StoreKit1的校验用法,StroeKit2推荐使用originalTransactionId进行校验,因此又写了一篇《iOS通过StoreKit2的Transaction实现后端验证交易》,如果需要服务器进行校验,推荐后者。

再次更新(2025年2月1日),因为这个StoreKit2代码并不涉及退款后的场景,这也就意味着用户在退款后,应用仍然会保留内购标识。因此,写了一篇《iOS通过StoreKit2的Transaction校验内购退款信息》,新增了一个checkAllTransactions方法,通过检查所有交易信息,校验已完成的内购交易,移除没有内购的交易标识。

再次更新(2025年3月21日),今天App Store Connect反馈我新上线的游戏没有“恢复购买”功能,因此单独写了“恢复购买”功能的文章《App Store Connect审核拒绝原因:Guideline 3.1.1 – Business – Payments – In-App Purchase》,恢复购买实际上通过Transaction.currentEntitlements获取所有有效的交易,如果校验成功,则添加内购标识,如果不添加“恢复购买”功能,则会被3.1.1条款拒审。

内购代码

IAPManager文件

import StoreKit

@available(iOS 15.0, *)

@MainActor

class IAPManager:ObservableObject {

static let shared = IAPManager()

private init() {}

@Published var productID = ["产品ID1"] // 需要内购的产品ID数组

@Published var products: [Product] = [] // 存储从 App Store 获取的内购商品信息

@Published var loadPurchased = false // 如果开始内购流程,loadPurchased为true,View视图显示加载画布

// 视图自动加载loadProduct()方法

func loadProduct() async {

do {

// 传入 productID 产品ID数组,调取Product.products接口从App Store返回产品信息

// App Store会返回对应的产品信息,如果数组中个别产品ID有误,只会返回正确的产品ID的产品信息

let fetchedProducts = try await Product.products(for: productID)

if fetchedProducts.isEmpty { // 判断返回的是否是否为空

// 抛出内购信息为空的错误,可能是所有的产品ID都不存在,中断执行,不会return返回products产品信息

throw StoreError.IAPInformationIsEmpty

}

DispatchQueue.main.async {

self.products = fetchedProducts // 将获取的内购商品保存到products变量

print("成功加载产品: \(self.products)") // 输出内购商品数组信息

}

} catch {

print("加载产品失败:\(error)") // 输出报错

}

}

// purchaseProduct:购买商品的方法,返回购买结果

func purchaseProduct(_ product: Product) {

// 在这里输出要购买的商品id

print("Purchasing product: \(product.id)")

Task { @MainActor in

do {

let result = try await product.purchase()

switch result {

case .success(let verification): // 购买成功的情况,返回verification包含交易的验证信息

let transaction = try checkVerified(verification) // 验证交易

savePurchasedState(for: product.id) // 更新UserDefaults中的购买状态

await transaction.finish() // 告诉系统交易完成

print("交易成功:\(result)")

case .userCancelled: // 用户取消交易

print("用户取消交易:\(result)")

case .pending: // 购买交易被挂起

print("购买交易被挂起:\(result)")

default: // 其他情况

throw StoreError.failedVerification // 购买失败

}

} catch {

print("购买失败:\(error)")

await resetProduct() // 购买失败后重置 product 以便允许再次尝试购买

}

DispatchQueue.main.async {

self.loadPurchased = false // 隐藏内购时的加载画布

}

print("loadPurchased:\(loadPurchased)")

}

}

// 验证购买结果

func checkVerified(_ result: VerificationResult) throws -> T {

switch result {

case .unverified: // unverified校验失败,StoreKit不能确定交易有效

print("校验购买结果失败")

throw StoreError.failedVerification

case .verified(let signedType): // verfied校验成功

print("校验购买结果成功")

return signedType // StoreKit确认本笔交易信息由苹果服务器合法签署

}

}

// handleTransactions处理所有的交易情况

func handleTransactions() async {

for await result in Transaction.updates {

// 遍历当前所有已完成的交易

do {

let transaction = try checkVerified(result) // 验证交易

// 处理交易,例如解锁内容

savePurchasedState(for: transaction.productID)

await transaction.finish()

} catch {

print("交易处理失败:\(error)")

}

}

}

// 当购买失败时,会尝试重新加载产品信息。

func resetProduct() async {

self.products = []

await loadProduct() // 调取loadProduct方法获取产品信息

}

// 保存购买状态到用户偏好设置或其他存储位置

func savePurchasedState(for productID: String) {

UserDefaults.standard.set(true, forKey: productID)

print("Purchased state saved for product: \(productID)")

}

// 通过productID检查是否已完成购买

func loadPurchasedState(for productID: String) -> Bool{

let isPurchased = UserDefaults.standard.bool(forKey: productID) // UserDefaults读取购买状态

print("Purchased state loaded for product: \(productID) - \(isPurchased)")

return isPurchased // 返回购买状态

}

}

// 定义 throws 报错

enum StoreError: Error {

case IAPInformationIsEmpty

case failedVerification

}

以上是内购代码逻辑部分,理解具体的功能代码后,请根据实际的需求进行调整。

View视图

顶部文件

在入口文件处,创建一个变量用于管理IAPManager.shared的单例模式。

@main

struct ERdepotApp: App {

@StateObject var iapManager = IAPManager.shared

var body: some Scene {

WindowGroup {

ContentView()

.environmentObject(iapManager)

.task {

await iapManager.loadProduct() // 加载产品信息

await iapManager.handleTransactions() // 加载内购交易更新

}

}

}

}

在ContentView视图加载时,调取IAPManager的handleTransactions和loadProduct方法。

视图文件

struct Settings: View {

@EnvironmentObject var iapManager: IAPManager

VStack {

if UserDefaults.standard.bool(forKey: iapManager.productID[0]) {

// 显示解锁内购

}

else {

// 内购按钮

Button()

.onTapGesture {

if !iapManager.products.isEmpty {

iapManager.loadPurchased = true // 显示加载动画

// 将商品分配给一个变量

let productToPurchase = iapManager.products[0]

// 分开调用购买操作

iapManager.purchaseProduct(productToPurchase)

} else {

print("products为空")

Task {

await iapManager.loadProduct() // 加载产品信息

}

}

}

}

}

.overlay {

if iapManager.loadPurchased {

ZStack {

Color.black.opacity(0.3).edgesIgnoringSafeArea(.all)

VStack {

// 加载条

ProgressView("loading...")

// 加载条修饰符

.progressViewStyle(CircularProgressViewStyle())

.padding()

.background(colorScheme == .dark ? Color(hex: "A8AFB3") : Color.white)

.cornerRadius(10)

}

}

}

}

}

#Preview {

@StateObject var iapManager = IAPManager.shared

return Settings()

.environmentObject(iapManager)

}

在视图文件中,通过UserDefaults.standard.bool(forKey:)判断是否解锁成功。

iapManager.productID[0]表示产品的id,因为内购代码部分是通过iapManager.productID[0]保存的。

// 保存购买状态到用户偏好设置或其他存储位置

func savePurchasedState(for productID: String) {

UserDefaults.standard.set(true, forKey: productID)

print("Purchased state saved for product: \(productID)")

}

在按钮部分,我还添加了一个判断,如果iapManager.products产品信息数组为空,就不去调取purchaseProduct方法。

iapManager.products.isEmpty

原因为,如果用户刚下载App,当他没有同意网络权限或者网络权限还没有弹出时。如果它点击这个解锁按钮,就会引发products数组索引报错。

因为没有网络等情况下,因为loadProduct方法没能加载到产品信息,所以products数组就是空的。

如果这时调用products数组内的某个元素

let productToPurchase = iapManager.products[0]

就会导致报错。

如果products数组为空,则考虑重新调取loadProduct方法,重新获取内购商品信息。

附录

原始的 StoreKit API 和最新的 StoreKit 2 API

我们在本次实现内购的代码中,使用的是StoreKit 2 API,因此对支持的系统版本是具有局限性的,只支持iOS 15以上版本。在这里,对两个API进行了简单的介绍和区分。

在 iOS 开发中,App 内购买(In-App Purchase, IAP)是一个非常重要的功能,允许开发者向用户提供额外的内容和服务。对于实现这一功能,Apple 提供了两个主要的 API:原始的 StoreKit API 和最新的 StoreKit 2 API。以下是这两个 API 的主要区别:

StoreKit(原始 API)

引入时间:自 iOS 3.0 以来,StoreKit API 一直存在。

使用方式:基于委托(delegate)模式和通知(notification)模式。

代码复杂性:需要处理大量的样板代码(boilerplate code),如请求和处理交易、管理产品信息、处理交易状态等。

并发支持:依赖 GCD(Grand Central Dispatch)或其他传统并发处理方法。

支持系统版本:适用于 iOS 3.0 及以后版本,适合支持早期操作系统的应用。

StoreKit 2(最新 API)

引入时间:随着 iOS 15 的发布而引入。

使用方式:基于 Swift 的现代编程接口,利用 Swift 并发(Swift Concurrency)功能,如 async/await 和结构化并发。

代码简化:简化了许多操作,如获取产品信息、进行交易、管理订阅等,使代码更加简洁和易于维护。

并发支持:内置对 Swift 并发的支持,允许在异步操作期间内嵌返回结果,避免使用委托对象。

支持系统版本:主要针对 iOS 15 及以上版本。

选择使用哪一个 API

新应用或现有应用的更新:如果正在开发一个新应用或更新一个现有应用,并且目标操作系统版本是 iOS 15 及以上,建议使用 StoreKit 2。它能简化开发流程,提供更好的并发支持。

需要支持早期操作系统的应用:如果应用需要支持 iOS 15 以下的版本,那么需要使用原始的 StoreKit API,或者同时使用两者来兼容不同版本的系统。

参考资料

简单又安全的 App 内购买项目:https://developer.apple.com/cn/in-app-purchase/

App 内购买项目的原始 API:https://developer.apple.com/cn/documentation/storekit/original_api_for_in-app_purchase/

App 内购买项目:https://developer.apple.com/cn/documentation/storekit/in-app_purchase/

App套装信息:https://developer.apple.com/cn/help/app-store-connect/reference/app-bundle-information/

swift私有化初始化方法: https://fangjunyu.com/2024/10/19/swift%e7%a7%81%e6%9c%89%e5%8c%96%e5%88%9d%e5%a7%8b%e5%8c%96%e6%96%b9%e6%b3%95/

swift控制全局的单例模式: https://fangjunyu.com/2024/10/19/swift%e6%8e%a7%e5%88%b6%e5%85%a8%e5%b1%80%e7%9a%84%e5%8d%95%e4%be%8b%e6%a8%a1%e5%bc%8f/

swift科普文《associatedtype》: https://fangjunyu.com/2024/10/21/swift%e7%a7%91%e6%99%ae%e6%96%87%e3%80%8aassociatedtype%e3%80%8b/

swift科普文《typealias》: https://fangjunyu.com/2024/10/21/swift%e7%a7%91%e6%99%ae%e6%96%87%e3%80%8atypealias%e3%80%8b/

swift科普文《异步序列》: https://fangjunyu.com/2024/10/21/swift%e7%a7%91%e6%99%ae%e6%96%87%e3%80%8a%e5%bc%82%e6%ad%a5%e5%ba%8f%e5%88%97%e3%80%8b/

swift科普文《枚举enum》: https://fangjunyu.com/2024/10/20/swift%e7%a7%91%e6%99%ae%e6%96%87%e3%80%8a%e6%9e%9a%e4%b8%beenum%e3%80%8b/

swift科普文《可失败的构造函数》: https://fangjunyu.com/2024/10/20/swift%e7%a7%91%e6%99%ae%e6%96%87%e3%80%8a%e5%8f%af%e5%a4%b1%e8%b4%a5%e7%9a%84%e6%9e%84%e9%80%a0%e5%87%bd%e6%95%b0%e3%80%8b/

swift-深入理解throws抛出: https://fangjunyu.com/2024/10/20/swift-%e6%b7%b1%e5%85%a5%e7%90%86%e8%a7%a3throws%e6%8a%9b%e5%87%ba/

swift控制全局的单例模式: https://fangjunyu.com/2024/10/19/swift%e6%8e%a7%e5%88%b6%e5%85%a8%e5%b1%80%e7%9a%84%e5%8d%95%e4%be%8b%e6%a8%a1%e5%bc%8f/

swift私有化初始化方法: https://fangjunyu.com/2024/10/19/swift%e7%a7%81%e6%9c%89%e5%8c%96%e5%88%9d%e5%a7%8b%e5%8c%96%e6%96%b9%e6%b3%95/

swift 数组和数组元素返回nil问题: https://fangjunyu.com/2024/10/20/swift-%e6%95%b0%e7%bb%84%e5%92%8c%e6%95%b0%e7%bb%84%e5%85%83%e7%b4%a0%e8%bf%94%e5%9b%9enil%e9%97%ae%e9%a2%98/

swift使用userdefaults保存数据: https://fangjunyu.com/2024/05/26/swift%e4%bd%bf%e7%94%a8userdefaults%e4%bf%9d%e5%ad%98%e6%95%b0%e6%8d%ae/

Xcode提示:Making a purchase without listening for transaction updates risks missing successful purchases. Create a Task to iterate Transaction.updates at launch.: https://fangjunyu.com/2024/10/22/xcode%e6%8f%90%e7%a4%ba%ef%bc%9amaking-a-purchase-without-listening-for-transaction-updates-risks-missing-successful-purchases-create-a-task-to-iterate-transaction-updates-at-launch/

iOS通过StoreKit2应用收据校验交易:https://fangjunyu.com/2024/12/25/ios%e9%80%9a%e8%bf%87storekit2%e5%ba%94%e7%94%a8%e6%94%b6%e6%8d%ae%e9%aa%8c%e8%af%81%e4%ba%a4%e6%98%93/

iOS通过StoreKit2的Transaction实现后端验证交易:https://fangjunyu.com/2025/01/19/ios%e9%80%9a%e8%bf%87storekit2%e7%9a%84transaction%e5%ae%9e%e7%8e%b0%e5%90%8e%e7%ab%af%e9%aa%8c%e8%af%81%e4%ba%a4%e6%98%93/

iOS通过StoreKit2的Transaction校验内购退款信息:https://fangjunyu.com/2025/02/01/ios%e9%80%9a%e8%bf%87storekit2%e7%9a%84transaction%e6%a0%a1%e9%aa%8c%e5%86%85%e8%b4%ad%e9%80%80%e6%ac%be%e4%bf%a1%e6%81%af/

App Store Connect创建应用:https://fangjunyu.com/2025/03/03/app-store-connect%e5%88%9b%e5%bb%ba%e5%ba%94%e7%94%a8/

App Store Connect审核拒绝原因:Guideline 3.1.1 – Business – Payments – In-App Purchase:https://fangjunyu.com/2025/03/21/app-store-connect%e5%ae%a1%e6%a0%b8%e6%8b%92%e7%bb%9d%e5%8e%9f%e5%9b%a0%ef%bc%9aguideline-3-1-1-business-payments-in-app-purchase/

Top