WWDC Quick Look 💓 By SwiftGGTeam
Meet StoreKit for SwiftUI

Meet StoreKit for SwiftUI

观看原视频

Highlight

Apple 在 StoreKit 中推出了一组 SwiftUI 原生视图(StoreView、ProductView、SubscriptionStoreView),开发者只需几行代码就能在 App 中展示和销售内购商品与订阅,同时支持 Xcode Previews 实时预览,无需在 App Store Connect 中手动配置截图即可看到实际效果。


核心内容

在 iOS 17 之前,开发者要在 App 中搭建一个内购商店页面,需要做不少工作:加载产品列表、监听购买状态、处理交易回调、自己写 UI 布局。如果还要支持订阅,还要额外处理试用期、折扣优惠、升级降级等逻辑。一个完整的商店页面动辄几百行代码,而且每次调试都要跑真实设备。

StoreKit 在 iOS 17 中直接提供了三组 SwiftUI 视图,把产品加载、展示、购买流程全部封装好了。开发者只需要告诉它产品 ID,视图会自动从 App Store 拉取元数据(名称、价格、描述),渲染出完整的 UI。

这三组视图分别是:

  • StoreView — 用于展示一次性购买商品(非消耗品、消耗品)的集合视图
  • ProductView — 用于展示单个商品,可以自定义布局
  • SubscriptionStoreView — 用于展示订阅商品组,自动处理试用期、折扣、升级等展示逻辑

下面分别来看这三组视图怎么用。


详细内容

StoreView:几行代码搭一个商品货架

StoreView 是最简单的入口。你给它一组产品 ID,它自动拉取元数据并以默认样式渲染出来。

03:35 先从最基本的开始。新建一个 BirdFoodShop 视图:

import SwiftUI
import StoreKit

struct BirdFoodShop: View {
    @Query var birdFood: [BirdFood]

    var body: some View {
        StoreView(ids: birdFood.productIDs)
    }
}

关键点:

  • import StoreKit 引入 StoreKit 框架,才能使用这些新视图
  • StoreView(ids:) 接收一组产品 ID([String]),自动从 App Store Connect 拉取对应产品的元数据
  • 你不需要手动调用 SKProductRequest,不需要管理 loading/error 状态
  • @Query 是 SwiftData 的属性包装器,这里用来获取鸟食数据模型中的产品 ID 列表

04:51 如果觉得默认的商品图标不够个性,可以传入一个闭包自定义每个产品的装饰图标:

StoreView(ids: birdFood.productIDs) { product in
    BirdFoodProductIcon(productID: product.id)
}

StoreView 默认使用 .compact 样式。你也可以显式指定样式:

StoreView(ids: birdFood.productIDs) { product in
    BirdFoodProductIcon(productID: product.id)
}
.productViewStyle(.compact)

ProductView:自定义布局和样式

当你想自己设计商店的排版时,ProductView 是更灵活的选择。它代表单个商品的视图,你可以把它放到任何 SwiftUI 布局容器里。

06:47ProductView 放进 ScrollView + VStack 中,手动排版:

ScrollView {
    VStack(spacing: 10) {
        if let (birdFood, product) = birdFood.bestValue {
            ProductView(id: product.id) {
                BirdFoodProductIcon(
                    birdFood: birdFood,
                    quantity: product.quantity
                )
            }
            .padding()
            .background(.background.secondary, in: .rect(cornerRadius: 20))
            .padding()
            .productViewStyle(.large)
        }
    }
    .scrollClipDisabled()

    Text("Other Bird Food")
        .font(.title3.weight(.medium))
        .frame(maxWidth: .infinity, alignment: .leading)

    ForEach(birdFood.premiumBirdFood) { birdFood in
        BirdFoodShopShelf(title: birdFood.name) {
            ForEach(birdFood.orderedProducts) { product in
                ProductView(id: product.id) {
                    BirdFoodProductIcon(
                        birdFood: birdFood,
                        quantity: product.quantity
                    )
                }
            }
        }
    }
}
.contentMargins(.horizontal, 20, for: .scrollContent)

关键点:

  • ProductView(id:) 接收单个产品 ID,自动加载并展示该产品
  • 第二个参数是装饰图标闭包,和 StoreView 一样可以自定义
  • .productViewStyle(.large) 指定大尺寸样式,适合做主打商品展示
  • ProductView 放进 ForEach + VStack,就相当于手动实现了 StoreView 的布局能力
  • .productViewStyle(.compact) 是默认样式,适合列表展示

21:25 使用推广图标,让某个产品在视觉上更突出:

ProductView(
    id: ids.nutritionPelletBox,
    prefersPromotionalIcon: true
) {
    BoxOfNutritionPelletsIcon()
}

prefersPromotionalIcon: true 会让图标使用更大的尺寸,适合在商品列表中突出某个推荐商品。

21:56 也可以给图标加边框:

