iOS应用内购买(IAP)指南 - StoreKit 2详解

iOS内购产品类型详解

iOS内购(IAP)提供四种产品类型,每种类型适用于不同场景:

  • Name
    消耗型(Consumable)
    Type
    concept
    Description
    • 购买后会立即消耗或可重复购买
    • 用户用完后无法恢复
    • 适用场景:游戏虚拟货币、付费道具、一次性解锁内容
  • Name
    非消耗型(Non-Consumable)
    Type
    concept
    Description
    • 购买后永久拥有,不会消失
    • 可通过Apple ID恢复购买
    • 适用场景:解锁高级功能、购买特定内容、永久会员权益
  • Name
    自动续订订阅(Auto-Renewable)
    Type
    concept
    Description
    • 按固定周期(周/月/年)自动扣费
    • 用户可随时取消续订
    • 适用场景:流媒体服务、云存储、持续更新内容
  • Name
    非续订订阅(Non-Renewing)
    Type
    concept
    Description
    • 需手动续费,不自动扣款
    • 提供有时间限制的内容访问权
    • 适用场景:限时会员、课程、期刊订阅

产品类型对比

产品类型        可重复购买    可恢复购买    适用场景
消耗型         ✅          ❌         游戏道具、虚拟货币
非消耗型       ❌          ✅         解锁功能、永久权益
自动续订订阅    ❌          ✅         持续服务、定期更新
非续订订阅      ✅          ❌         课程、赛事、短期会员

什么是应用内购买?

应用内购买(In-App Purchase, IAP)是苹果提供的允许开发者在应用内销售数字内容和服务的机制。

  • Name
    购买类型
    Type
    concept
    Description
    • 消耗型项目(如游戏币)
    • 非消耗型项目(如解锁功能)
    • 自动续期订阅
    • 非续期订阅
  • Name
    主要功能
    Type
    meaning
    Description
    • 内容销售
    • 功能解锁
    • 订阅服务
    • 安全交易

准备工作

// 1. App Store Connect配置
- 创建应用内购买项目
- 设置价格和描述
- 提交审核

// 2. 项目配置
- 添加StoreKit框架
- 配置App ID
- 设置测试账号

StoreKit 2内购管理类详解

StoreKit 2是苹果在iOS 15中引入的新API,提供了更简洁、更安全的内购实现方式。本教程将详细解析一个完整的Store类实现。

  • Name
    核心特性
    Type
    concept
    Description
    • 异步/等待API
    • 内置收据验证
    • 交易自动更新
    • 订阅状态管理
    • 家庭共享支持

基本类型定义

import Foundation
import StoreKit

typealias Transaction = StoreKit.Transaction
typealias RenewalInfo = StoreKit.Product.SubscriptionInfo.RenewalInfo
typealias RenewalState = StoreKit.Product.SubscriptionInfo.RenewalState

public enum StoreError: Error {
    case failedVerification
}

// 定义应用的订阅权限等级,按服务级别排序,最高级别的服务在前
// 数值级别与StoreKit配置文件或App Store Connect中配置的订阅级别匹配
public enum ServiceEntitlement: Int, Comparable {
    case notEntitled = 0
    
    case pro = 1
    case premium = 2
    case standard = 3
    
    init?(for product: Product) {
        // 产品必须是订阅才能有服务权限
        guard let subscription = product.subscription else {
            return nil
        }
        if #available(iOS 16.4, *) {
            self.init(rawValue: subscription.groupLevel)
        } else {
            switch product.id {
            case "subscription.standard":
                self = .standard
            case "subscription.premium":
                self = .premium
            case "subscription.pro":
                self = .pro
            default:
                self = .notEntitled
            }
        }
    }

    public static func < (lhs: Self, rhs: Self) -> Bool {
        // 订阅组级别按降序排列
        return lhs.rawValue > rhs.rawValue
    }
}

Store类的核心属性

Store类是一个ObservableObject,用于管理应用内所有产品和购买状态。

  • Name
    主要属性
    Type
    concept
    Description
    • 产品分类列表
    • 已购买产品状态
    • 订阅状态跟踪
    • 产品ID到表情映射
  • Name
    发布属性
    Type
    meaning
    Description

    所有带有@Published标记的属性会在值变化时自动通知UI更新,实现数据与界面的绑定。

Store类属性定义

class Store: ObservableObject {

    // 产品分类
    @Published private(set) var cars: [Product]
    @Published private(set) var fuel: [Product]
    @Published private(set) var subscriptions: [Product]
    @Published private(set) var nonRenewables: [Product]
    
