WWDC Quick Look 💓 By SwiftGGTeam
Implement App Store Offers

Implement App Store Offers

观看原视频

Highlight

App Store 的订阅优惠体系已经有了 introductory offers(拉新)、promotional offers(促活)和 offer codes(营销渠道分发)三种形态。今年的更新分两个方向:一是给现有 API 做了信息补全,二是推出全新的 win-back offers 用来挽回流失用户。


核心内容

订阅产品的核心难题是用户流失。一个用户取消自动续期后,开发者几乎没有任何手段再次触达他——promotional offers 需要自己验证资格、自己发推送,转化链路长且成本高。Apple 今年给出的解法是 win-back offers:一种全新的优惠类型,专门面向”订阅已过期且关闭了自动续期”的流失用户。

win-back offers 和以往优惠最大的区别在于:Apple 替你做用户分群和渠道分发。你在 App Store Connect 里配置资格条件(付费时长、流失时长、两次优惠间隔),Apple 自动匹配符合条件的用户,并在 App Store 的产品页、Today 标签页、订阅设置页等位置展示优惠卡片。用户点卡、确认支付,一气呵成。你甚至可以在 App Store Connect 里为 win-back offer 配置优先级,Apple 会用它来个性化推荐。

API 层面也有配套更新。Transaction 和 RenewalInfo 两个结构体新增了 offer 字段,把优惠 ID、类型、支付模式统一放在一起;StoreKit Views 新增了 subscriptionPromotionalOfferpreferredSubscriptionOffer 两个 View Modifier,让你可以直接在 SubscriptionStoreView 里控制展示哪个优惠;macOS 15 终于支持了 offer code redemption sheet。


详细内容

Transaction 和 RenewalInfo 新增 offer 字段

Transaction 结构体在 iOS 17.2 新增了 offer 成员,类型为可选值,包含 idtypepaymentMode 三个字段。只有当用户购买时使用了优惠,这个字段才会被填充。RenewalInfo 中的同名字段则从 iOS 18.0 起可用,表示下一个续期周期将应用的优惠(02:02)。

在 App Store Server API 侧,对应的字段是 offerIdentifierofferType(已有)和新增的 offerDiscountType(新字段,历史交易也会回填)。offerDiscountType 的值对应 StoreKit 中的 paymentMode,区分 free trial / pay-as-you-go / pay-up-front(02:53)。

macOS 15 支持 offer code redemption sheet

// Present offer code redemption sheet on macOS - SwiftUI API

import SwiftUI
import StoreKit

struct MyView: View {

    @State var showOfferCodeRedemption: Bool = false

