LoginSignup
11
8

More than 1 year has passed since last update.

StoreKit 2 でのアプリ内課金の基本

Last updated at Posted at 2022-09-29

本記事内で使っているワークアラウンドコードの解決策はこちらの記事に書いてあります

概要

StoreKit 2 でのアプリ内課金の基本をローカルで動くコードと共にまとめます。
勉強した順にまとめているので分かりにくいかもしれません。

準備

今回は Xcode でのテストをします。Xcode と Sandbox のテストでできることの比較はこちら

途中で戸惑ったのですが、Xcode でのテストでは、再インストール時の自動復元の確認などができないようです:https://stackoverflow.com/questions/69768519/appstore-sync-not-restoring-purchases

  • 適当なサンプルアプリを作る
  • Setting Up StoreKit Testing in Xcode に従って StoreKit Configuration file を作成する
  • StoreKit Configuration file を開いたまま Storefront を日本にする

アプリ内課金の基本

商品を取得する

import StoreKit

var productList: [Product]
productList = try await Product.products(for: ["consumable.apple"])

StoreKit Configuration file に定義した Product id を指定して、Product を取得できる。
返り値は [Product]Product のドキュメント
(簡単のため consumable だけで試していく)

実際のコード

StoreKit Configuration File
Screen Shot 2022-07-30 at 12.38.43.png

Store という class で StoreKit とやりとりしています

import Foundation
import StoreKit

class Store: ObservableObject {

    @Published var productList: [Product]

    init() {
        productList = []

        Task {
            await fetchProducts()
        }
    }

    @MainActor
    func fetchProducts() async {
        do {
            productList = try await Product.products(for: ["consumable.apple"])
        } catch {
            print("error")
        }
    }
}

UI は最低限のものを用意するとこんな感じ

import SwiftUI

struct ContentView: View {

    @StateObject var store: Store = Store()

    var body: some View {
        List {
            Section("食品") {
                ForEach(store.productList) { product in
                    HStack {
                        Text("\(product.displayName)")

                        Spacer()

                        Button {
                            // todo
                        } label: {
                            Text("購入")
                        }.buttonStyle(BorderlessButtonStyle())
                    }
                }
            }
            .listStyle(GroupedListStyle())
        }
    }
}

商品の情報を表示する

  • 商品名 product.displayName
  • 商品の説明 product.description
  • 商品の値段(表示用) product.displayPrice (String)
  • 商品の値段(計算用) product.price (Decimal)
実際のコード

UI

struct ContentView: View {

    @StateObject var store: Store = Store()

    var body: some View {
        List {
            Section("食品") {
                ForEach(store.productList) { product in
                    HStack(spacing: 2) {
                        Text(product.displayName)

                        Spacer()

                        Text(product.description)
                            .font(.caption)

                        Spacer()

                        VStack {
                            Button {
                                // todo
                            } label: {
                                Text(product.displayPrice)
                            }.buttonStyle(BorderlessButtonStyle())

                            Text("2つ買うと¥\(String(describing: product.price * 2))")
                                .font(.caption2)
                        }
                    }
                }
            }
            .listStyle(GroupedListStyle())
        }
    }
}

商品の購入

購入自体は product.purchase でできる。結果は

  • case success(VerificationResult)
  • case pending
  • case userCancelled

の3つがあるため、それぞれをハンドリングする。
また、successVerificationResult も、

  • case verified
  • case unverified

があるためそれぞれハンドリングする。
unverified の時は自分で verify する方法もあると Transaction に書いてある。

購入の関数は例えば以下のようになる:

func purchase(_ product: Product) async throws {
    let result = try await product.purchase()

    switch result {
    case let .success(verificationResult):
        switch verificationResult {
        case let .verified(transaction):
            // 購入情報を更新する
            switch transaction.productType {
            case .consumable:
                // 例えば UserDefaults に購入数をいれる
                let amount = UserDefaults.standard.integer(forKey: transaction.productID)
                UserDefaults.standard.set(amount + 1, forKey: transaction.productID)
            default:
                break
            }

            // transaction を終了させる必要がある
            await transaction.finish()

        case let .unverified(_, verificationError):
            // StoreKit の validation が通らなかった場合
            // 自分で validate することができる
            throw verificationError
        }
    case .pending:
        // ユーザーのアクションが必要
        // transaction が終わったら Transaction.updates から取れる
        break
    case .userCancelled:
        // ユーザーがキャンセルした
        break
    @unknown default:
        break
    }
}

Consumable 以外はレシートから購入情報を取得できるが、consumable だけは購入情報をアプリ側で保持しなければいけない。そのため、UserDefaults に格納している。