    // 已购买产品状态
    @Published private(set) var purchasedCars: [Product] = []
    @Published private(set) var purchasedNonRenewableSubscriptions: [Product] = []
    @Published private(set) var purchasedSubscriptions: [Product] = []
    @Published private(set) var subscriptionGroupStatus: Product.SubscriptionInfo.Status?
    
    // 交易监听任务
    var updateListenerTask: Task<Void, Error>? = nil

    // 产品ID到表情映射字典
    private let productIdToEmoji: [String: String]

    init() {
        // 从配置文件加载产品ID到表情的映射
        productIdToEmoji = Store.loadProductIdToEmojiData()

        // 初始化空产品列表,然后异步执行产品请求来填充它们
        cars = []
        fuel = []
        subscriptions = []
        nonRenewables = []

        // 尽可能早地启动交易监听器,以免错过任何交易
        updateListenerTask = listenForTransactions()

        Task {
            // 在商店初始化期间,从App Store请求产品
            await requestProducts()

            // 交付用户购买的产品
            await updateCustomerProductStatus()
        }
    }

    deinit {
        // 在类销毁时取消交易监听任务
        updateListenerTask?.cancel()
    }
}

产品配置加载

Store类从配置文件中加载产品ID到表情符号的映射,这是一种将产品与视觉元素关联的简洁方式。

  • Name
    实现细节
    Type
    steps
    Description
    • 读取Products.plist文件
    • 解析为字典数据
    • 提供产品ID到表情的映射
  • Name
    应用场景
    Type
    meaning
    Description

    在UI中展示产品时,可以使用对应的表情符号来增强视觉效果。

产品配置加载

static func loadProductIdToEmojiData() -> [String: String] {
    guard let path = Bundle.main.path(forResource: "Products", ofType: "plist"),
          let plist = FileManager.default.contents(atPath: path),
          let data = try? PropertyListSerialization.propertyList(from: plist, format: nil) as? [String: String] else {
        return [:]
    }
    return data
}

// 获取产品对应的表情符号
func emoji(for productId: String) -> String {
    return productIdToEmoji[productId]!
}

// 按价格排序产品
func sortByPrice(_ products: [Product]) -> [Product] {
    products.sorted(by: { return $0.price < $1.price })
}

交易监听实现

StoreKit 2提供了一个强大的交易监听机制,可以捕获所有交易更新,包括应用启动时的恢复购买。

  • Name
    关键功能
    Type
    steps
    Description
    • 创建后台任务监听交易
    • 验证每个交易的真实性
    • 更新用户产品状态
    • 完成交易处理
  • Name
    注意事项
    Type
    notes
    Description
    • 应尽早启动监听器
    • 必须验证每个交易
    • 必须完成(finish)每个交易
    • 处理验证失败的情况

交易监听实现

func listenForTransactions() -> Task<Void, Error> {
    return Task.detached {
        // 迭代处理所有不是直接来自`purchase()`调用的交易
        for await result in Transaction.updates {
            do {
                let transaction = try self.checkVerified(result)

                // 向用户交付产品
                await self.updateCustomerProductStatus()

                // 始终完成交易
                await transaction.finish()
            } catch {
                // StoreKit有一个验证失败的交易。不要向用户交付内容
                print("Transaction failed verification.")
            }
        }
    }
}

func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
    // 检查JWS是否通过StoreKit验证
    switch result {
    case .unverified:
        // StoreKit解析了JWS,但验证失败
        throw StoreError.failedVerification
    case .verified(let safe):
        // 结果已验证。返回解包的值
        return safe
    }
}

产品请求实现

从App Store请求产品信息是内购实现的第一步,StoreKit 2使用异步API简化了这一过程。

  • Name
    实现步骤
    Type
    steps
    Description
    • 使用产品ID请求产品信息
    • 根据产品类型分类
    • 按价格排序产品
    • 更新UI显示
  • Name
    产品类型
    Type
    concept
    Description
    • 消耗型(.consumable):如游戏中的燃料
    • 非消耗型(.nonConsumable):如游戏中的车辆
    • 自动续期订阅(.autoRenewable):如高级会员
    • 非续期订阅(.nonRenewable):如一年期会员

产品请求实现

