大多数浏览器和
Developer App 均支持流媒体播放。
-
实现主动的 App 内购买项目恢复
了解如何在用户首次打开您的 App 时主动恢复他们的 App 内购买项目访问。我们将介绍如何利用 StoreKit 或 StoreKit 2 提供对现有订阅的即时访问,并讨论客户端和服务器实施的最佳实践。进一步探索如何确定客户的购买状态,为您的 App 打造个性化的新手引导体验。
资源
- App Store Server Notifications
- CloudKit
- Determining service entitlement on the server
- Implementing a store in your app using the StoreKit API
- Introducing StoreKit 2
- Reducing Involuntary Subscriber Churn
相关视频
WWDC22
Tech Talks
WWDC21
WWDC20
-
下载
David:开发者好 我是 David Wendland App Store 的商务技术倡导者 今天 我将向您介绍该如何通过 主动识别用户购买记录 包括新记录、当前记录、历史记录 来让您的 App 为顾客提供一流体验 且期间不需要顾客的任何操作 我将介绍如何使用 StoreKit 2 和初版 StoreKit 方便您为所有顾客优化 App 的启用体验 先说说主动 App 内购买项目恢复的定义吧 这指的是当顾客启动您的 App 时 您可以通过使用设备上现成的数据 主动检查交易记录 来确定这是新顾客还是现有顾客 期间不需要顾客进行操作 甚至都不需要 点击“恢复购买”或输入密码 由此 您能够根据 顾客的购买历史和状态 为其定制 App 体验 您的 App 可以为现有顾客 解锁产品或服务 也可以向新顾客推销最新产品 而对于那些曾经订阅的顾客 您可以为其提供订阅优惠 来吸引顾客回归 这就是主动恢复的意义所在 通过 StoreKit 自动为新顾客 现有顾客和既往顾客 在所有设备上自动优化 App 体验 我们来看看这个例子 这个 App 名为“海洋日志” 以它为例 在常见的销售体验中 顾客有几个行为召唤选项可供选择 我可以试着进行 App 内购买 并使用面容 ID 等 生物识别技术进行身份验证 我也可以创建 App 帐户 通过密钥串输入密码来登录帐号 如果我是个活跃订阅者 还可以使用“恢复购买”功能 当活跃订阅者使用新设备时 并不一定知道该选哪个选项 但有了触手可及的数据 加上我们的主动 App 内购买项目恢复最优解 您的 App 可以简化这一流程 也就是说 如果我在新设备上 启动了 App 且我是个活跃订阅者 启动后 无需我进行任何操作 该 App 会自动 主动恢复我的服务内容 因此 请看这里 App 识别出了 我的专业会员订阅 于是加载了我最喜欢的海滩 附上了冲浪条件 并启用了实时摄像功能 这种体验能够 让您的 App 与众不同 我将介绍如何在 iOS 15 及更高版本 上使用 StoreKit 2 实现这种效果 此外 针对 支持旧版本 iOS 的 App 我也将介绍如何使用旧版 StoreKit 和 verifyReceipt 端点 来创造同样出色的体验 背景介绍到此结束 我们进入正题 在本期视频 我将首先详细描述 App 所用的核心顾客产品状态 基于该状态 您的 App 能够使用 StoreKit 基于顾客的 App 内购买记录生成个性化体验 随后我将回顾使用 StoreKit 2 实现该效果的步骤 我会用 StoreKit 演示 App 来完成示例代码 先来看看每种 App 内购买项目类型 的核心顾客产品状态
并查看一些个性化入职体验示例 首先 适用的主动恢复的 App 内购类型包括 非消耗品 非续订类订阅 和自动续期订阅 此类服务都记录在顾客的交易历史中 StoreKit 始终可以调用 因此 您的 App 可以识别 每个顾客帐户中 对每个产品或订阅组的购买状态 当查看顾客产品状态时 我将使用“订阅项”一词来指代 非续订和自动续订订阅 以下是您的 App 可以针对其 进行个性化的三个核心状态 让我们深入观察一下新顾客 该状态表示的是某个已登录 App Store 的 Apple ID 中 没有任何当前或过去的 App 内购买记录 此状态通常也就是 App 的默认销售体验状态 我们的“海洋日志”正在推销 可免费试用一个月的 月度和年度订阅 接下来是第二个核心状态 已购顾客和活跃订阅者 在这种状态下 顾客有一个活跃的交易 且您的 App 有义务授予顾客 访问已购产品或服务的权限 请看这里 “海洋日志”立即 给顾客推送了 顾客偏好的海滩 以及高质量海滩实时镜头 由于服务已主动恢复 购买按钮消失了 对于每个已购产品或有效订阅 其交易都具有 静态且唯一的原始交易 ID 始终绑定顾客的 Apple ID 和 App Store 为了维护顾客的交易状态 您需要将原始交易 ID 与您系统的帐户关联 它可以是匿名帐户 或用户通过您的系统创建的帐户 原始交易 ID 对于 充分利用 App Store 服务器通知的强大功能非常重要 能够使您的服务器始终 保持最新的交易状态 需要强调的情况是 当顾客的订阅 未能自动续订 就进入了所谓的计费重试状态 在该情况下 我们将努力在至多 60 天内恢复订阅 如果您在 App Store Connect 中 选择了计费宽限期功能 进入计费重试状态 但处于宽限期内的订阅者 可以在我们努力恢复订阅的同时 继续访问订阅服务 虽然订阅者仍可以访问您的服务 请务必为其展示简单的行为召唤 来解决订阅者的付款问题 如果您想详细了解 计费重试和计费宽限期 请查看我们有关于 减少非自愿订户损失的 讲座和资源 最后一个核心状态是 不活跃购买或不活跃订阅者 此状态代表顾客曾进行过 App 内购买 但由于到期或被撤销 不再有产品或服务使用权限的顾客 这些交易始终存在 并包含原始交易 ID 您因此能够跨设备和平台维护状态 对于订阅 其非活跃状态 是由到期日期确定的 对于所有 App 内购买项目类型 如果存在撤销日期 就可能会处于非活跃状态 会出现该状态的情况包括 交易已退款时 或者通过家庭共享授予的 访问权限已被撤销时 对于由于到期或被撤销 产生的非活动订阅者 您可以考虑提供订阅优惠 来让顾客回归 对于处于计费重试状态的顾客 别忘记向他们展示同样的行为召唤 来解决顾客的付款细节问题 回顾一下 以下是 在主动恢复 App 内购买状态 并为顾客量身定制 App 体验时 App 将使用的三个 核心顾客产品状态 接下来看看“海洋日志”是 怎样应用这些体验的吧
新顾客将看到最新产品和试销优惠 而活跃顾客会觉得 App 运行一如往常 因为 App 在该类顾客的 所有设备上 简化了对产品和服务的访问流程 对于非活跃订阅者 您可以用优惠代码或促销优惠 向其展示最新的回归优惠 到此我们已经介绍了 三个核心顾客产品状态 也说明了为什么根据状态提供服务 能够为顾客带来良好体验 但是当然 我们还有更进一步的空间 您的 App 可以扩展或 改进顾客体验 来适合您的产品供应 商业模式 策略和优先事项 但您在 App 中实施主动恢复时 还有几件事需要考虑
如果您支持多个产品或订阅组 顾客状态是基于各产品 和订阅组分别确定的 因此 您可能需要考虑混合状态 或其他可能的依赖项 请思考一下非平台活动 以及该类活动会如何影响 顾客的产品状态 以及 请务必查看 App Store 服务器通知 因为这对于在服务器间同步 所有 App 内购买项目类型的状态非常重要 在版本 2 中 新的通知类型和 子类型 能够近乎实时地 将 28 个独特事件 安全发送到您的服务器 如果您想深入了解 版本 2 的集成或迁移 可以观看主题为 “探索 App 内购买的集成和迁移”的视频 在视频中 Alex 和 Gabriel 还介绍了 StoreKit 2 的兼容性 原始的 StoreKit 框架 以及最佳实践方式 到目前为止 我们已经讨论了 支持的顾客产品状态 以及这种体验可以 为您的顾客带来什么 现在让我们来看看实现细节 我将使用 StoreKit 演示 App 演示 App 已经更新了 StoreKit 2 的主动恢复功能 请注意 StoreKit 演示 App 可在本期视频中下载 让我们看看 StoreKit 演示 为新顾客提供的默认体验 也就是还没有进行任何 App 内购买的顾客 为了查看产品 需要点击“商店”按钮 屏幕上方展示了当前可用汽车库存 这些术语非消耗型 App 内购买项目产品 然后将导航服务设为 每月自动续期订阅服务 提供三种不同级别的服务供顾客选择 下方有一个非续订订阅选项 允许一次性的访问 以上就是我们 App 提供给未消费顾客的新顾客体验 现在让我们看看 App 如何确定 该顾客在当前或过去是否有购买记录 App 需要在启动后 立即执行三个步骤 最重要的是 在“购买”按钮 出现在顾客界面前 这些步骤就全部完成了 第一步 App 需要开始监听 来自 App Store 的交易 这是 App Store 的最佳使用方法 因为交易记录可能因为 家庭共享询问购买、 代码兑换、订阅自动续订、 或者中断购买等情况 随时显示 此外 由于退款而失去访问权限 或不再通过家庭共享而撤销的交易 也可以被 App 接收 等被授予访问权限后 且顾客状态 从活跃变为不活跃的过程中 App 能在启动时更多应用此功能 交易被发现后 会被判定为未完成交易 需要经过验证 交付给顾客 并标记为已完成 由此 您的 App 可以保证 不错过任何交易 并提供出色的顾客体验 现在来看看 StoreKit 演示 App 是如何监听 StoreKit 2 交易的 这里用了函数 listenForTransactions 即“监听交易” 该函数将为已登录的 App Store 顾客 返回任何未完成交易或更新交易 对于发现的交易 StoreKit 2 将验证交易 的真实性 接着 在我的 App 交付内容后 授予访问权限或更新顾客产品状态 然后完成交易 向 App Store 表明 购买已送达 一旦交易完成 它将不会在任何设备上 通过 StoreKit 返回到您的 App 这第一个步骤对 所有 App 都非常重要 此后每次 App 启动时 都会重复这一步骤 第二步是确定顾客产品状态 也就是要通过使用 currentEntitlements 即“当前授权” 来主动请求顾客的活跃交易 特别是自动续期订阅 此类订阅会改变顾客状态 例如已取消 计费重试或待降级 这种情况下 您可以使用函数 Product.SubscriptionInfo.RenewalState 来通过 StoreKit 演示 App 看看我们是如何做到这一点的 从函数 updateCustomerProductStatus 即“更新顾客产品状态”开始 该函数能够为每种长期 App 内购买项目类型 跟踪顾客产品状态 然后使用 StoreKit 2 的 currentEntitlements 方法 遍历每种购买类型 顾客所有可能获得产品授权 的交易都会被返回 我们根据产品类型记录交易 这是非消耗品的代码 这是非续订订阅产品的 为了确定顾客是活跃订阅者 还是非活跃订阅者 我为非续订订阅添加了额外的逻辑 来计算到期日期 最后 我要检查一个 活跃的自动续期订阅 并将该状态应用于订阅组 考虑到非活动状态 例如计费重试、过期和撤销 可变订阅组状态使用的是 Product.SubscriptionInfo.RenewalState 函数 现在我们已经检索了用户的交易 并确定了各产品或订阅组的顾客状态 我们的 App 获得了 能够支持为各种用例 定制体验的逻辑 让我们看看 StoreKit 演示 App 的源代码 如果对于三种 App 内购买项目产品类型 都没有发现活跃交易 顾客将进行默认的新顾客体验 就像我们之前看过的那样 顾客将在“商店”页面 看到简单的行为召唤 如果顾客有活跃交易的记录 App 启动时 此类顾客将 看到已购买的服务 各产品上的“购买”按钮 也会相对进行更新 所以 对于非消耗品 我们将展示已购买产品与服务 而 App 将显示已购的非消耗品 或提供行动号召 让顾客看到购买选项 这里是对于活跃产品的处理方式 在此情境下 顾客是导航服务 非自动续期订阅与自动续期订阅 的活跃订阅者 最后是不活跃的订阅者 那些订阅项已过期、被撤销 或处于计费重试状态的顾客 接下来让我们进入 StoreKit 演示 App 来模拟一位非消耗型和 自动续期订阅活跃顾客 假设我购买了赛车并订阅了专业导航 演示 App 将应用绿色复选标记 表示确认这些购买已成功、已验证 且已启用 通过购买以上产品与服务 我对于该非消耗品的顾客产品状态 变为了已购买 对于订阅项来说 我是活跃的订阅者 现在 如果我将 App 安装在了新设备上 当我第一次启动 StoreKit 演示 App 时 它将主动执行步骤一、二、三 在这里 您可以看到演示 App 已主动恢复了我的两项交易 而且根本不需要我来操作 由于这是一个演示 App 产品的同步范围只能到这里了 但在您的 App 中 此过程将确保不会给活跃顾客 推送已购产品的购买信息 还会自动为顾客启用这些产品和服务 这对于您已有的顾客来说 是很棒的功能 顾客无需登录或点击“恢复购买” 简简单单就能起效 您的 App 可以使用 现成的 API 和数据 目前为止已经介绍了用 StoreKit 2 实现该功能的三个步骤 接下来 我想说说该如何在 无法运用 StoreKit 2 强大功能的 旧版本 iOS 上也为您的顾客 带来同样的良好体验 您需要在旧版 StoreKit 中执行 与 StoreKit 2 相同的步骤 来确定顾客产品状态 在 iOS 7 或更高版本上 主动恢复 App 内购买 为此 您的服务器需要使用 verifyReceipt 端点 来验证和检索最新交易 以确定顾客的产品状态 App Store 安装 App 时 设备上会显示 App 收据 但请记住 在使用 Sandbox 或 TestFlight 进行测试时 App 收据仅存在于 App 内购买完成或恢复后 App 无法找到 App 收据的情况 应该只发生在 Sandbox 中 您的 App 可以将这种情况 等同于新顾客状态 即没有发现 App 内购买记录的情况 过去创建的 App 收据就足够 从 App Store 检索最新交易了 因此 就不需要 顾客的操作来“恢复购买” 或者刷新收据 只需在 verifyReceipt 请求中 包含共享密钥 就能够接收最新的非消耗型订阅 非续订订阅 和自动续期订阅的交易 让我们回顾一下三个实施步骤 区别在于第二步 也就是识别顾客产品状态的步骤 确定顾客产品状态的流程 从设备上的 App 收据开始 您的服务器也会用 App Store 的 verifyReceipt 端点验证收据 我们来看看这个过程 首先 我们需要检索 App 收据 请您确保使用的是 appStoreReceiptURL 属性 以开发者文档中的示例为准 有了 App 收据 来看看设备从设备发送到 您的服务器和 App Store 的过程 左侧是您在设备上的 App 它会首先检索 App 收据 并将其发送到您的服务器 然后使用 App Store verifyReceipt 端点对其进行验证 验证结果将确定顾客产品状态 状态信息会被发送到您的 App 为了确定顾客产品状态 我们使用了 WWDC20 大会的 授权引擎 该引擎现已更新 可支持非消耗型和非自动续期订阅 在没有 App 内购买记录时 也能处理新顾客状态
如果您想要了解授权引擎的更多信息 建议您查看 WWDC20 的 “订阅服务架构”视频 并下载示例项目 您可以通过本视频的资源 找到该视频链接以及更多内容 这样就完成了第二步 在这一步 App 将从您的服务器 接收顾客产品状态 现在 您的 App 将在启动时使用 StoreKit 2 和旧版 StoreKit 框架 立即个性化 App 体验 最后 我还想分享一些最佳实践方法 首先 请在您的 App 中 保留“恢复购买”按钮 虽然很少用到 但在出现问题 或者顾客 使用了其他 Apple ID 时 该按钮能够让顾客 强制 App 恢复其 Apple ID 的交易 当您的 App 首次在设备上 主动恢复顾客的 App 内购买时 建议您优化 App 并安全存储数据 这有助于确定顾客产品状态 CloudKit 具有相当的灵活性 安全性 以及跨设备同步的能力 是个值得应用的功能 使用 StoreKit 时 进行实现测试非常重要 有了 StoreKit 2 您可以使用 Sandbox TestFlight 和 Xcode StoreKit 测试主动恢复的效果 如果您使用的是旧版 StoreKit 请务必记住 在 Sandbox 和 TestFlight 中进行测试时 可能无法检索到 App 收据 而在 App Store 安装 App 后 必然产生 App 收据 如果没有 App 收据 建议您的 App 使用 默认的新顾客体验 以及 请确保您备有 随时可用的“恢复购买”按钮 总之 请您更新 App 主动检查交易记录 此过程中 顾客无需点击 或验证等任何操作 请您允许 App 在启动时定制顾客体验 以分别适应新顾客 活跃顾客 和非活跃顾客的产品状态 请在服务器间维护好 针对所有 App 内购买项目类型的 顾客交易状态 实现 App Store 服务器通知 版本 2 就能实现该功能 由此 您的后端能够近乎实时地了解 交易发生的任何变化 比如退款或撤销交易 或订阅续订 计费重试和服务到期 感谢您的收看 请务必查看 另一个视频 主题为 “App 内购买的新功能” 在该视频中 Dani 和 Ian 将 向您介绍有关 App 内购买的所有重大更新 包括 StoreKit、Server API 和 Server Notifications Version 2 感谢收看 祝您一切顺利
-
-
11:16 - Transaction Listener at app launch
//Transaction Listener at app launch func listenForTransactions() -> Task<Void, Error> { return Task.detached { //Iterate through any transactions which didn't come from a direct call to `purchase()`. for await result in Transaction.updates { do { let transaction = try self.checkVerified(result) //Deliver products to the user. await self.updateCustomerProductStatus() //Always finish a transaction await transaction.finish() } catch { //StoreKit transaction failed verification, don't deliver content to user. print("Transaction failed verification") } } } }
-
12:27 - Determine customer product state
//Determine customer product state func updateCustomerProductStatus() async { var purchasedCars: [Product] = [] var purchasedSubscriptions: [Product] = [] var purchasedNonRenewableSubscriptions: [Product] = [] //Iterate through all of the user's purchased products. for await result in Transaction.currentEntitlements { do { //First check if the transaction is verified. If the transaction is not verified //we'll catch the `failedVerification` error. let transaction = try checkVerified(result) //Check the `productType` of the transaction and get the corresponding product from the store. switch transaction.productType { case .nonConsumable: if let car = cars.first(where: { $0.id == transaction.productID }) { purchasedCars.append(car) } //..
-
12:56 - Determine customer product state
//Determine customer product state case .nonRenewable: if let nonRenewable = nonRenewables.first(where: { $0.id == transaction.productID }), transaction.productID == "nonRenewing.standard" { //Non-renewing subscriptions have no inherent expiration. let currentDate = Date() let expirationDate = Calendar(identifier: .gregorian).date(byAdding: DateComponents(year: 1), to: transaction.purchaseDate)! if currentDate < expirationDate { purchasedNonRenewableSubscriptions.append(nonRenewable) } } //..
-
13:09 - Determine customer product state
//Determine customer product state case .autoRenewable: if let subscription = subscriptions.first(where: { $0.id == transaction.productID }) { purchasedSubscriptions.append(subscription) } default: break } } catch { print() } } //Update the Store information with the purchased products. self.purchasedCars = purchasedCars self.purchasedNonRenewableSubscriptions = purchasedNonRenewableSubscriptions self.purchasedSubscriptions = purchasedSubscriptions //Check subscriptionGroupStatus to learn auto-renewable subscription state subscriptionGroupStatus = try? await subscriptions.first?.subscription?.status.first?.state }
-
13:45 - Updating my car view at app launch
//Updating my car view at app launch if store.purchasedCars.isEmpty && store.purchasedNonRenewableSubscriptions.isEmpty && store.purchasedSubscriptions.isEmpty { VStack { Text("SK Demo App") .bold() .font(.system(size: 50)) .padding(.bottom, 20) Text("🏎💨") .font(.system(size: 120)) .padding(.bottom, 20) Text("Head over to the shop to get started!") .font(.headline) NavigationLink { StoreView() } //… } } }
-
13:59 - Updating my car view at app launch
//Updating my car view at app launch else { List { Section("My Cars") { if !store.purchasedCars.isEmpty { ForEach(store.purchasedCars) { product in NavigationLink { ProductDetailView(product: product) } label: { ListCellView(product: product, purchasingEnabled: false) } } } else { Text("You don't own any car products. \nHead over to the shop to get started!") } } //…
-
14:20 - Updating my car view at app launch
//Updating my car view at app launch Section("Navigation Service") { if !store.purchasedNonRenewableSubscriptions.isEmpty || !store.purchasedSubscriptions.isEmpty { ForEach(store.purchasedNonRenewableSubscriptions) { product in NavigationLink { ProductDetailView(product: product) } label: { ListCellView(product: product, purchasingEnabled: false) } } ForEach(store.purchasedSubscriptions) { product in NavigationLink { ProductDetailView(product: product) } label: { ListCellView(product: product, purchasingEnabled: false) } } }
-
14:30 - Updating my car view at app launch
//Updating my car view at app launch else { if let subscriptionGroupStatus = store.subscriptionGroupStatus { if subscriptionGroupStatus == .expired || subscriptionGroupStatus == .revoked { Text("Welcome Back! \nHead over to the shop to get started!") } else if subscriptionGroupStatus == .inBillingRetryPeriod { //Provide a deep link from your app to https://apps.apple.com/account/billing. Text("Please verify your billing details.") } } else { Text("You don't own any subscriptions. \nHead over to the shop to get started!") } } }
-
17:42 - Fetch App Receipt Data
//Fetch App Receipt Data public func getReceipt() { if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL, FileManager.default.fileExists(atPath: appStoreReceiptURL.path) { do { let receiptData = try Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped) print(receiptData) let receiptString = receiptData.base64EncodedString(options: []) print("receipt send it to your server: \(receiptString)") // Read receiptData } catch { print("Couldn't read receipt data with error: " + error.localizedDescription) } } }
-
-
正在查找特定内容?在上方输入一个主题,就能直接跳转到相应的精彩内容。
提交你查询的内容时出现错误。请检查互联网连接,然后再试一次。