この関数を使うだけでは、以下のような warning が出るため、アプリ起動時に Transaction を取得する必要がある(次のセクション)
Screen Shot 2022-07-31 at 16.46.28.png

購入情報を更新する

以下のような状況があるので、アプリ起動時に購入情報を更新する必要がある。

  • 終了していない transaction があった時
  • アプリ起動中に transaction の更新があった時
    • 他のデバイスで購入した時など

これは Transaction.updates で取得でき、そのための Task を用意してアプリ起動時に実行するようにする。

class Store: ObservableObject {

    private var updates: Task<Void, Never>? = nil

    init() {
        updates = newTransactionListenerTask()
    }

    deinit {
        updates?.cancel()
    }

    private func newTransactionListenerTask() -> Task<Void, Never> {
        Task(priority: .background) {
            for await verificationResult in Transaction.updates {
                guard
                    case .verified(let transaction) = verificationResult,
                    transaction.revocationDate == nil // 払い戻されたものは無視する
                else { return }

                // 購入情報の更新
                await refreshPurchasedProducts()

                await transaction.finish()
            }
        }
    }

refreshPurchasedProducts() については次のセクションで。

最新の購入情報の取得

Transaction.currentEntitlements で最新の購入情報を取得できる。
取得できる情報は以下:

  • non-consumable それぞれの transaction
  • アクティブな auto-renewable subscription の最新の transaction
  • non-renewing subscription の最新の transaction
  • finish() を呼んでいない consumable の transaction 全て

購入後やアプリ起動後に購入情報を更新する必要があるが、その関数はこのような感じになる:

func refreshPurchasedProducts() async {
    for await verificationResult in Transaction.currentEntitlements {

        // 取り消しや払い戻された transaction は currentEntitlements には現れないので
        // transaction.revocationDate はチェックしなくて良い
        guard case .verified(let transaction) = verificationResult else { return }

            switch transaction.productType {
            case .consumable:
                // transaction が終了していなかった consumable が来る
                let amount = UserDefaults.standard.integer(forKey: transaction.productID)
                UserDefaults.standard.set(amount + 1, forKey: transaction.productID)
            default:
                break
        }
    }
}

↓このワークアラウンドの解決には Consumable Product の Ask to Buy 対応 を見てください
これで consumable の購入個数を更新できるかと思ったが、transaction が finish() していない consumable があっても currentEntitlements で取得できなかった。
この記事 でも同様のことが述べられていた:In tests I've done transactions for consumables do not remain in the receipt, even if you omit to call finish().

なので、購入時に consumable の数を更新することにした(Apple の出しているサンプルアプリでも同様にしていた)