    var body: some View {
        Button("Redeem Code") {
            showOfferCodeRedemption = true
        }
        .offerCodeRedemption(isPresented: $showOfferCodeRedemption) { result in
            // Handle result
        }
    }
}
  • @State var showOfferCodeRedemption:用布尔状态控制兑换弹窗的显示/隐藏
  • .offerCodeRedemption(isPresented:):macOS 15 起与 iOS 同 API,Sheet 自动处理兑换流程
  • result 闭包:拿到兑换结果后做后续处理(04:25

preferredSubscriptionOffer:在 SubscriptionStoreView 中选择展示哪个优惠

// Choose preferred offer in a SubscriptionStoreView

import SwiftUI
import StoreKit

struct MyView: View {
    let groupID: String

    var body: some View {
        SubscriptionStoreView(groupID: groupID)
            .preferredSubscriptionOffer { product, subscription, eligibleOffers in
                let freeTrialOffer = eligibleOffers
                    .filter { $0.paymentMode == .freeTrial }
                    .max { lhs, rhs in
                        lhs.period.value < rhs.period.value
                    }
                return freeTrialOffer ?? eligibleOffers.first
            }
    }
}
  • preferredSubscriptionOffer:闭包参数为 product、subscription、eligibleOffers,StoreKit 自动传入当前用户符合资格的所有优惠
  • $0.paymentMode == .freeTrial:筛选出免费试用类优惠
  • .max { lhs.period.value < rhs.period.value }:找周期最长的免费试用
  • freeTrialOffer ?? eligibleOffers.first:没找到免费试用就回退到第一个可用优惠(20:15

检查订阅资格与 win-back offer 展示逻辑

// Check subscription entitlement and offer eligibility

import StoreKit

func shouldShowMerchandising(
    for groupID: String,
    productIDs: [Product.ID]
) async throws -> MerchandisingVisibility {
    // Get subscription status
    let statuses = try await Product.SubscriptionInfo.status(for: groupID)

    // Check if the customer is already entitled to the subscription
    let entitlement = SubscriptionEntitlement(for: statuses)
    if entitlement.autoRenewalEnabled {
        return .hidden
    }

    // Check for offers to show in merchandising UI
    let products = try await Product.products(for: productIDs)

    let isEligibleForIntroOffer = await Product.SubscriptionInfo.isEligibleForIntroOffer(for: groupID)
    if isEligibleForIntroOffer {
        let subscriptions = products.map {
            ($0, $0.subscription?.introductoryOffer)
        }
        return .visible(subscriptions)
    }

    // Check for eligible win-back offers
    let purchasedStatus = statuses.first {
        $0.transaction.unsafePayloadValue.ownershipType == .purchased
    }
    let renewalInfo = try purchasedStatus?.renewalInfo.payloadValue
    let bestWinBackOfferID = renewalInfo?.eligibleWinBackOfferIDs.first

    // Return the product with the offer if there is one
    if let bestWinBackOfferID {
        let subscriptions: [(Product, Product.SubscriptionOffer?)] = products.map {
            let winBackOffer = $0.subscription?.winBackOffers.first {
                $0.id == bestWinBackOfferID
            }
            return ($0, winBackOffer)
        }
        return .visible(subscriptions)
    }

    // Only return the product if there is no offer
    return .visible(products.map { ($0, nil) })
}
  • Product.SubscriptionInfo.status(for:):拉取订阅组的当前状态
  • SubscriptionEntitlement:自定义结构体,从状态列表中判断是否享有权益、是否开启自动续期
  • isEligibleForIntroOffer:先查 introductory offer 资格,新用户优先展示试用
  • $0.transaction.unsafePayloadValue.ownershipType == .purchased:只看”自己付费购买”的记录,排除 Family Sharing
  • renewalInfo?.eligibleWinBackOfferIDs.first:Apple 返回的列表第一个就是最佳 win-back offer
  • $0.subscription?.winBackOffers.first { $0.id == bestWinBackOfferID }:用 ID 在产品信息中匹配完整 offer 对象(23:05

将 win-back offer 添加到购买

// Add a win-back offer to a purchase

import StoreKit

func purchase(
    _ product: Product,
    with offer: Product.SubscriptionOffer?
) async throws {
    // Prepare the purchase options
    var purchaseOptions: Set<Product.PurchaseOption> = []

    // Add win-back offer to the purchase
    if let offer, offer.type == .winBack {
        purchaseOptions.insert(.winBackOffer(offer))
    }

    // Make the purchase
    try await product.purchase(options: purchaseOptions)
}
  • .winBackOffer(offer):iOS 18.0 新增的 PurchaseOption,把 win-back offer 对象传入购买流程
  • product.purchase(options:):App Store 会在弹出支付 Sheet 前自动验证用户资格(25:26

Streamlined Purchasing 和 PurchaseIntent

如果你在 App Store Connect 开启了 App Store 推广(默认开启),用户可以在 App Store 内直接完成 win-back offer 的购买,不用先打开 app。这个行为叫 streamlined purchasing。如果你需要在购买前执行自定义逻辑(比如展示引导页),可以在 App Store Connect 中关闭它,然后在 app 里用 PurchaseIntent API 接收用户在 App Store 发起的购买(28:48)。PurchaseIntent 在 iOS 18.0 新增了 offer 字段,仅当包含 win-back offer 时才会填充。


核心启发

  • 做什么:为流失用户配置 win-back offers 分群策略。用”付费订阅时长 ≥ 3 个月 + 流失 2-6 个月”作为首批条件,在 App Store Connect 中创建一个免费试用型 win-back offer。为什么值得做:Apple 自动分发,你不用自己写资格验证和推送逻辑,覆盖面比 promotional offers 更广。怎么开始:在 App Store Connect 的订阅定价页找到 Win-Back Offers 标签,点击 Create Offer,配置资格条件和价格。

  • 做什么:在 SubscriptionStoreView 中接入 preferredSubscriptionOffer,让 StoreKit Views 自动展示最优优惠。为什么值得做:当用户同时符合 introductory offer 和 win-back offer 时,你需要决定展示哪一个,这个 View Modifier 让你在几行代码里完成选择逻辑。怎么开始:在已有的 SubscriptionStoreView 上添加 .preferredSubscriptionOffer 修饰符,闭包里按业务优先级返回合适的 SubscriptionOffer。

  • 做什么:迁移 Transaction 和 RenewalInfo 中的优惠检查到新的 offer 字段。为什么值得做:offer 字段把优惠 ID、类型、支付模式统一在一起,是 Apple 推荐的查询方式,老的散列字段未来可能被废弃。怎么开始:将服务端解析 JWSTransaction 时对 offerIdentifier + offerType 的读取改为同时读取 offerDiscountType,客户端侧将 transaction.offer 作为首选检查入口。

  • 做什么:为 macOS 版应用添加 offer code redemption sheet 支持。为什么值得做:macOS 15 终于补上了这个能力,之前 macOS 用户只能手动输入优惠码,体验差。怎么开始:用 .offerCodeRedemption(isPresented:) SwiftUI 修饰符,代码与 iOS 完全一致。


关联 Session

评论

GitHub Issues · utterances