Moon

 · 2天 ago

Hotwire 原生深度解析:iOS 应用内购买

从 HTML 付费墙到实时成功信息的完整流程,以及仅凭一个 UUID 就能将一切串联起来。

如果你在 iOS 应用中销售数字内容或订阅,苹果要求你使用应用内购买。没法避免。对于 Rails 开发者来说,这是最难构建的功能之一:你需要原生的 Swift 代码来与 StoreKit 通信,Webhook 处理器来处理苹果的通知,并且需要一个将所有信息同步回服务器的方法。

顺便说一句:如果你卖给美国或欧盟客户,苹果现在允许链接到像 #Stripe 这样的外部支付服务商。但他们会显示一个令人害怕的警告画面,导致转化率下降。对于大多数应用来说,原生内购仍然是更好的途径。

本文将详细介绍整个流程:从 HTML 付费墙触发购买,在 Swift 中处理,处理苹果的 webhooks,以及用 Turbo Streams 实时更新 UI。过去 10+年里,我为许多客户开发过这个系统,这里的方法正是 PurchaseKit 的底层运作方式。

应用内购买流程

这是我们正在构建的内容:



提醒一下,这些代码示例是简化的。请参阅下方可下载的源代码以获取完整实现。

1. 通过 HTML 触发购买

一个按钮会触发刺激控制器,传递产品 ID 和一个令牌以识别 HTML 中的用户。

<div data-controller="bridge--paywall">
<button
data-action="bridge--paywall#subscribe"
data-product-id="com.example.pro.annual"
data-user-id="<%= Current.user.id %>">
Subscribe for $4.99 per month
</button>
</div>

2. 将请求发送给 native

桥控制器提取数据并发送给 iOS 桥接组件。

export default class extends BridgeComponent {
static component = "paywall"

subscribe(event) {
const productId = event.currentTarget.dataset.productId
const userId = event.currentTarget.dataset.userId
this.send(”purchase”, { productId, userId })
}
}

3. 致电 StoreKit

iOS 接收消息后,从 StoreKit 获取产品,并以用户标识发起购买。

class PaywallComponent: BridgeComponent {
override class var name: String { "paywall" }

override func onReceive(message: Message) {
let id = message.data["productId"]
let userId = message.data["userId"]

let product = Product.products(for: [id]).first!
product.purchase(options: [.appAccountToken(userId)])
}
}

4. 处理苹果的 webhook

成功购买后,苹果会向你的 Rails 服务器发送签名的 JSON 网络签名(JWS)。通过解码,通过标识符查找用户,并更新其订阅。

class AppStoreWebhooksController < ApplicationController
def create
payload = decode(params["signedPayload"])
info = decode(payload["data"]["signedTransactionInfo"])
user = User.find(info["appAccountToken"])

case payload["notificationType"]
when "SUBSCRIBED"
user.subscription.update!(
product_id: info["productId"],
expires_at: Time.at(info[”expiresDate”] / 1000),
status: :active
)
end

head :ok
end
end

5. 广播更新

当订阅保存完毕时,会播放一个 Turbo Stream,用成功提示取代付费墙。

class Subscription < ApplicationRecord
belongs_to :user

after_commit :broadcast_update, if: :active?

def broadcast_update
Turbo::StreamsChannel.broadcast_replace_to(
user, :paywall,
target: "paywall",
partial: "paywalls/success"
)
end
end

关键洞察是 appAccountToken。当 iOS 进行购买时,它可以传递一个 UUID,苹果会在与该交易相关的每个 webhook 中回响该 UUID。我们用它来关联购买与用户:将用户 ID 嵌入令牌中,当苹果的 webhook 到来时,提取它以知道谁刚刚订阅了。

这意味着没有会话 Cookie、回调 URL 或“待处理购买”记录需要清理。

产品 ID(比如 com.yourapp.pro.monthly)是识别购买内容的工具。你可以把这些硬编码在 HTML 按钮里,然后通过 webhook 回馈。

这种方法有很多好处。

  • 你可以在 Rails 中设计和迭代你的付费墙。 这意味着你可以进行定价实验或完成重新设计,而无需向 App Store 提交新的二进制文件。
  • 订阅数据存储在您的 Rails 数据库中,而非苹果服务器。 那你就可以用你平时的 user.subscribed 了吗? 而不是需要往返到设备。
  • 监听 webhook 确保即使用户再也不会打开应用,订阅数据也能始终保持同步 。当有人在应用里订阅时,这非常有帮助——你也希望让他们在网页上使用。

如果看起来像是一堆动的环节,嗯......你是对的。PurchaseKit 处理所有这些(加上 Android、错误处理和边缘情况),所以你可以跳过复杂性。但如果你想了解它内部的工作原理,或者自己组装,请继续阅读。

付费订阅者将获得完整实现:Swift、JavaScript 和 Ruby 的每一行内容都被逐步解释,还有一个可下载的演示应用,内置可运行的 StoreKit 代码,你今天就可以在设备上运行。

