本記事内で使っているワークアラウンドコードの解決策はこちらの記事に書いてあります
概要
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
だけで試していく)
実際のコード
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つがあるため、それぞれをハンドリングする。
また、success
の VerificationResult
も、
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 を取得する必要がある(次のセクション)
購入情報を更新する
以下のような状況があるので、アプリ起動時に購入情報を更新する必要がある。
- 終了していない 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を分けました)
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())
}
}
}
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
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 (ごちゃごちゃしたので二段にしました)
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 の商品を追加してみる。
Consumable はアプリ側で購入情報を保持しなければいけなかったが、non-consumable や subscription は 最新の購入情報の取得 で紹介した Transaction.currentEntitlements
で購入しているかが分かる。
なので、起動時に購入情報を取得する必要がある。
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
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)
}
}
}
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
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 時の確認だけしていました)
今後確認したいことは以下です。
- Promotional Offer での購入:promotionalOffer(offerID:keyID:nonce:signature:timestamp:)
- Parental Control の機能をテストする:simulatesAskToBuyInSandbox(_:)
- 払い戻し画面の表示:beginRefundRequest(in:)
- 現在購入中のサブスクリプションを確認するシートを出す:showManageSubscriptions(in:)
- ユーザーが購入できるかどうかを示す:canMakePayments
- 復元処理。自動で同期されるように実装すべきなので、普段は使わないが、何かあった時にユーザーが更新できるように用意しておくと良い:sync()