    func purchase(_ product: Product) async throws {
        let result = try await product.purchase()

        switch result {
        case let .success(verificationResult):
            switch verificationResult {
            case let .verified(transaction):
                // 購入情報を更新する
                await refreshPurchasedProducts()

                // consumable については refreshPurchasedProducts() で更新できなかったのでここで更新
                // currentEntitlements の説明では更新できそうに思えたができなかった
                if case .consumable = transaction.productType {
                    let amount = UserDefaults.standard.integer(forKey: transaction.productID)
                    UserDefaults.standard.set(amount + 1, forKey: transaction.productID)
                }

                // transaction を終了させる必要がある
                await transaction.finish()

        中略
        }
    }
現時点でのコード

UI (Cellを分けました)

ContentView.swift
import SwiftUI

struct ContentView: View {

    @StateObject var store: Store = Store()

    var body: some View {
        List {
            Section("食品") {
                ForEach(store.productList) { product in
                    ConsumableCellView(product: product)
                        .environmentObject(store)
                }
            }
            .listStyle(GroupedListStyle())
        }
    }
}
ConsumableCellView.swift

import SwiftUI
import StoreKit

struct ConsumableCellView: View {
    @EnvironmentObject var store: Store
    let product: Product
    @State private var showAlert = false
    @State private var errorMessage = ""
    @AppStorage var amount: Int

    init(product: Product) {
        self.product = product
        self._amount = AppStorage(wrappedValue: 0, product.id)
    }

    var body: some View {
        HStack(spacing: 2) {
            Text(product.displayName)

            Spacer()

            Text(product.description)
                .font(.caption)

            Spacer()

            VStack {
                Button {
                    Task {
                        await purchase()
                    }
                } label: {
                    Text(product.displayPrice)
                }.buttonStyle(BorderlessButtonStyle())

                Text("2つ買うと¥\(String(describing: product.price * 2))")
                    .font(.caption2)
            }

            Spacer()

            Text("所持数:\(amount)")
                .font(.caption2)
        }
        .alert(errorMessage, isPresented: $showAlert) {
            Button("OK", role: .cancel) { }
        }
    }

    func purchase() async {
        do {
            try await store.purchase(product)
        } catch {
            showAlert = true
            errorMessage = "購入に失敗しました"
        }
    }
}

Store

Store.swift
import Foundation
import StoreKit

class Store: ObservableObject {

    @Published var productList: [Product]

    private var updates: Task<Void, Never>? = nil

    init() {
        productList = []

        Task {
            await fetchProducts()
        }

        updates = newTransactionListenerTask()
    }

    deinit {
        updates?.cancel()
    }

    private func newTransactionListenerTask() -> Task<Void, Never> {
        Task(priority: .background) {
            for await verificationResult in Transaction.updates {
                guard
                    case .verified(let transaction) = verificationResult,
                    transaction.revocationDate == nil
                else { return }

                // 購入情報の更新
                await refreshPurchasedProducts()

                await transaction.finish()
            }
        }
    }

    @MainActor
    func fetchProducts() async {
        do {
            productList = try await Product.products(for: ["consumable.apple"])
        } catch {
            print("error")
        }
    }

    func purchase(_ product: Product) async throws {
        let result = try await product.purchase()

        switch result {
        case let .success(verificationResult):
            switch verificationResult {
            case let .verified(transaction):
                // 購入情報を更新する
                await refreshPurchasedProducts()

                // consumable については refreshPurchasedProducts() で更新できなかったのでここで更新
                // currentEntitlements の説明では更新できそうに思えたができなかった
                if case .consumable = transaction.productType {
                    let amount = UserDefaults.standard.integer(forKey: transaction.productID)
                    UserDefaults.standard.set(amount + 1, forKey: transaction.productID)
                }

                // transaction を終了させる必要がある
                await transaction.finish()

            case let .unverified(_, verificationError):
                // StoreKit の validation が通らなかった場合
                // 自分で validate することができる
                throw verificationError
            }
        case .pending:
            // ユーザーのアクションが必要
            // transaction が終わったら Transaction.updates から取れる
            break
        case .userCancelled:
            // ユーザーがキャンセルした
            break
        @unknown default:
            break
        }
    }

    func refreshPurchasedProducts() async {
        for await verificationResult in Transaction.currentEntitlements {

            // 取り消しや払い戻された transaction は currentEntitlements には現れないので
            // transaction.revocationDate はチェックしなくて良い
            guard case .verified(let transaction) = verificationResult else { return }

            switch transaction.productType {
            case .consumable:
                // transaction が終了していなかった consumable が来る
                let amount = UserDefaults.standard.integer(forKey: transaction.productID)
                UserDefaults.standard.set(amount + 1, forKey: transaction.productID)
            default:
                break
            }
        }
    }
}

複数個購入する(consumable, non-renewing subscription)

商品を購入する際、Product.purchase(options:) の引数に Set<Product.PurchaseOption> を渡すことで、購入時のオプションを指定できる。
購入個数を指定するには以下のように書く:

let options: Set<Product.PurchaseOption> = [.quantity(selectedCount)] // selectedCount は View からの情報
let result = try await product.purchase(options: options)

個数指定は consumable か non-renewing subscription のみ行える。

Transaction からの購入個数の取得は Transaction.purchasedQuantity で行える。

現時点でのコード

Store

    func purchase(_ product: Product, options: Set<Product.PurchaseOption> = []) async throws {
        let result = try await product.purchase(options: options)
        
        switch result {
        case let .success(verificationResult):
            switch verificationResult {
            case let .verified(transaction):
                // 購入情報を更新する
                await refreshPurchasedProducts()

                // consumable については refreshPurchasedProducts() で更新できなかったのでここで更新
                // currentEntitlements の説明では更新できそうに思えたができなかった
                if case .consumable = transaction.productType {
                    let amount = UserDefaults.standard.integer(forKey: transaction.productID)
                    UserDefaults.standard.set(amount + transaction.purchasedQuantity, forKey: transaction.productID)
                }

                // 省略...
            }
        }
    }

UI (ごちゃごちゃしたので二段にしました)

ConsumableCellView
import SwiftUI
import StoreKit

struct ConsumableCellView: View {
    @EnvironmentObject var store: Store
    let product: Product
    @State private var showAlert = false
    @State private var errorMessage = ""
    @State private var selectedCount = 1
    @State private var isShowingAmountPicker = false
    @AppStorage var amount: Int

    init(product: Product) {
        self.product = product
        self._amount = AppStorage(wrappedValue: 0, product.id)
    }

    var body: some View {
        VStack(spacing: 10) {
            HStack(spacing: 2) {
                Text(product.displayName)

                Spacer()

                Text(product.description)
                    .font(.caption)

                Spacer()

                Text("所持数:\(amount)")
                    .font(.caption2)

            }

            HStack {

                Button {
                    isShowingAmountPicker.toggle()
                } label: {
                    Text("個数:\(selectedCount)")
                }
                Text("¥\(String(describing: product.price * Decimal(selectedCount)))")

                Button {
                    Task {
                        await purchase()
                    }
                } label: {
                    Text("購入")
                }.buttonStyle(BorderlessButtonStyle())
            }
        }
        .alert(errorMessage, isPresented: $showAlert) {
            Button("OK", role: .cancel) { }
        }
        .sheet(isPresented: $isShowingAmountPicker) {
            AmountPicker(selectedCount: $selectedCount, isShowingPicker: $isShowingAmountPicker)

        }
    }

    func purchase() async {

        let options: Set<Product.PurchaseOption> = [.quantity(selectedCount)]

        do {
            try await store.purchase(product, options: options)
        } catch {
            showAlert = true
            errorMessage = "購入に失敗しました"
        }
    }
}

struct AmountPicker: View {
    @Binding var selectedCount: Int
    @Binding var isShowingPicker: Bool

    var body: some View {
        Picker(selection: $selectedCount, label: Text("個数")) {
            ForEach(1 ..< 100) { num in
                Text("\(num)").tag(num)
            }
        }
        .pickerStyle(.wheel)
    }
}

Non-consumable の追加

Non-consumable の商品を追加してみる。

Screen Shot 2022-08-14 at 9.19.14.png

Consumable はアプリ側で購入情報を保持しなければいけなかったが、non-consumable や subscription は 最新の購入情報の取得 で紹介した Transaction.currentEntitlements で購入しているかが分かる。
なので、起動時に購入情報を取得する必要がある。

Store.swift
class Store: ObservableObject {

    @Published var consumableProductList: [Product] = []
    @Published var nonConsumableProductList: [Product] = []
    @Published var purchasedNonConsumableProductList: [Product] = []
    // 中略

    init() {
        Task {
            await fetchProducts()
            await refreshPurchasedProducts() // 購入情報の更新
        }
        // 中略
    }
    
    // 中略

    @MainActor
    func fetchProducts() async {
        do {
            consumableProductList = try await Product.products(for: ["consumable.apple"])
            nonConsumableProductList = try await Product.products(for: ["non.consumable.knife"])
        } catch {
            print("error")
        }
    }

    // 中略

    @MainActor
    func refreshPurchasedProducts() async {
        var purchasedNonConsumables: [Product] = []

        for await verificationResult in Transaction.currentEntitlements {
            guard case .verified(let transaction) = verificationResult else { return }

            switch transaction.productType {
            case .consumable:
                // 中略
            case .nonConsumable:
                guard let product = nonConsumableProductList.first(where: { $0.id == transaction.productID }) else { return }
                purchasedNonConsumables.append(product)
            default:
                break
            }
        }

        purchasedNonConsumableProductList = purchasedNonConsumables
    }
}

購入の手順は consumable と同じ。

現時点でのコード

View

ContentView.swift
import SwiftUI

struct ContentView: View {

    @StateObject var store: Store = Store()

    var body: some View {
        if store.consumableProductList.isEmpty == false && store.nonConsumableProductList.isEmpty == false {

            List {
                Section("食品") {
                    ForEach(store.consumableProductList) { product in
                        ConsumableCellView(product: product)
                    }
                }
                .listStyle(GroupedListStyle())

                Section("食器") {
                    ForEach(store.nonConsumableProductList) { product in
                        NonConsumableCellView(product: product)
                    }
                }
                .listStyle(GroupedListStyle())
            }
            .environmentObject(store)
        }
    }
}
NonConsumableView.swift
import SwiftUI
import StoreKit

struct NonConsumableCellView: View {
    @EnvironmentObject var store: Store
    let product: Product
    @State private var showAlert = false
    @State private var errorMessage = ""
    @State private var isPurchased = false

    var body: some View {
        VStack(spacing: 10) {
            HStack(spacing: 2) {
                Text(product.displayName)

                Spacer()

                Text(product.description)
                    .font(.caption)

                Spacer()

                Text(isPurchased ? "購入済み" : "未購入")
                    .font(.caption)
            }

            HStack {
                Text("¥\(String(describing: product.price))")

                Button {
                    Task {
                        await purchase()
                    }
                } label: {
                    Text("購入")
                }
                .buttonStyle(BorderlessButtonStyle())
                .disabled(isPurchased)
            }
        }
        .alert(errorMessage, isPresented: $showAlert) {
            Button("OK", role: .cancel) { }
        }
        .onChange(of: store.purchasedNonConsumableProductList) { _ in
            Task {
                isPurchased = (try? await store.isPurchased(product)) ?? false
            }
        }
    }

    func purchase() async {

        do {
            try await store.purchase(product)
            isPurchased = try await store.isPurchased(product)
        } catch {
            showAlert = true
            errorMessage = "購入に失敗しました"
        }
    }
}

Store

Store.swift
import Foundation
import StoreKit

class Store: ObservableObject {

    @Published var consumableProductList: [Product] = []

    @Published var nonConsumableProductList: [Product] = []

    @Published var purchasedNonConsumableProductList: [Product] = []

    private var updates: Task<Void, Never>? = nil

    init() {
        Task {
            await fetchProducts()

            await refreshPurchasedProducts() // 購入情報の更新
        }

        updates = newTransactionListenerTask()
    }

    deinit {
        updates?.cancel()
    }

    private func newTransactionListenerTask() -> Task<Void, Never> {
        Task(priority: .background) {
            for await verificationResult in Transaction.updates {
                guard
                    case .verified(let transaction) = verificationResult,
                    transaction.revocationDate == nil
                else { return }

                // 購入情報の更新
                await refreshPurchasedProducts()

                await transaction.finish()
            }
        }
    }

    @MainActor
    func fetchProducts() async {
        do {
            consumableProductList = try await Product.products(for: ["consumable.apple"])
            nonConsumableProductList = try await Product.products(for: ["non.consumable.knife"])
        } catch {
            print("error")
        }
    }

    func purchase(_ product: Product, options: Set<Product.PurchaseOption> = []) async throws {
        let result = try await product.purchase(options: options)

        switch result {
        case let .success(verificationResult):
            switch verificationResult {
            case let .verified(transaction):
                // 購入情報を更新する
                await refreshPurchasedProducts()

                // consumable については refreshPurchasedProducts() で更新できなかったのでここで更新
                // currentEntitlements の説明では更新できそうに思えたができなかった
                if case .consumable = transaction.productType {
                    let amount = UserDefaults.standard.integer(forKey: transaction.productID)
                    UserDefaults.standard.set(amount + transaction.purchasedQuantity, forKey: transaction.productID)
                }

                // transaction を終了させる必要がある
                await transaction.finish()

            case let .unverified(_, verificationError):
                // StoreKit の validation が通らなかった場合
                // 自分で validate することができる
                throw verificationError
            }
        case .pending:
            // ユーザーのアクションが必要
            // transaction が終わったら Transaction.updates から取れる
            break
        case .userCancelled:
            // ユーザーがキャンセルした
            break
        @unknown default:
            break
        }
    }

    @MainActor
    func refreshPurchasedProducts() async {
        var purchasedNonConsumables: [Product] = []

        for await verificationResult in Transaction.currentEntitlements {

            // 取り消しや払い戻された transaction は currentEntitlements には現れないので
            // transaction.revocationDate はチェックしなくて良い
            guard case .verified(let transaction) = verificationResult else { return }

            switch transaction.productType {
            case .consumable:
                // transaction が終了していなかった consumable が来る
                let amount = UserDefaults.standard.integer(forKey: transaction.productID)
                UserDefaults.standard.set(amount + transaction.purchasedQuantity, forKey: transaction.productID)
            case .nonConsumable:
                guard let product = nonConsumableProductList.first(where: { $0.id == transaction.productID }) else { return }
                purchasedNonConsumables.append(product)
            default:
                break
            }
        }

        purchasedNonConsumableProductList = purchasedNonConsumables
    }

    func isPurchased(_ product: Product) async throws -> Bool {
        switch product.type {
        case .nonConsumable:
            return purchasedNonConsumableProductList.contains(product)
        default:
            return false
        }
    }
}

ファミリー共有できる商品かを取得する

ファミリー共有できる商品かは Product.isFamilyShareable で取得できる。
例えば、View で以下のような分岐として使える

if product.isFamilyShareable {
    Text("ファミリー共有")
        .font(.caption)
        .foregroundColor(.green)
}

終わりに

StoreKit 2 での課金の基本について調べました。
再インストール時に Transaction.currentEntitlements の処理が正常に動いているかは TestFlight にあげないと確認できなさそうだったので残念です(WWDCの動画でも rebuild 時の確認だけしていました)

今後確認したいことは以下です。

参考リンク(本文に載せていないもの)

11
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
8