步骤 0:App Store Connect 设置

在以下代码运行之前,你需要在 App Store Connect 中配置几项:创建一个包含产品的订阅组,设置 webhook URL,并创建一个沙盒测试账户。

我不会逐一介绍这里的每个点击,因为苹果的界面经常变化。相反,可以查看 PurchaseKit 的 App Store Connect 指南 ,该指南保持最新,并附有截图。

第一步:付费墙(HTML)

我们的付费墙是来自 Rails,它生成的是普通的 HTML 代码。我们需要两个传递给客户端的值:用户的唯一标识符和购买产品的 ID。

<div data-controller="bridge--paywall"
data-bridge--paywall-user-id-value="<%= @user_id %>">

<button class="btn btn-primary btn-lg"
data-action="bridge--paywall#subscribe"
data-product-id="<%= @annual.id %>">
<%= @annual.price %>/year
</button>

<button class="btn btn-outline-primary btn-lg"
data-action="bridge--paywall#subscribe"
data-product-id="<%= @monthly.id %>">
<%= @monthly.price %>/month
</button>
</div>

我们在控制器中设置了这些实例变量。

class PaywallsController < ApplicationController
def show
@user_id = Current.user.app_account_token

@annual = Product.new(
id: "com.example.pro.annual",
price: "$9.99"
)

@monthly = Product.new(
id: "com.example.pro.monthly",
price: "$0.99"
)
end

Product = Struct.new(:id, :price, keyword_init: true)
end

app_account_token 是我们通过苹果为用户关联购买的方式。请记住,我们不会直接回传到应用,而是依赖苹果通过他们的成功数据为我们的服务器创建一个 webhook。这确保了我们能将购买与 数据库中的用户匹配。而 StoreKit 需要 UUID,所以我们会把 User.id 转换成一个 UUID。

class User < ApplicationRecord
def app_account_token
"%08x-0000-0000-0000-000000000000" % id
end

def self.find_by_app_account_token(token)
user_id = token.split("-").first.to_i(16)
find_by(id: user_id)
end
end

接下来我们将构建一个提取这些值并将其传递给 iOS 的 JavaScript。

步骤 2:JavaScript 桥接控制器

桥接组件是 Hotwire 原生应用在网页和原生代码之间通信的方式。JavaScript 端扩展了 BridgeComponent,并通过 this.send() 发送消息 。本地端通过 onReceive(message:) 接收这些数据 。 静态组件属性通过名称将它们连接在一起。

import { BridgeComponent } from "@hotwired/hotwire-native-bridge"

export default class extends BridgeComponent {
static component = "paywall"
static values = { userId: String }

subscribe(event) {
const productId = event.currentTarget.dataset.productId
this.send(”purchase”, { productId, userId: this.userIdValue })
}
}

接下来我们将介绍接收该消息并与 #StoreKit API 交互的原生代码。

步骤 3:iOS 桥接组件 + StoreKit

本地端接收“购买”信息,解码 JSON,并通过 StoreKit 启动购买流程。

class PaywallComponent: BridgeComponent {
override nonisolated class var name: String { "paywall" }

override func onReceive(message: HotwireNative.Message) {
Task { await purchase(message) }
}

private func purchase(_ message: HotwireNative.Message) async {
guard let data: PurchaseRequest = message.data()
else { return }

let productId = data.productId ​
do {
try await Store.purchase(id: productId, token: data.userId)
} catch {
print(error.localizedDescription)
}
}
}

private extension PaywallComponent {
struct PurchaseRequest: Decodable {
let productId: String
let userId: UUID
}
}

Store 助手负责实际的 StoreKit 调用。关键一行是 product.purchase(options:)。这会将用户标识符传递给苹果,苹果会将其包含在 webhook 负载中,以便我们将购买数据匹配到正确的用户。

enum Store {
static func purchase(id: String, token: UUID) async throws {
let products = try await Product.products(for: [id])
guard let product = products.first else {
throw StoreError.productNotFound
}

let result = try await product.purchase(
options: [.appAccountToken(token)]
)

if case .success(let verification) = result,
case .verified(let transaction) = verification
{
await transaction.finish()
} else {
throw StoreError.purchaseFailed
}
}
}

注意没有回复 JavaScript。我们不需要,因为购买会在苹果那边完成,我们会通过 webhook 得知。Turbo Stream 负责更新用户界面。

transaction.finish() 调用很重要。它告诉苹果我们已经确认了购买。跳过它,苹果会不断重试交易。我们这里只检查 .verified, 但不会对客户端做额外的收据验证。苹果的 Webhook 会带着签名的交易数据访问我们的服务器,这才是我们真正信任购买是合法的。

其他教程会在客户端验证购买情况,并建议直接向服务器发送 POST。但你不能完全信任设备的数据,因为它可能会被伪造。Webhook 直接来自苹果的服务器,所以我们让他们帮我们处理验证。

