WWDC Quick Look 💓 By SwiftGGTeam
What's new in StoreKit 2 and StoreKit Testing in Xcode

What's new in StoreKit 2 and StoreKit Testing in Xcode

观看原视频

Highlight

StoreKit 2 在 WWDC2023 新增推广购买管理 API、Transaction/RenewalInfo 数据模型扩展、SwiftUI 专用商品展示视图(ProductView/StoreView/SubscriptionStoreView),以及 Xcode 15 中支持多设备离线调试、模拟错误注入和自定义订阅续费速率的 StoreKit Testing 工具链。

核心内容

推广购买的完整链路

用户在 App Store 产品页看到推广的内购项目,点击购买后,应用需要监听这个意图并完成交易。以前开发者只能被动等待,现在可以主动管理推广产品的展示顺序和可见性。

用户在 App Store 购买推广产品时,应用可能还没准备好(比如在关卡中)。PurchaseIntent 支持延迟处理,保存意图稍后完成。已购买的产品可以隐藏,游戏关卡进度可以动态调整展示顺序。

数据模型终于够用了

Transaction 模型新增了 storefront(商店地区)、storefrontCountryCode(国家代码)和 reason(购买原因:用户主动购买还是自动续订)。RenewalInfo 新增了 nextRenewalDate(下次续订日期)。

这些字段解决了长期痛点:判断用户来自哪个地区商店、区分首次购买和续订、提前告知用户下次扣费时间。

测试不再是黑箱

Xcode 15 的 StoreKit Testing 支持在设备未运行应用时直接创建交易,模拟一年前的订阅购买并观察自动续订。可以注入网络错误、产品不可用等异常,验证应用的容错逻辑。订阅续订速率可以调到每分钟一次,快速验证长期订阅状态。

详细内容

监听推广购买意图

01:42

import StoreKit

let promotedPurchasesListener = Task {
    for await promotion in PurchaseIntent.intents {
        let product = promotion.product

        do {
            let result = try await product.purchase()
            // 处理购买结果
        } catch {
            // 处理错误,或保存意图稍后重试
        }
    }
}

关键点:

  • PurchaseIntent.intents 是 async sequence,每次用户在 App Store 点击推广购买时产生新值
  • 应用未运行时系统会保留意图,启动后立刻收到
  • 可以保存 promotion 对象,在用户合适的时机调用 purchase()
  • 需要在应用生命周期内保持 listener 运行

管理推广产品顺序和可见性

02:57

import StoreKit

// 查询当前推广顺序
do {
    let promotions = try await Product.PromotionInfo.currentOrder

    if promotions.isEmpty {
        // 没有本地覆盖,使用 App Store Connect 配置的顺序
    }

    for promotion in promotions {
        let productID = promotion.productID
        let visibility = promotion.visibility
    }
} catch {
    // 处理错误
}

// 设置自定义顺序
let newOrder = ["acorns.individual", "nectar.cup", "sunflowerseeds.pile"]
do {
    try await Product.PromotionInfo.updateProductOrder(byID: newOrder)
} catch { }

// 隐藏特定产品
do {
    try await Product.PromotionInfo.updateProductVisibility(.hidden, for: "acorns.individual")
} catch { }

// 或通过对象修改
var firstPromotion = promotions.first!
firstPromotion.visibility = .hidden
try await firstPromotion.update()

关键点:

  • currentOrder 为空表示没有本地覆盖,使用 App Store Connect 的默认配置
  • visibility 有三种状态:.visible.hidden.default(跟随 App Store Connect 设置)
  • 修改顺序和可见性只影响当前设备
  • 适合根据游戏进度、用户状态动态调整推广展示

Transaction 和 RenewalInfo 新字段

05:00

// Transaction 新增字段
for await result in Transaction.currentEntitlements {
    if case .verified(let transaction) = result {
        let storefront = transaction.storefront
        let countryCode = transaction.storefrontCountryCode
        let reason = transaction.reason // .purchase 或 .renewal

        print("商店: \(storefront), 原因: \(reason)")
    }
}

// RenewalInfo 新增字段
if let subscriptionInfo = product.subscription {
    let renewalInfo = try await subscriptionInfo.status
    for status in renewalInfo {
        if case .verified(let info) = status.renewalInfo {
            if let nextDate = info.nextRenewalDate {
                print("下次续订: \(nextDate)")
            }
        }
    }
}