@MainActor
func requestProducts() async {
    do {
        // 使用`Products.plist`文件定义的标识符从App Store请求产品
        let storeProducts = try await Product.products(for: productIdToEmoji.keys)

        var newCars: [Product] = []
        var newSubscriptions: [Product] = []
        var newNonRenewables: [Product] = []
        var newFuel: [Product] = []

        // 根据类型将产品分类
        for product in storeProducts {
            switch product.type {
            case .consumable:
                newFuel.append(product)
            case .nonConsumable:
                newCars.append(product)
            case .autoRenewable:
                newSubscriptions.append(product)
            case .nonRenewable:
                newNonRenewables.append(product)
            default:
                // 忽略此产品
                print("Unknown product.")
            }
        }

        // 按价格从低到高对每个产品类别进行排序,以更新商店
        cars = sortByPrice(newCars)
        subscriptions = sortByPrice(newSubscriptions)
        nonRenewables = sortByPrice(newNonRenewables)
        fuel = sortByPrice(newFuel)
    } catch {
        print("Failed product request from the App Store server. \(error)")
    }
}

购买流程实现

StoreKit 2简化了购买流程,使用异步API处理整个购买过程,包括验证和交付。

  • Name
    购买步骤
    Type
    steps
    Description
    • 发起产品购买请求
    • 处理购买结果
    • 验证交易
    • 更新用户产品状态
    • 完成交易
  • Name
    结果处理
    Type
    notes
    Description
    • 成功:验证并交付产品
    • 用户取消:返回nil
    • 待处理:等待进一步处理
    • 其他情况:返回nil

购买流程实现

func purchase(_ product: Product) async throws -> Transaction? {
    // 开始购买用户选择的`Product`
    let result = try await product.purchase()

    switch result {
    case .success(let verification):
        // 检查交易是否已验证。如果没有,
        // 此函数会重新抛出验证错误
        let transaction = try checkVerified(verification)

        // 交易已验证。向用户交付内容
        await updateCustomerProductStatus()

        // 始终完成交易
        await transaction.finish()

        return transaction
    case .userCancelled, .pending:
        return nil
    default:
        return nil
    }
}

func isPurchased(_ product: Product) async throws -> Bool {
    // 确定用户是否购买了给定产品
    switch product.type {
    case .nonRenewable:
        return purchasedNonRenewableSubscriptions.contains(product)
    case .nonConsumable:
        return purchasedCars.contains(product)
    case .autoRenewable:
        return purchasedSubscriptions.contains(product)
    default:
        return false
    }
}

用户产品状态更新

这个方法是整个Store类的核心,负责确定用户拥有哪些产品的权限,并更新UI状态。

  • Name
    实现功能
    Type
    steps
    Description
    • 获取用户当前所有权限
    • 处理不同类型的产品
    • 验证非续期订阅的有效期
    • 更新订阅组状态
  • Name
    特殊处理
    Type
    notes
    Description
    • 非消耗型产品:永久拥有
    • 非续期订阅:需手动计算过期时间
    • 自动续期订阅:检查订阅状态
    • 家庭共享:获取最高服务级别

用户产品状态更新

@MainActor
func updateCustomerProductStatus() async {
    var purchasedCars: [Product] = []
    var purchasedSubscriptions: [Product] = []
    var purchasedNonRenewableSubscriptions: [Product] = []

    // 迭代用户购买的所有产品
    for await result in Transaction.currentEntitlements {
        do {
            // 检查交易是否已验证。如果没有,捕获`failedVerification`错误
            let transaction = try checkVerified(result)

            // 检查交易的`productType`并从商店获取相应的产品
            switch transaction.productType {
            case .nonConsumable:
                if let car = cars.first(where: { $0.id == transaction.productID }) {
                    purchasedCars.append(car)
                }
            case .nonRenewable:
                if let nonRenewable = nonRenewables.first(where: { $0.id == transaction.productID }),
                   transaction.productID == "nonRenewing.standard" {
                    // 非续期订阅没有固有的过期日期,所以`Transaction.currentEntitlements`
                    // 在用户购买后始终包含它们
                    // 此应用将此非续期订阅的过期日期定义为购买后一年
                    // 如果当前日期在购买日期的一年内,用户仍有权使用此产品
                    let currentDate = Date()
                    let expirationDate = Calendar(identifier: .gregorian).date(byAdding: DateComponents(year: 1),
                                                               to: transaction.purchaseDate)!

                    if currentDate < expirationDate {
                        purchasedNonRenewableSubscriptions.append(nonRenewable)
                    }
                }
            case .autoRenewable:
                if let subscription = subscriptions.first(where: { $0.id == transaction.productID }) {
                    purchasedSubscriptions.append(subscription)
                }
            default:
                break
            }
        } catch {
            print()
        }
    }

    // 使用购买的产品更新商店信息
    self.purchasedCars = purchasedCars
    self.purchasedNonRenewableSubscriptions = purchasedNonRenewableSubscriptions

    // 使用自动续期订阅产品更新商店信息
    self.purchasedSubscriptions = purchasedSubscriptions

    // 检查`subscriptionGroupStatus`以了解自动续期订阅状态,确定客户
    // 是新客户(从未订阅)、活跃客户还是非活跃客户(订阅已过期)
    // 此应用只有一个订阅组,因此subscriptions数组中的产品都属于同一组
    // 客户只能订阅订阅组中的一个产品
    // `product.subscription.status`返回的状态适用于整个订阅组
    subscriptionGroupStatus = try? await subscriptions.first?.subscription?.status.max { lhs, rhs in
        // 由于此应用支持家庭共享,可能有多个家庭成员的不同状态
        // 订阅者有权获得服务级别最高的状态
        let lhsEntitlement = entitlement(for: lhs)
        let rhsEntitlement = entitlement(for: rhs)
        return lhsEntitlement < rhsEntitlement
    }
}