说到这里,让我们设置一个端点,解析来自苹果的 webhook 并提取订阅数据。

步骤 4:苹果 webhook 处理

苹果以嵌套 JWS 的形式发送 webhook 。外部有效载荷包含通知类型和一个已签名的 TransactionInfo 字段,这是另一个包含实际交易细节的 JWS。我们解码两者以获得所需信息。

我们在购买时传递的 appAccountToken 会在交易信息中返回。这就是我们找到用户的方式。接着,我们打开通知类型: 订阅,DID_RENEW 创建或更新订阅,DID_CHANGE_RENEWAL_STATUS 和过期则处理取消。

class WebhooksController < ApplicationController
def apple
payload = decode_jwt(params[:signedPayload])
transaction = decode_jwt(payload["data"]["signedTransactionInfo"])
app_account_token = transaction["appAccountToken"]
user = User.find_by_app_account_token(app_account_token)

case payload["notificationType"]
when "SUBSCRIBED", "DID_RENEW"
subscription = user.subscriptions.find_or_initialize_by(
processor_id: transaction["transactionId"]
)
subscription.update!(
store: "apple",
store_product_id: transaction["productId"],
status: "active",
current_period_end: Time.at(transaction["expiresDate"] / 1000),
ends_at: Time.at(transaction["expiresDate"] / 1000)
)

when "DID_CHANGE_RENEWAL_STATUS"
# ...

when "EXPIRED"
# ...

head :ok
end
end

private

def decode_jwt(token)
JWT.decode(token, nil, false).first
end
end

你会注意到我们在解码 JWT 时没有验证签名。在生产环境中,你需要获取苹果的公钥,缓存它们,并正确验证签名。我们这里跳过这个,专注于流程。 苹果的文档提供了整个流程的概述。

好,最后一步:启动一个 Turbo Stream,异步更新付费墙。

步骤 5:订阅模式+Turbo Stream

webhook 保存了订阅,但用户仍然盯着付费墙。我们需要向浏览器推送更新。涡轮流速队来救场!🎉

首先,付费墙视图订阅了针对当前用户的频道。

<%= turbo_stream_from Current.user, :paywall %>

<div id="paywall"
data-controller="bridge--paywall"
data-bridge--paywall-user-id-value="<%= @user_id %>">
<%# ... %>
</div>

当订阅变得有效时,after_commit 回拨会广播该频道的替代频道。 目标与我们想替换的 div 的 id 匹配 。

class Subscription < ApplicationRecord
belongs_to :user
enum status: [:active, :cancelled, :expired]

after_create :broadcast_success, if: :active?

def broadcast_success
Turbo::StreamsChannel.broadcast_replace_to(
user, :paywall,
target: "paywall",
partial: "paywalls/success"
)
end
end

成功部分会在感谢信息中被替换。注意它保持相同的 id=“付费墙”,所以后续的广播仍然有目标。

<div id="paywall">
<h2>You’re subscribed!</h2>
<p>Thank you for your support.</p>
<%= link_to "Continue", paid_path %>
</div>

用户从未刷新或点击任何按钮。付费墙消失了,成功信息就会出现。这就是将服务器发送的 webhook 与 Turbo Streams 结合的魔力所在。

接下来是什么

你有代码,但你到底怎么测试它?Xcode 的 StoreKit 配置文件允许你在本地模拟购买过程,而无需使用 App Store Connect。创建一个 .storekit 文件,添加你的产品,Xcode 就像虚拟商店一样。当你准备测试完整的 webhook 流程时,你需要一个沙箱账户和一个实体设备,因为 StoreKit 配置文件不会触发真正的 webhook。

但请记住,这只是 #iOS 的特点。Android 需要自己的实现,通知格式与 Google Play 完全不同。你还需要处理谷歌的确认要求,购买必须在 3 天内确认,否则会自动退款。

我们还保持简单,专注于快乐的道路。生产实施需要处理本地化定价、续订失败、宽限期、新设备上的购买恢复以及订阅升级和降级。😅

这是我用于这次深入分析的完整源代码。它包含一个 Rails 应用和一个 iOS 项目,配置完全符合这里所示。欢迎你把作品复制到自己的应用里,或者把它当作新作品的起点。

下载源代码

但如果构建和维护这些听起来比你愿意承担的还要多,那这正是我打造 PurchaseKit 的原因 。它能处理两个平台的复杂性,规范化 webhook 格式,让你能专注于 Rails 应用。

考虑把这个分享给其他开发者。这有助于通讯触达那些能从中获得最大收益的人。

共享

邀请你的朋友,赚取奖励

如果你喜欢 #Hotwire Native Weekly,欢迎分享给朋友们,订阅时还能获得奖励。

邀请朋友

作者 Joe Masilotti

來源 https://newsletter.masilotti.com/p/hotwire-native-deep-dive-in-app-purchases

Download Pickful App

Better experience on mobile

iOS QR

iOS

Android QR

Android

APK QR

APK