iOS通过StoreKit 2实现应用内购
iOS通过StoreKit 2实现应用内购

iOS通过StoreKit 2实现应用内购

前情提要

因为设计了一款“存钱猪猪”的应用,想要在免费的基础之上,提供一个赞助的内购功能,因此产生了这篇文章。

在处理应用内购代码过程中也遇到了几处问题,在这里分享一下。

(1)首先,我第一次提供内购功能后,没有经过沙盒测试,因为我发现应用内购时会报错,而我考虑过后,所想到的就是内购产品也没有审核通过,所以想着苹果审核人员会在审核通过内购产品后,再对应用的购买进行审核。

结果就因为内购功能提示:Cannot Connect to iTunes Store

苹果给予的答复是应用出现一个或多个对App Store产生负面影响的错误。以及我的应用界面中存在赞助的具体金额:Support free development ($1 USD),被苹果认为应该从应用中删除任何对定价的引用,如果想要宣传应用价格的变化,需要在应用说明中包含此信息。

关于应用价格的变化,我理解的是在应用内购中展示介绍内容,当然我后面把这段问题删除了。

(2)我第二次提供应用时,将上面的内购展示功能删除了,因为我想应该得先审核通过内购,然后我才能测试,同时修改的赞助文字的部分。

这一次,苹果给我的反馈是审核您的应用,但无法继续,因为无法在应用中找到应用内购。

这两次的反馈总结,我想无法连接iTunes Store,应该不是内购未审核通过问题,因此,我应该进行测试。然后从网络上查找相关的测试教程,接着修改内购的实现代码,在应用沙箱中完成的内购的测试。

(3)第三次提审时,我还特意进行备注:“我已经重新提交内购展示代码,并且已经通过沙盒进行购买测试,当我使用TestFlight时,发现无法打开商品页,查询了一下原因可能为内购商品没有审核通过,如果内购商品没有审核通过,我就不可能使用真正的购买页面,如果我只提供内购商品,没有购买功能,又是这次报错,这成了一个悖论。”

这一次,应用审核通过了。

很高兴,我的应用会上架到Apple Store市场上,虽然需要我等待24小时。

很高兴我能够分享我的内购代码,希望能给需要的朋友带来帮助。

配置Xcode项目

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

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

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

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

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

实现代码

下面是内购功能实现的具体代码:

1、IAPManager文件

import StoreKit
@available(iOS 15.0, *)
class IAPManager {
    static let shared = IAPManager()
    private init() {}
    func fetchSingleProduct() async throws -> Product? {
        let productID = "单个产品id"	//  注意,要替换成实际的产品ID
        print("Fetching product with ID: \(productID)")
        let products = try await Product.products(for: [productID])
        if let product = products.first {
            print("Product fetched: \(product)")
        } else {
            print("Product not found")
        }
        return products.first
    }
    func purchaseProduct(_ product: Product) async throws -> Product.PurchaseResult {
        print("Purchasing product: \(product.id)")
        let result = try await product.purchase()
        switch result {
        case .success(let verification):
            // 验证交易
            print("Purchase successful, verifying transaction")
            let transaction = try checkVerified(verification)
            // 更新购买状态
            savePurchasedState(for: product.id)
            await transaction.finish()
            return result
        case .userCancelled:
            print("Purchase cancelled by user")
            return result
        case .pending:
            print("Purchase pending")
            return result
        default:
            print("Purchase failed with result: \(result)")
            throw StoreError.failedVerification
        }
    }
    func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .unverified:
            print("Failed verification")
            throw StoreError.failedVerification
        case .verified(let signedType):
            return signedType
        }
    }
    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 savePurchasedState(for productID: String) {
        // 保存购买状态到用户偏好设置或其他存储位置
        UserDefaults.standard.set(true, forKey: productID)
        print("Purchased state saved for product: \(productID)")
    }
    func loadPurchasedState(for productID: String) -> Bool {
        let isPurchased = UserDefaults.standard.bool(forKey: productID)
        print("Purchased state loaded for product: \(productID) - \(isPurchased)")
        return isPurchased
    }
}
enum StoreError: Error {
    case failedVerification
}

2、View文件

import SwiftUI
import StoreKit
@available(iOS 15.0, *)
struct View: View {
    @State private var product: Product?
    @State private var purchaseResult: Product.PurchaseResult?
    @State private var isPurchased: Bool = false
    var body: some View {
        VStack(alignment: .leading) {
            if isPurchased {
                Text(SupportedThisApplication)
            } else if let product = product {
                Text(SupportApp)
            } else {
                Text("加载中...")
            }
        }
        .onAppear {
            Task {
                await loadProduct()
                checkPurchaseStatus()
            }
        }
    }
    private func loadProduct() async {
        do {
            print("加载产品中...")
            if let singleProduct = try await IAPManager.shared.fetchSingleProduct() {
                product = singleProduct
                print("产品加载成功: \(singleProduct)")
            } else {
                print("未找到产品")
            }
        } catch {
            print("加载产品失败:\(error)")
        }
    }
    private func resetProduct() async {
        product = nil
        await loadProduct()
    }
    private func checkPurchaseStatus() {
        let productID = "单个产品ID"    //  注意,要替换成实际的产品ID
        isPurchased = IAPManager.shared.loadPurchasedState(for: productID)
    }
}

