blog icon indicating copy to clipboard operation
blog copied to clipboard

SwiftUI付费方式接入

Open diamont1001 opened this issue 1 year ago • 0 comments

苹果应用如果跟付费相关的话,现在是限定要使用 in-app purchase 方式的,也就是应用内购买的方式,还好 SwiftUI 在这块的支持也是挺好的,接入挺方便,下面就介绍下接入的细节。

之前有个 App 尝试过一个【Buy me a coffee】的打赏按扭直接跳 Paypal 的支付链接,第一个版本通过审核了,后面再更新的时候就被拒绝,理由是付费相关需要使用 “in-app purchase” 接入方式。

image

苹果付费类型

  • 消耗品:Consumable
  • 非消耗品:Non-Consumable
  • 自动续期的订阅:Auto-Renewable Subscriptions
  • 不自动续期的订阅:Non-Renewing Subscriptions

接入前:银行和税务相关的协议

如果你的应用涉及到付费,必需要完成税务相关的一些条例的声明。

访问 appstoreconnect,在 “Agreements, Tax, and Banking” 完成相关的协议表格。

免费的比较简单,其中付费的那块需要填一些银行、税务相关的信息,一步步填下去就好,也不难。

image

代码接入

参考链接:https://blckbirds.com/post/how-to-use-in-app-purchases-in-swiftui-apps/

步骤1:App Store Connect 增加付费详细内容和价格

image

参考上图,完成定价。如果看到图中的黄字 “Missing Metadata”,可能是因为你没有上传截图,就在每个价格详情页的最后,都完成后,就可以在应用信息页添加相应的价格列表了。

image

步骤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 接入做了一些例子,开箱即用:

参考:https://www.revenuecat.com/blog/engineering/ios-in-app-subscription-tutorial-with-storekit-2-and-swift/

代码例子

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())
}

diamont1001 avatar Nov 07 '22 18:11 diamont1001