ProductView(id: ids.nutritionPelletBox) {
    BoxOfNutritionPelletsIcon()
        .productIconBorder()
}

20:52 在加载中时显示占位图标:

ProductView(id: ids.nutritionPelletBox) {
    BoxOfNutritionPelletsIcon()
} placeholderIcon: {
    Circle()
}

placeholderIcon 在产品元数据尚未加载完成时显示,避免 UI 空白。

自定义 ProductViewStyle

23:02 ProductViewStyle 协议允许你完全控制产品视图的外观和行为。内置的有 .compact.large,但你也可以自己写:

struct SpinnerWhenLoadingStyle: ProductViewStyle {
    func makeBody(configuration: Configuration) -> some View {
        switch configuration.state {
        case .loading:
            ProgressView()
                .progressViewStyle(.circular)
        default:
            ProductView(configuration)
        }
    }
}

使用方式:

ProductView(id: ids.nutritionPelletBox) {
    BoxOfNutritionPelletsIcon()
}
.productViewStyle(SpinnerWhenLoadingStyle())

关键点:

  • ProductViewStyle 协议只有一个方法 makeBody(configuration:)
  • configuration.state 有四种状态:.loading.failure(let error).unavailable.success(let product)
  • 在 loading 时显示菊花旋转指示器,其他情况回退到默认样式 ProductView(configuration)

23:58 更完整的自定义样式示例,完全控制每个状态:

struct BackyardBirdsStyle: ProductViewStyle {
    func makeBody(configuration: Configuration) -> some View {
        switch configuration.state {
        case .loading:
            ProgressView()
        case .failure(let error):
            Text("加载失败")
        case .unavailable:
            Text("不可用")
        case .success(let product):
            HStack(spacing: 12) {
                configuration.icon
                VStack(alignment: .leading, spacing: 10) {
                    Text(product.displayName)
                    Button(product.displayPrice) {
                        configuration.purchase()
                    }
                    .bold()
                }
            }
            .backyardBirdsProductBackground()
        }
    }
}

关键点:

  • configuration.icon 是你之前传入的装饰图标闭包
  • configuration.purchase() 触发购买流程
  • product.displayNameproduct.displayPrice 是 App Store 元数据中的字段,自动本地化

SubscriptionStoreView:订阅商店一键生成

SubscriptionStoreView 是专门为订阅设计的视图。它自动处理了试用期、折扣、多档位对比等订阅特有的展示需求。

09:57 最基本的使用方式:

import SwiftUI
import StoreKit