关键点:

  • storefrontstorefrontCountryCode 帮助判断用户所在地区
  • reason 区分用户主动购买(.purchase)和自动续订(.renewal
  • nextRenewalDate 在 RenewalInfo 中,告知下次续订处理时间
  • Xcode 15 构建的应用可用,大部分字段向前兼容旧版系统

处理账单问题消息

06:12

for await message in Message.messages {
    switch message.reason {
    case .billingIssue:
        // 订阅续订失败,显示账单问题 sheet
        // StoreKit 自动展示,应用可选择推迟或抑制
        await message.display()
    default:
        break
    }
}

关键点:

  • .billingIssue 在 iOS 16.4 引入,订阅因账单问题续订失败时触发
  • StoreKit 自动展示 Billing Problem sheet,用户无需离开应用即可解决
  • 应用可以推迟消息(用户正在交互流程中)或抑制消息
  • Sandbox 环境已启用,可提前测试

SwiftUI 商品展示视图

08:32

import SwiftUI
import StoreKit

// 单个商品视图
struct BirdFoodShop: View {
    let productID: String

    var body: some View {
        ProductView(id: productID) {
            BirdFoodProductIcon(for: productID)
        }
        .productViewStyle(.large)
    }
}

// 商店视图(多个商品)
struct StorePage: View {
    let productIDs: [String]

    var body: some View {
        StoreView(ids: productIDs) { product in
            BirdFoodIcon(productID: product.id)
        }
    }
}

// 订阅商店视图
struct SubscriptionPage: View {
    let groupID: String

    var body: some View {
        SubscriptionStoreView(groupID: groupID)
    }
}

// 管理订阅 sheet
.manageSubscriptionsSheet(
    isPresented: $showManageSheet,
    subscriptionGroupID: groupID // 直接跳转到指定订阅组
)

关键点:

  • ProductView 自动加载商品信息,显示本地化标题、描述和促销图片
  • StoreView 展示商品集合,几行代码搭建完整商店
  • SubscriptionStoreView 展示订阅组的所有等级,只需 subscriptionGroupID
  • .manageSubscriptionsSheet 新增 subscriptionGroupID 参数,直接跳转到指定组
  • 所有视图支持 SwiftUI 标准自定义,如背景、字体等

Xcode Transaction Manager 离线调试

11:12

Xcode 15 的 Transaction Manager 支持:

  • 查看所有连接设备和模拟器上的 StoreKit 应用交易记录
  • 无需运行应用即可查看历史购买
  • 直接从 Mac 创建新购买,配置购买参数(数量、优惠码、购买日期)
  • 支持订阅的自动续订配置和历史回溯

操作路径:Debug > StoreKit > Manage Transactions

模拟 StoreKit 错误

18:54

在 StoreKit Configuration 文件的 Configuration Settings 中:

  • 勾选 Purchase API 的模拟错误选项
  • 选择错误类型,如 .productUnavailable(用户切换商店)
  • 配置自动保存并同步到运行中的设备
  • 无需重新运行应用即可测试

支持的 API 和错误类型包括:加载产品、购买、收据验证、交易验证、退款请求等。

单元测试中的模拟购买和错误

21:09

import StoreKit
import StoreKitTest
import XCTest

func testSubscriptionRenewal() async throws {
    let session = try SKTestSession(configurationFileNamed: "Store")

    // 模拟一年前的订阅购买
    let oneYear: TimeInterval = 365 * 24 * 60 * 60
    let transaction = try await session.buyProduct(
        identifier: "birdpass.individual",
        options: [.purchaseDate(Date.now - oneYear)]
    )

    // 验证交易状态
    XCTAssertNotNil(transaction)
}

func testLoadProductsError() async throws {
    let session = try SKTestSession(configurationFileNamed: "Store")

    // 注入网络错误
    session.setSimulatedError(.generic(.networkError), forAPI: .loadProducts)

    do {
        _ = try await Product.products(for: ["product1"])
        XCTFail("应该抛出网络错误")
    } catch StoreKitError.networkError {
        // 预期的错误
    }

    // 清除模拟错误
    session.setSimulatedError(nil, forAPI: .loadProducts)
}

func testFastRenewal() async throws {
    let session = try SKTestSession(configurationFileNamed: "Store")

    // 每分钟续订一次,快速验证长期订阅状态
    session.timeRate = .oneRenewalEveryMinute

    let transaction = try await session.buyProduct(identifier: "birdpass.individual")

    // 等待观察续订行为
}

关键点:

  • SKTestSession 从 StoreKit Configuration 文件创建测试环境
  • buyProduct(options:) 支持 purchaseDate 等测试专用选项
  • setSimulatedError(_:forAPI:) 注入指定 API 的错误
  • timeRate 控制订阅续订速度,.oneRenewalEveryMinute 最快
  • 测试结束后清除模拟错误,避免影响其他测试

核心启发

  1. 动态推广商店

    • 做什么:根据用户游戏进度动态调整 App Store 推广产品的展示顺序和可见性
    • 为什么值得做Product.PromotionInfo 允许本地覆盖推广顺序,用户通关后自动展示高级道具,已购买的道具自动隐藏
    • 怎么开始:监听 PurchaseIntent.intents 处理推广购买,用 updateProductOrderupdateProductVisibility 根据游戏状态调整
  2. 订阅状态仪表盘

    • 做什么:在应用内展示用户的订阅详情,包括下次续订日期、商店地区、续订原因
    • 为什么值得做nextRenewalDate 让用户提前知道扣费时间,storefrontCountryCode 帮助定位地区定价问题
    • 怎么开始:遍历 Transaction.currentEntitlements 读取 storefrontreason,从 Product.SubscriptionInfo 获取 renewalInfo
  3. 账单问题自助修复

    • 做什么:订阅续订失败时主动引导用户修复支付方式,减少流失
    • 为什么值得做Message.messages 中的 .billingIssue 让应用能在用户离开前解决问题,StoreKit 自动展示修复 sheet
    • 怎么开始:监听 Message.messages,收到 .billingIssue 时判断时机,调用 message.display() 或推迟到合适时机
  4. 零代码商店页面

    • 做什么:用 StoreKit SwiftUI 视图快速搭建应用内购买页面
    • 为什么值得做StoreViewSubscriptionStoreView 几行代码实现完整商店,自动处理加载、展示和购买流程
    • 怎么开始:传入商品 ID 数组创建 StoreView,或用 SubscriptionStoreView(groupID:) 展示订阅选项,用 .productViewStyle.containerBackground 自定义外观

关联 Session

评论

GitHub Issues · utterances