订阅权限管理

这个方法用于确定用户的订阅服务级别,特别是在有多个订阅选项和家庭共享的情况下。

  • Name
    实现功能
    Type
    steps
    Description
    • 检查订阅状态
    • 处理过期或撤销的情况
    • 获取产品对应的权限级别
  • Name
    应用场景
    Type
    meaning
    Description
    • 多级订阅管理
    • 家庭共享权限处理
    • 服务级别降级处理

订阅权限管理

// 使用产品ID获取订阅的服务级别
func entitlement(for status: Product.SubscriptionInfo.Status) -> ServiceEntitlement {
    // 如果状态是过期的,则客户没有权限
    if status.state == .expired || status.state == .revoked {
        return .notEntitled
    }
    // 获取与订阅状态关联的产品
    let productID = status.transaction.unsafePayloadValue.productID
    guard let product = subscriptions.first(where: { $0.id == productID }) else {
        return .notEntitled
    }
    // 最后,获取此产品对应的权限
    return ServiceEntitlement(for: product) ?? .notEntitled
}

StoreKit 2的主要优势

相比传统的StoreKit 1 API,StoreKit 2提供了许多显著的改进。

  • Name
    技术优势
    Type
    concept
    Description
    • 使用Swift并发API
    • 内置收据验证
    • 简化的交易处理
    • 更好的订阅管理
    • 更少的样板代码
  • Name
    实际应用
    Type
    meaning
    Description
    • 减少开发时间
    • 提高代码安全性
    • 简化服务器验证
    • 更好的用户体验

StoreKit 2与StoreKit 1对比

// StoreKit 1 (旧API)
// 1. 需要手动实现收据验证
// 2. 使用代理模式处理异步回调
// 3. 需要大量样板代码处理交易队列
// 4. 订阅状态管理复杂

// StoreKit 2 (新API)
// 1. 使用async/await简化异步操作
// 2. 内置收据验证
// 3. 使用Transaction.updates和Transaction.currentEntitlements简化交易处理
// 4. 提供丰富的订阅状态API
// 5. 支持家庭共享

// 示例:StoreKit 2简化的产品请求
let products = try await Product.products(for: ["com.example.product1"])

// 示例:StoreKit 2简化的购买流程
let result = try await product.purchase()

最佳实践与注意事项

在实现StoreKit 2内购时,有一些重要的最佳实践需要遵循。

  • Name
    关键建议
    Type
    key-points
    Description
    • 尽早启动交易监听器
    • 始终验证每个交易
    • 始终完成(finish)每个交易
    • 正确处理不同类型的产品
    • 实现恢复购买功能
  • Name
    常见陷阱
    Type
    notes
    Description
    • 忘记完成交易导致重复交付
    • 未正确处理非续期订阅过期
    • 未考虑家庭共享情况
    • 未处理网络错误和离线情况

实现要点与陷阱

// 1. 尽早启动交易监听器
init() {
    // ...其他初始化代码...
    
    // 在应用启动时尽早启动,避免错过交易
    updateListenerTask = listenForTransactions()
}

// 2. 始终验证交易
let transaction = try checkVerified(verification)

// 3. 始终完成交易
await transaction.finish()

// 4. 正确处理非续期订阅过期
let currentDate = Date()
let expirationDate = Calendar(identifier: .gregorian)
    .date(byAdding: DateComponents(year: 1), to: transaction.purchaseDate)!

if currentDate < expirationDate {
    // 订阅仍然有效
}

// 5. 处理家庭共享
subscriptionGroupStatus = try? await subscriptions.first?.subscription?.status.max { lhs, rhs in
    let lhsEntitlement = entitlement(for: lhs)
    let rhsEntitlement = entitlement(for: rhs)
    return lhsEntitlement < rhsEntitlement
}

这篇文章对你有用吗?