struct BackyardBirdsPassShop: View {
    @Environment(\.shopIDs.pass) var passGroupID

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

groupID 是在 App Store Connect 中配置的订阅组 ID。SubscriptionStoreView 会自动加载该组中的所有订阅层级并展示。

10:38 在订阅页顶部添加营销内容:

SubscriptionStoreView(groupID: passGroupID) {
    PassMarketingContent()
}

传入的闭包是营销区域的内容,你可以放 logo、宣传图、功能介绍等。

10:57 设置全高背景和样式:

SubscriptionStoreView(groupID: passGroupID) {
    PassMarketingContent()
        .lightMarketingContentStyle()
        .containerBackground(for: .subscriptionStoreFullHeight) {
            SkyBackground()
        }
}
.backgroundStyle(.clear)

.containerBackground(for: .subscriptionStoreFullHeight) 设置整个订阅页的背景,从顶部营销区一直延伸到订阅选项区。

11:21 控制订阅按钮的标签样式:

.subscriptionStoreButtonLabel(.multiline)

.multiline 让按钮标签支持多行,适合显示较长的订阅描述。

11:44 设置选择器项的背景材质:

.subscriptionStorePickerItemBackground(.thinMaterial)

12:20 显示兑换码按钮:

.storeButton(.visible, for: .redeemCode)

31:22 切换控制样式为按钮式(默认是选择器式):

.subscriptionStoreControlStyle(.buttons)

按钮式让每个订阅层级以独立按钮展示,而不是 Picker 式的单选列表。

33:44 给每个订阅层级自定义图标:

.subscriptionStoreControlIcon { subscription, info in
    Group {
        let status = PassStatus(
            levelOfService: info.groupLevel
        )
        switch status {
        case .premium:
            Image(systemName: "bird")
        case .family:
            Image(systemName: "person.3.sequence")
        default:
            Image(systemName: "wallet.pass")
        }
    }
    .foregroundStyle(.tint)
    .symbolVariant(.fill)
}

35:30 只显示升级选项(隐藏当前订阅层级):

SubscriptionStoreView(
    groupID: passGroupID,
    visibleRelationships: .upgrade
) {
    PremiumMarketingContent()
        .containerBackground(for: .subscriptionStoreFullHeight) {
            SkyBackground()
        }
}

visibleRelationships: .upgrade 只展示比当前层级更高的订阅选项,适合已经订阅的用户升级。

30:32 显示 App Store 的隐私政策和条款链接:

.subscriptionStorePolicyForegroundStyle(.white)
.storeButton(.visible, for: .policies)

29:56 自定义登录行为:

@State var presentingSignInSheet = false

var body: some View {
    SubscriptionStoreView(groupID: passGroupID) {
        PassMarketingContent()
            .containerBackground(for: .subscriptionStoreFullHeight) {
                SkyBackground()
            }
    }
    .subscriptionStoreSignInAction {
        presentingSignInSheet = true
    }
    .sheet(isPresented: $presentingSignInSheet) {
        SignInToBirdAccountView()
    }
}

如果用户需要在购买前登录你的 App 账号(比如关联现有账号的订阅状态),可以通过 .subscriptionStoreSignInAction 拦截默认的登录流程,弹出你自己的登录页。

处理购买结果

14:10 在视图上监听购买完成事件:

BirdFoodShop()
    .onInAppPurchaseCompletion { (product: Product, result: Result<Product.PurchaseResult, Error>) in
        if case .success(.success(let transaction)) = result {
            await BirdBrain.shared.process(transaction: transaction)
            dismiss()
        }
    }

15:43 监听购买开始事件(适合显示 loading 状态):

BirdFoodShop()
    .onInAppPurchaseStart { (product: Product) in
        self.isPurchasing = true
    }

查询订阅状态

16:57 使用 subscriptionStatusTask 查询当前用户的订阅状态:

subscriptionStatusTask(for: passGroupID) { taskState in
    if let statuses = taskState.value {
        passStatus = await BirdBrain.shared.status(for: statuses)
    }
}

19:37 对于非消耗品,用 currentEntitlementTask 检查购买资格:

currentEntitlementTask(for: "com.example.id") { state in
    self.isPurchased = BirdBrain.shared.isPurchased(
        for: state.transaction
    )
}

处理产品加载状态

26:44 当需要更精细地控制加载流程时,使用 storeProductsTask

@State var productsState: Product.CollectionTaskState = .loading

var body: some View {
    ZStack {
        switch productsState {
        case .loading:
            BirdFoodShopLoadingView()
        case .failed(let error):
            ContentUnavailableView(/* ... */)
        case .success(let products, let unavailableIDs):
            if products.isEmpty {
                ContentUnavailableView(/* ... */)
            } else {
                BirdFoodShop(products: products)
            }
        }
    }
    .storeProductsTask(for: productIDs) { state in
        self.productsState = state
    }
}

关键点:

  • storeProductsTask(for:) 异步加载产品,并通过回调更新状态
  • Product.CollectionTaskState 有三种状态:.loading.failed(let error).success(let products, let unavailableIDs)
  • 根据状态展示不同的 UI:加载中、加载失败、空结果、正常内容

核心启发

1. 给已有 App 快速接入内购商店

如果你的 App 目前是用 WebView 展示商品页,或者根本没有内购商店,现在只需要创建一个新页面,放一个 StoreViewSubscriptionStoreView,传入产品 ID,就有一个完整的原生商店页面了。产品元数据、价格本地化、购买按钮全部自动处理。

入口 APIStoreView(ids:)SubscriptionStoreView(groupID:)

2. 打造一个有”主打款”的商品详情页

ProductView + .productViewStyle(.large) 把最划算的商品在顶部放大展示,下面用 .compact 样式列出其他商品。prefersPromotionalIcon: true 让主打商品的图标更加醒目。

入口 APIProductView(id:prefersPromotionalIcon:content:) + .productViewStyle(.large)

3. 做订阅层级对比页

SubscriptionStoreView 默认的 Picker 样式已经提供了层级对比。但如果你想要更自定义的对比效果,可以用 .subscriptionStoreControlStyle(.buttons) 切换成按钮布局,然后给每个层级加上自定义图标和描述。visibleRelationships: .upgrade 用来做专门的升级页。

入口 APISubscriptionStoreView(groupID:visibleRelationships:content:)

4. 结合 SwiftData 做一个动态商品货架

示例中用了 @Query var birdFood: [BirdFood] 从 SwiftData 获取数据,然后把数据模型中的 productIDs 传给 StoreView。你可以在 App 中维护一个本地商品目录(分类、排序、推荐逻辑),然后把对应的 product ID 列表交给 StoreKit 视图渲染。

入口 API@Query + StoreView(ids:)

5. 写自定义 ProductViewStyle 做品牌化商店

如果你的 App 有独特的设计风格,内置的 .compact.large 样式可能不够用。通过实现 ProductViewStyle 协议,你可以完全控制每个产品的展示方式——从 loading 状态的动画到购买按钮的样式,再到整个卡片的布局。

入口 APIProductViewStyle 协议,makeBody(configuration:) 方法


关联 Session

评论

GitHub Issues · utterances