这里的代码是支持单一内购商品的,注意代码中的单个产品ID需要替换为实际的内购产品ID,在App Store Conntent中查看内购产品ID:

Xcode项目的具体代码,可以访问 piggyBank 的 Github进行查看:https://github.com/fangjunyu1/piglet

完成代码后,我们需要进行沙盒测试,以测试内购应用的完整性。

沙盒测试

我们完成内购代码后,在测试手机上找到设置-App Store-沙盒账户-登录沙盒账户的Apple ID,

我不知道Xcode模拟器是否可以登录,我是使用的真机进行沙盒测试的。

然后,我们使用Xcode将应用安装到iPhone上,点击实现的内购按钮。

最后,页面调出我们心心念念的内购视图,完成沙盒内购。

但存在一个问题,就是我发现首次内购完成后,Xcode会提示网络错误,然后首次内购完成,会再次弹出内购功能。

我虽然在代码中添加了内购校验,但因为我的内购项目属于非消耗型内购,所以再次购买或者取消,都不会额外付费,而且重新进入应用后,会校验内购并展示我的内购完成视图。

当你的代码是非消耗性内购时,使用这个代码,会存在让用户误解的情况,同时,因为校验没有走服务器,也跟实际公司的生产环境不符。因此这里实现内购的环境比较单一,不具备普适性,只能用作个人应用的非消耗性内购代码参考。

如果想要重置内购,可以到刚才设置沙盒账号的页面,点击沙盒账号-管理-清除购买历史记录。

以上是本次iOS通过StoreKit 2实现内购的全部内容,我的“存钱猪猪”应用在本文章发布的24小时后,会上架到全部的Apple Store,希望各位游客可以去下载并尝试使用。

我现在还在学习Sketch,因为我发现设计应用的主要思路还是需要一个参考页面,才能分析并实现对应的功能,之前我设想的是直接用Xcode写页面,现在看来也是盲人摸象,很难写出好的应用。

最后,希望看到这篇文章的人,也能够实现内购功能,完成个人的内购应用,异或实现公司交与的任务需求。

还要感谢一下苹果的审核人员,虽然退审了两次,但是审核时间还都是蛮快的,可能因为我的应用简单,基本上是一天或者半天就告知我审核结果,而且审核人员可能是全球性质的,我记得使用Shynet查看发现有德国Apple ip,美国Apple ip,这也许是苹果审核比较快的原因吧。

完结撒花🎉🎉🎉

附录

原始的 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)

  1. 引入时间:自 iOS 3.0 以来,StoreKit API 一直存在。
  2. 使用方式:基于委托(delegate)模式和通知(notification)模式。
  3. 代码复杂性:需要处理大量的样板代码(boilerplate code),如请求和处理交易、管理产品信息、处理交易状态等。
  4. 并发支持:依赖 GCD(Grand Central Dispatch)或其他传统并发处理方法。
  5. 支持系统版本:适用于 iOS 3.0 及以后版本,适合支持早期操作系统的应用。

StoreKit 2(最新 API)

  1. 引入时间:随着 iOS 15 的发布而引入。
  2. 使用方式:基于 Swift 的现代编程接口,利用 Swift 并发(Swift Concurrency)功能,如 async/await 和结构化并发。
  3. 代码简化:简化了许多操作,如获取产品信息、进行交易、管理订阅等,使代码更加简洁和易于维护。
  4. 并发支持:内置对 Swift 并发的支持,允许在异步操作期间内嵌返回结果,避免使用委托对象。
  5. 支持系统版本:主要针对 iOS 15 及以上版本。

选择使用哪一个 API

  1. 新应用或现有应用的更新:如果正在开发一个新应用或更新一个现有应用,并且目标操作系统版本是 iOS 15 及以上,建议使用 StoreKit 2。它能简化开发流程,提供更好的并发支持。
  2. 需要支持早期操作系统的应用:如果应用需要支持 iOS 15 以下的版本,那么需要使用原始的 StoreKit API,或者同时使用两者来兼容不同版本的系统。

参考资料

  1. 简单又安全的 App 内购买项目:https://developer.apple.com/cn/in-app-purchase/
  2. App 内购买项目的原始 API:https://developer.apple.com/cn/documentation/storekit/original_api_for_in-app_purchase/
  3. App 内购买项目:https://developer.apple.com/cn/documentation/storekit/in-app_purchase/

如果您认为这篇文章给您带来了帮助,您可以在此通过支付宝或者微信打赏网站开放者。

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注