blog
blog copied to clipboard
SwiftUI付费方式接入
苹果应用如果跟付费相关的话,现在是限定要使用 in-app purchase 方式的,也就是应用内购买的方式,还好 SwiftUI 在这块的支持也是挺好的,接入挺方便,下面就介绍下接入的细节。
之前有个 App 尝试过一个【Buy me a coffee】的打赏按扭直接跳 Paypal 的支付链接,第一个版本通过审核了,后面再更新的时候就被拒绝,理由是付费相关需要使用 “in-app purchase” 接入方式。
![image](https://user-images.githubusercontent.com/7159888/200387191-95b44900-7f7f-483a-b1a4-d3ace371b8ef.png)
苹果付费类型
- 消耗品:Consumable
- 非消耗品:Non-Consumable
- 自动续期的订阅:Auto-Renewable Subscriptions
- 不自动续期的订阅:Non-Renewing Subscriptions
接入前:银行和税务相关的协议
如果你的应用涉及到付费,必需要完成税务相关的一些条例的声明。
访问 appstoreconnect,在 “Agreements, Tax, and Banking” 完成相关的协议表格。
免费的比较简单,其中付费的那块需要填一些银行、税务相关的信息,一步步填下去就好,也不难。
![image](https://user-images.githubusercontent.com/7159888/200380933-ad5fb1bb-65e4-410d-bfeb-66a6dbb791a5.png)
代码接入
参考链接:https://blckbirds.com/post/how-to-use-in-app-purchases-in-swiftui-apps/
步骤1:App Store Connect 增加付费详细内容和价格
![image](https://user-images.githubusercontent.com/7159888/200384262-04233aeb-6a48-47b4-b09f-91c998158363.png)
参考上图,完成定价。如果看到图中的黄字 “Missing Metadata”,可能是因为你没有上传截图,就在每个价格详情页的最后,都完成后,就可以在应用信息页添加相应的价格列表了。
![image](https://user-images.githubusercontent.com/7159888/200403223-6c606a93-dc44-4a49-a89f-c3d17e0817f7.png)
步骤2:in-app purchase 代码接入
StoreManager.swift
import Foundation
import StoreKit
class StoreManager: NSObject, ObservableObject, SKProductsRequestDelegate, SKPaymentTransactionObserver {
@Published var myProducts = [SKProduct]()
@Published var transactionState: SKPaymentTransactionState?
var request: SKProductsRequest!
// 以下一些 ID 换成你上一个步骤建立的定价的 ID
private let _productIDs = [
"xxxx.xxxx.IAP.tipscoffee", // ¥12
"xxxx.xxxx.IAP.tipssnack", // ¥30
"xxxx.xxxx.IAP.tipsdinner" // ¥128
]
// As soon as we receive a response from App Store Connect, this function is called
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
print("Did receive response")
if !response.products.isEmpty {
for fetchedProduct in response.products {
DispatchQueue.main.async {
self.myProducts.append(fetchedProduct)
}
}
}
for invalidIdentifier in response.invalidProductIdentifiers {
print("Invalid identifiers found: \(invalidIdentifier)")
}
}
func request(_ request: SKRequest, didFailWithError error: Error) {
print("Request did fail: \(error)")
}
func getProducts() {
getProducts(productIDs: _productIDs)
}
func getProducts(productIDs: [String]) {
print("Start requesting products ...")
let request = SKProductsRequest(productIdentifiers: Set(productIDs))
request.delegate = self
request.start()
}
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
for transaction in transactions {
switch transaction.transactionState {
case .purchasing:
transactionState = .purchasing
case .purchased:
UserDefaults.standard.setValue(true, forKey: transaction.payment.productIdentifier)
queue.finishTransaction(transaction)
transactionState = .purchased
case .restored:
UserDefaults.standard.setValue(true, forKey: transaction.payment.productIdentifier)
queue.finishTransaction(transaction)
transactionState = .restored
case .failed, .deferred:
print("Payment Queue Error: \(String(describing: transaction.error))")
queue.finishTransaction(transaction)
transactionState = .failed
default:
queue.finishTransaction(transaction)
}
}
}
// 购买
func purchaseProduct(product: SKProduct) {
if SKPaymentQueue.canMakePayments() {
let payment = SKPayment(product: product)
SKPaymentQueue.default().add(payment)
} else {
print("User can't make payment.")
}
}
// if the user reinstalls the app but has already made an In-App Purchase
func restoreProducts() {
print("Restoring products ...")
SKPaymentQueue.default().restoreCompletedTransactions()
}
}
extension SKProduct {
var localizedPrice: String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.locale = priceLocale
return formatter.string(from: price)!
}
}
xxxxApp.swift
import SwiftUI
import StoreKit // 引入StoreKit
@main
struct xxxxApp: App {
@StateObject var storeManager = StoreManager() // App 最外层完成 StoreManager 对象的实例化
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(storeManager) // 把 StoreManager 对象放到环境变量(后面想用直接到环境变量即可引用)
// App 初始化时完成相关的调用
.onAppear(perform: {
SKPaymentQueue.default().add(storeManager)
storeManager.getProducts()
})
}
}
}
付费页面:PageDonation.swift
import SwiftUI
struct PageDonation: View {
@EnvironmentObject var storeManager : StoreManager
@Environment(\.presentationMode) var presentationMode // 用于关闭页面
private var description: String =
"""
If you have found my app useful, please consider supporting me by making a donation.
This App is written and supported by just one developer. This includes adding extra functionality, maintaining the website, sales, marketing and last, but not least, providing comprehensive and timely support to users.
Your donation will help fund future upgrades of my app and is greatly appreciated 🙏
"""
private let colors: [Color] = [.orange, .green, .purple, .blue]
var body: some View {
VStack {
HStack {
Spacer()
Button(action: {
presentationMode.wrappedValue.dismiss()
}) {
Text("Close")
.foregroundColor(.red)
.font(.subheadline)
.padding([.top, .bottom], 5)
.padding([.leading, .trailing], 10)
.background(Color.white.opacity(0.7))
.cornerRadius(40)
}
}.padding()
Group {
Text("Support Roulette EC")
.font(.title2)
.bold()
Text(description)
.font(.body)
.fontWeight(.medium)
.padding()
}
.foregroundColor(.white)
ForEach(0..<storeManager.myProducts.count, id: \.self) { index in
let product = storeManager.myProducts[index]
Button(action: {
storeManager.purchaseProduct(product: product)
}) {
HStack {
Text("\(product.localizedTitle)")
.bold()
Spacer()
Text("\(product.localizedPrice)")
.bold()
}
.foregroundColor(.white)
.font(.title3)
.padding([.top, .bottom], 12)
.padding([.leading, .trailing], 30)
.background(colors[index])
.cornerRadius(80)
}
.padding([.top, .leading, .trailing])
}
Spacer()
}
.background(
LinearGradient(gradient: Gradient(colors: [.pink, .white]), startPoint: .top, endPoint: .bottom)
)
.ignoresSafeArea()
}
}
struct PageDonation_Previews: PreviewProvider {
static var previews: some View {
PageDonation()
.environmentObject(StoreManager())
}
}
以上代码已经完成整套付所有的流程了,然后自己在需要的地方搞个按钮,打开 PageDonation()
页面即可调起付费页面。
注意:模拟器里,iOS 15+ 才可以获取到真实的商品列表。
单例模式
如果觉得上面使用的环境变量的方式传递不方便,也可以自行把 StoreManager
类改成单例模式,这里就不详细展开了。
参考:
class StoreManager {
static let shared = StoreManager()
private override init() {
super.init()
}
....
}
这样就可以随时随地使用 StoreManager.shared
来调用 StoreManager 的实例了。
步骤3:测试
从 iOS 14 开始,我们可以在模拟器进行付费的模拟了,只需要新建一个 Configuration.storekit
文件即可。
具体参考:https://www.revenuecat.com/docs/apple-app-store,或者自己 Google "swiftui sandbox test in-app purchase"。
StoreKit2 方式接入
上面说到的是使用 StoreKit 1 的方式接入,存在一些问题,在没有后端的情况下执行应用内购买逻辑既困难又不安全,但 Apple 对 StoreKit 2 进行了一些重大改进,使之成为可能且安全。
下面针对 StoreKit 2 接入做了一些例子,开箱即用:
代码例子
StoreManager.swift
import Foundation
import StoreKit
// 确保所有被标记的方法都在主线程上调用
@MainActor
class StoreManager: ObservableObject {
@Published var products = [Product]() // 当前在售商品列表
private var productsLoaded = false
// 已购买商品列表
@Published private(set) var purchasedProductIDs = Set<String>()
// VIP:订阅者
var isVip: Bool {
return !self.purchasedProductIDs.isDisjoint(with: self._subsIDs)
}
// 贡献者:打赏过的
var isContributor: Bool {
return !self.purchasedProductIDs.isDisjoint(with: self._tipsIDs)
}
// 打赏
private let _tipsIDs = [
"xxx.IAP.tipscoffee", // ¥12
"xxx.IAP.tipssnack", // ¥30
"xxx.IAP.tipsdinner", // ¥128
]
// 订阅
private let _subsIDs = [
"xxx_vip_monthly",
"xxx_vip_quarterly",
"xxx_vip_yearly"
]
// 打赏列表
var tipsList: [Product] {
products.filter{_tipsIDs.contains($0.id)}.sorted {
$0.price < $1.price
}
}
// 订阅列表
var subsList: [Product] {
products.filter{_subsIDs.contains($0.id)}.sorted {
$0.price < $1.price
}
}
// 监听应用程序外部创建的新交易
private var updates: Task<Void, Never>? = nil
private func observeTransactionUpdates() -> Task<Void, Never> {
Task(priority: .background) { [unowned self] in
for await verificationResult in Transaction.updates {
await self.updatePurchasedProducts()
}
}
}
init() {
updates = observeTransactionUpdates()
}
deinit {
updates?.cancel()
}
// 加载在售商品列表
func loadProducts() async throws {
guard !self.productsLoaded else { return }
self.products = try await Product.products(for: _tipsIDs + _subsIDs)
self.productsLoaded = true
}
// 购买
func purchase(_ product: Product) async throws {
let result = try await product.purchase()
switch result {
case let .success(.verified(transaction)):
// Successful purhcase
await transaction.finish()
await self.updatePurchasedProducts()
case let .success(.unverified(_, error)):
// Successful purchase but transaction/receipt can't be verified
// Could be a jailbroken phone
print(error)
break
case .pending:
// Transaction waiting on SCA (Strong Customer Authentication) or
// approval from Ask to Buy
break
case .userCancelled:
print("user cancelled")
break
@unknown default:
break
}
}
// 更新已购买商品列表(需要在应用程序启动时、购买后以及交易更新时调用)
func updatePurchasedProducts() async {
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else {
continue
}
if transaction.revocationDate == nil {
self.purchasedProductIDs.insert(transaction.productID)
} else {
self.purchasedProductIDs.remove(transaction.productID)
}
}
}
// 在极少数情况下,当用户怀疑应用程序未显示所有交易时,会调用AppStore.sync(),这会强制应用程序从 App Store 获取交易信息和订阅状态
func restorePurchase() {
print("Restoring products ...")
Task {
do {
try await AppStore.sync()
} catch {
print(error)
}
}
}
}
xxxxApp.swift
import SwiftUI
import StoreKit // 引入StoreKit
@main
struct xxxxApp: App {
@StateObject var storeManager = StoreManager() // App 最外层完成 StoreManager 对象的实例化
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(storeManager) // 把 StoreManager 对象放到环境变量(后面想用直接到环境变量即可引用)
// App 初始化时完成相关的调用
.task{
Task {
do {
// 更新当前已购买的商品列表
await storeManager.updatePurchasedProducts()
// 加载一次所有商品列表
try await storeManager.loadProducts()
} catch {
print(error)
}
}
}
}
}
}
PageSubscription.swift
import SwiftUI
import StoreKit
struct PageSubscription: View {
@EnvironmentObject var storeManager : StoreManager
// 订阅列表
private var subsList: [Product] {
storeManager.subsList
}
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
Image(uiImage: UIImage(named: "AppIcon") ?? UIImage())
.resizable()
.scaledToFit()
.frame(width: 68, height: 68)
.padding(.top, 30)
.padding(.bottom)
if (storeManager.isVip || true) {
Text("🎊 🎉🎉🎉 🎊")
.font(.title2)
.padding(.bottom)
Text("[Thanks for premium user]")
.font(.title3)
.fontWeight(.medium)
.padding([.leading, .trailing])
} else {
VStack {
Text("[Slogan]")
.font(.title3)
.fontWeight(.medium)
.padding([.leading, .trailing])
Text("[Premium User Desc]")
.font(.subheadline)
.padding()
}
.padding(.bottom)
ForEach(0..<subsList.count, id: \.self) { index in
let product = subsList[index]
Button(action: {
Task {
do {
try await storeManager.purchase(product)
} catch {
print(error)
}
}
}) {
ListItem(
title: product.displayName,
price: product.displayPrice,
desc: product.description
)
}
.padding([.bottom, .leading, .trailing])
}
}
Group {
Text("Restore Purchases")
.font(.subheadline)
.underline()
.padding()
.onTapGesture {
storeManager.restorePurchase()
}
HStack(spacing: 4) {
Text("Terms of use")
.font(.subheadline)
.underline()
.onTapGesture {
if let url = URL(string: "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/") {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
}
Text("and")
.font(.subheadline)
Text("Privacy policy")
.font(.subheadline)
.underline()
.onTapGesture {
if let url = URL(string: "https://www.mlaohu.com/privacy/roulette_ec/") {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
}
}
.padding(.bottom)
}
Spacer()
}
.navigationBarTitle("Premium User", displayMode: .inline)
.edgesIgnoringSafeArea(.bottom)
}
private struct ListItem: View {
var title: String = ""
var price: String = ""
var desc: String = ""
var body: some View {
VStack {
HStack {
Text("\(NSLocalizedString(title, comment: ""))")
.bold()
Spacer()
Text("\(price)")
.bold()
}
.foregroundColor(.white)
.font(.body)
.padding([.top, .bottom], 12)
.padding([.leading, .trailing])
.background(Color.red)
.cornerRadius(80)
// .overlay(
// RoundedRectangle(cornerRadius: 80)
// .stroke(.blue, lineWidth: 3)
// )
if (desc != "") {
Text("\(desc)")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
}
#Preview {
PageSubscription()
.environmentObject(StoreManager())
}