Upgrade to Pro — share decks privately, control downloads, hide ads and more …

StoreKit2によるiOSのアプリ内課金のリニューアル

Kang Jianbin
August 24, 2024
590

 StoreKit2によるiOSのアプリ内課金のリニューアル

iOSDC2024で発表した資料になります。

#iosdc #iosdc2024
https://fortee.jp/iosdc-japan-2024/proposal/7c8384aa-ca3b-41ad-a738-e5aaaa7d32e0S

StoreKit2によるiOSのアプリ内課金リニューアル

【プロフィール】
康 建斌 | AbemaTV

GitHub : kangnux
X : @kangnuxlion

2022年サイバーエージェント中途入社してからABEMAのiOSエンジニアをしています。
現在はProduct Engineering Div.のiOSリーダーを担当している、課金関連の機能開発を主に行っています。
趣味は映画とアニメです。

Kang Jianbin

August 24, 2024
Tweet

Transcript

  1. AbemaTV, Inc. All Rights Reserved
 AbemaTV, Inc. All Rights Reserved


    1 株式会社 AbemaTV StoreKit 2によるiOSのア プリ内課金のリニューアル Aug 24th, 2024 AbemaTV 康 建斌
  2. AbemaTV, Inc. All Rights Reserved
 自己 紹介 2 康 建斌(コウ

    ケンヒン) a.k.a ジャンビン ❏ 株式会社サイバーエージェント(2022年中途) ❏ ABEMATV配属 ❏ Product Engineering Div.のiOSリーダー ❏ 主に課金システムの開発を担当する ❏ 中国(山西省)出身 ❏ 趣味:映画とアニメ ❏ X: @kangnuxlion
  3. AbemaTV, Inc. All Rights Reserved
 主な内容 3 ❏ ABEMA 紹介

    ❏ ABEMAの課金について ❏ 課金システムをリニューアルする背景 ❏ StoreKit 2について ❏ 課金システムのリニューアル ❏ オファー ❏ リニューアル後のリリース ❏ 新商品提供
  4. AbemaTV, Inc. All Rights Reserved
 ABEMA 紹介 5 テレビ ×

    ビデオのハイブリッド型 24 時間 365 日完全編成型リニア配信と 見逃しや限定コンテンツを 登録不要で好きなタイミングに視聴できるビデオ配信を 楽しむことができます。 100%プロコンテンツ サイバーエージェントとテレビ朝日 それぞれの強みを活かした制作体制で 高品質なコンテンツを配信しています。 多彩なラインナップ 国内唯一の 24 時間編成のニュース専門チャンネルをはじめ、 オリジナルのドラマや恋愛番組、アニメ、スポーツなど、 多彩なジャンルの約 20 チャンネルを 24 時間 365 日放送しています。
  5. AbemaTV, Inc. All Rights Reserved
 AbemaTV, Inc. All Rights Reserved


    10 課金システムをリニューアルする背景 ❏ ビジネス要件 ❏ iOS側の考慮 ❏ バックエンド側の考慮
  6. AbemaTV, Inc. All Rights Reserved
 11 課金システムをリニューアルする背景-ビジネス要件 ❏ ビジネス要件 ❏

    これまでの単一プランに加えて、今後は複数のプランを提供した い。 ❏ 提供するプラン多様化した上に、簡単に拡張できるシステムを構 築したい。 ❏ Appleで利用できるオファーの仕組みを導入したい。
  7. AbemaTV, Inc. All Rights Reserved
 課金システムをリニューアルする背景-iOS側の考慮 12 ❏ iOS側 ❏

    iOS 14のサポートが終了し、iOS 15からサポートされている StoreKit 2の導入が可能になる。 ❏ 課金のSLI(Service Level Indicator)を整備したい。 ❏ StoreKit Testingも導入したい。 ※SLI(Service Level Indicator):サービスの運用品質(パフォーマンス)を計測するのに使われる指標
  8. AbemaTV, Inc. All Rights Reserved
 課金システムをリニューアルする背景-バックエンド側の考慮 13 ❏ バックエンド側 ❏

    App Store Server NotificationをV2へアップデートとイベン トハンドリングの改修
  9. AbemaTV, Inc. All Rights Reserved
 AbemaTV, Inc. All Rights Reserved


    14 StoreKit 2について ❏ StoreKit 2とは? ❏ StoreKit 2のここが素晴らしい ❏ トランザクションの管理しやすさ①〜⑤ ❏ トランザクション管理の注意点①〜② ❏ トランザクション管理のAPI検証 ❏ クライアント完結のレシート検証 ❏ 新機能①〜②
  10. AbemaTV, Inc. All Rights Reserved
 StoreKit 2について 15 StoreKit 2とは?

    ❏ StoreKit 2は、Appleが提供するフレームワークの一つで、アプリ内課金やサブスク リプションの管理を行うためのツールです。StoreKit 2は、iOS 15、macOS Monterey、tvOS 15、およびwatchOS 8から導入されました。 ❏ 主な特徴 ❏ 商品情報や定期購入状態、トランザクション履歴を取得するための新しいAPI。 ❏ サーバーとの通信を確保するための新しい暗号署名検証機能。 ❏ ユーザーの購入体験を改善するための新しいUI。
  11. AbemaTV, Inc. All Rights Reserved
 StoreKit 2について 16 ❏ コード自体がOriginal

    API for In-App Purchase(a.k.a StoreKit 1)より圧倒的にシンプル! ❏ StoreKit Testingを活用して一部のテストケースに対してユニット テストを実装できる! ❏ 課金処理が理解しやすくなる! ※これから「StoreKit 1」と呼ばれる箇所は、Original API for In-App Purchaseを指し ます。 StoreKit 2でできること: StoreKit 1はiOS 18からdeprecatedに なることが発表されました!!
  12. AbemaTV, Inc. All Rights Reserved
 StoreKit 2について-トランザクションの管理しやすさ① 17 プロダクト情報の取得-Product.products(for: [productID])

    let appProducts = try await Product.products(for: [productID]) ❏ 説明: ❏ 1行で商品情報の取得が可能になる。 ❏ 取得したStoreKit.Productの中にはあらゆる情報が含まれている。 ❏ プロダクトの課金タイプ、購読状態、価格情報、名前など let request = SKProductsRequest(productIdentifiers: [productID]) request.start() return request.didReceiveResponse .flatMap { response -> Observable<SKProduct> in return response.findProduct(productID) } public var didReceiveResponse: Observable<SKProductsResponse> { return RxSKProductsRequestDelegateProxy.proxy(for: self) .productResponse }
  13. AbemaTV, Inc. All Rights Reserved
 StoreKit 2について-トランザクションの管理しやすさ② 18 プロダクトの購入-Product.purchase(options:) func

    purchase(_ product: Product) async throws -> Transaction? { let result = try await product.purchase() switch result { ... } } ❏ 説明: ❏ 1メソッド/1行で全ての処理を完結できる、StoreKit1の SKProductsRequestより圧倒的にシンプル。 ❏ Delegateなしの世界! let request = SKProductsRequest(productIdentifiers: [productID]) request.start() request.didReceiveResponse .flatMap { response -> Observable<SKProduct> in return response.findProduct(productID) } .flatMap { [weak self] product -> Observable<SKPaymentTransaction> in return Observable.create { [weak self] observer in let disposable = self?.newPurchaseTransactionResult .subscribe(onNext: { ... }) let payment = SKMutablePayment(product: product) self?.paymentQueue.add(payment) return Disposables.create { disposable.dispose() } } } .flatMap { [weak self] transaction -> Observable<SKStoreReceipt> in self?.loadReceipt() }
  14. AbemaTV, Inc. All Rights Reserved
 StoreKit 2について-トランザクションの管理しやすさ③ 19 トランザクションの監視-Transaction.updates for

    await result in Transaction.updates { do { let transaction = try self.checkVerified(result) // **権限付与などABEMA側の処理を行う** // トランザクションを完了する await transaction.finish() } catch { ... } } ❏ 説明: ❏ アプリ外部(App Store)とアプリ内部の購入が簡単に分けて処理できる。 ❏ アプリ内で購入した時はTransactionが流れない。 ❏ StoreKit1はユーザーが購読した際も問答無用でSKPaymentQueueにTransaction が流れていた、新規購入かどうかの判定が必要だった。 ❏ 注意: ❏ アプリ起動時できる限り早めにlistenする必要がある。 paymentQueue.updatedTransactions .flatMap { transactions -> Observable<SKPaymentTransaction> in Observable.from(transactions) } .flatMap { [weak self] transaction -> Observable<RetryingTransactionResult> in let transactionState = transaction.transactionState let productID = transaction.payment.productIdentifier let isRetrying = transaction.transactionState != .purchasing && !me.newPurchaseTransactions.value.contains(.init(transaction: transaction)) // アプリ内で新規購入かどうかの判定が必要 if isRetrying { ... } else { ... } }
  15. AbemaTV, Inc. All Rights Reserved
 StoreKit 2について-トランザクションの管理しやすさ④ 20 購読中トランザクションの取得-Transaction.currentEntitlements for

    await result in Transaction.currentEntitlements { do { let transaction = try checkVerified(result) // **権限付与などABEMA側の処理を行う** // トランザクションを完了する await transaction.finish() } catch { ... } } ❏ 説明: ❏ 購読中サブスクリプションのトランザクションが簡単に取得できる。 ❏ 用途例: ❏ AppleIDベースのリストアが実現できる。 Bundle.main .appStoreReceiptURL .flatMap { (url) -> Data? in try? Data(contentsOf: url) } .flatMap { $0.base64EncodedString(options: .init(rawValue: 0)) }
  16. AbemaTV, Inc. All Rights Reserved
 StoreKit 2について-トランザクションの管理しやすさ⑤ 21 finishしていないトランザクションの取得-Transaction.unfinished for

    await result in Transaction.unfinished { do { let transaction = try checkVerified(result) // **権限付与などABEMA側の処理を行う** // トランザクションを完了する await transaction.finish() } catch { ... } } ❏ 説明: ❏ finishしていないトランザクションが簡単に取得できる。 ❏ 用途例: ❏ App Storeの決済が完了したが、サーバー側の権限付与処理がまだ終わってい ないトランザクションを取得し、リトライなどを行う。 paymentQueue.transactions .filter { !newPurchaseTransactions.value.contains(.init(transaction: $0)) } \
  17. AbemaTV, Inc. All Rights Reserved
 StoreKit 2について-トランザクション管理の注意点① 22 iOS/tvOS15.4未満のバージョンにてTransaction.updatesの不具合 •

    Transaction.updates/Transaction.unfinished ❏ 現象:起動時にTransaction.updatesよりfinishしていないトランザクションが流れることが 書いてあるが! ❏ iOS15.4未満のバージョンは流れていない現象がある! ❏ Apple側にも該当現象を修復してリリースノートに書いてある ❏ https://developer.apple.com/documentation/tvos-release-notes/tvos-15_4- release-notes#StoreKit // If your app has unfinished transactions, the updates listener receives them once, immediately after the app launches. https://developer.apple.com/documentation/storekit/transaction/3851206-updates#discussion ❏ 対策:起動時にiOS15.4未満のバージョンに対してTransaction.updatesと Transaction.unfinished両方とも監視するように対策した。
  18. AbemaTV, Inc. All Rights Reserved
 StoreKit 2について-トランザクション管理の注意点② 23 Transaction.updatesの不具合 •

    Transaction.updates ❏ 現象:Transaction.updatesよりアプリ内で商品を購入した時はTransactionが流れな いと書いてあるが! ❏ AppStoreの購入結果画面が表示されて時間が経つとTransaction.updatesより流 れることがある! // This sequence receives transactions that occur outside of the app, such as Ask to Buy transactions, subscription offer code redemptions, and purchases that customers make in the App Store. It also emits transactions that customers complete in your app on another device. https://developer.apple.com/documentation/storekit/transaction/3851206-updates#discussion ❏ 対策:アプリ内で商品購入する途中でTransaction.updatesより流れた同じ商品IDのトラ ンザクションのハンドリング処理をスキップするように対策した。
  19. AbemaTV, Inc. All Rights Reserved
 StoreKit 2について-クライアント完結のレシート検証 25 アプリ側でレシート検証の結果が確認できる func

    checkVerified<T>(_ result: VerificationResult<T>) throws -> T { switch result { case .unverified: // レシート検証失敗 throw StoreError.failedVerification case .verified(let safe): // レシート検証成功 return safe } } ❏ レシートの検証ができるようになりましたが、ABEMAでは従来と同じく サーバー側でレシートを検証する方針で対応し、jwsRepresentationを サーバー側へ送信することで実現できる。
  20. AbemaTV, Inc. All Rights Reserved
 StoreKit 2について-新機能① 26 アプリ内にユーザーへの返金動線を用意できる try

    await Transaction.beginRefundRequest(for: id, in: scene) ❏ StoreKit 2からアプリ側に返金導線の用 意ができて、問い合わせとガイドライン など経由しなくても返金できるように なった。
  21. AbemaTV, Inc. All Rights Reserved
 StoreKit 2について-新機能② 27 appAccountTokenでユーザーを一意に特定できる。 let

    appAccountToken = <# Generate an app account token. #> let purchaseResult = try await product.purchase(options: [ .appAccountToken(appAccountToken) ]) ❏ appAccountToken(UUID型)を活用してどのユーザーでの購入がはっき り把握できる。
  22. AbemaTV, Inc. All Rights Reserved
 AbemaTV, Inc. All Rights Reserved


    28 課金システムのリニューアル ❏ StoreKit 1からStoreKit 2へ入れ替え ❏ 課金フローのリニューアル ❏ 従来のサブスクリプション課金フロー ❏ 新たなサブスクリプション課金フロー ❏ 汎用的なサブスクリプション登録ページを提供する ❏ 従来のサブスクリプション登録ページ ❏ 新たなサブスクリプション登録ページ
  23. AbemaTV, Inc. All Rights Reserved
 課金システムのリニューアル-StoreKit 1からStoreKit 2へ入れ替え 29 ❏

    AppStoreと通信処理をStoreKit 1からStoreKit 2にアップグレードした。
  24. AbemaTV, Inc. All Rights Reserved
 課金システムのリニューアル-従来のサブスクリプション課金フロー 30 現在のサブスクリプ ション状態確認 ユーザー情報取得

    AppStore経由購入 レシート検証 権限チェック ❏ AppStore経由して課金を行う前と課金後に複数APIを叩く 必要がある。 ❏ リトライフローがなくて、サーバーエラーより購入失敗す る場合、再度購入・復元ボタンをタップする必要がある。 購入完了 購入ボタンタップ
  25. AbemaTV, Inc. All Rights Reserved
 課金システムのリニューアル-新たなサブスクリプション課金フロー 31 レシート処理API AppStore経由購入 レシート処理API

    ❏ AppStore経由して課金を行う前と課金後に叩くABEMA サーバー側の複数APIを一本化にした。 ❏ 購入前に購入可否のチェック ❏ リトライ必要するかのチェック ❏ 購入後のレシート検証と権限付与 ❏ 復元対象の確認など ❏ リトライフローも合わせて用意して、バックグラウンドか らフォアグラウンドになるなどの場合にリトライを行って ユーザーの購入が早めに反映できるようになった。 購入完了 購入ボタンタップ
  26. AbemaTV, Inc. All Rights Reserved
 汎用的なサブスクリプション登録ページを提供する-従来のサブスクリプショ ン登録ページ 32 ❏ タイトルが固定されており、プレミアムプランしか購入できない。

    ❏ 購入ボタンの文言が固定されており、異なる価格が表示できない。 ❏ オファーを提供する場合、都度実装が必要になる。 ❏ 注意文言なども固定されている。
  27. AbemaTV, Inc. All Rights Reserved
 汎用的なサブスクリプション登録ページを提供する-新たなサブスクリプショ ン登録ページ 33 ❏ 画像やタイトル、注意文言を入稿でき、複数のプランに対応可能な汎用

    性がある。 ❏ プラン情報も入稿により動的に変化し、柔軟に対応できる。 ❏ オファーの提供が可能で、動的な変更にも対応できる。 ❏ ユーザー購読情報に基づいてリロード機能を付与し、二重課金をある程 度防止できる。 ❏ 入稿によって開放時間をコントロールでき、開放と閉じるを自由に操作 できる。
  28. AbemaTV, Inc. All Rights Reserved
 AbemaTV, Inc. All Rights Reserved


    34 オファー ❏ お試しオファー ❏ プロモーションオファー ※オファーコードは適用するCaseがないため割愛した。
  29. AbemaTV, Inc. All Rights Reserved
 オファー-お試しオファー 35 ❏ サブスクリプション単位で1件だけ有効なオファー設定が可能です。 ❏

    サブスクリプショングループごとに1度だけお試しオファーが適用可能で す。 ❏ 適用条件はApple側に判定するため、購入Request(product.purchase)にて オファーの指定が必要なし。 ❏ 同じサブスクリプショングループにユーザーが購読中のサブスクリプション があれば適用できない。 ❏ 3種類のタイプでの提供が可能です。 ❏ 都度払い、前払い、無料
  30. AbemaTV, Inc. All Rights Reserved
 オファー-お試しオファー 36 お試しオファー適用条件を判定する let fetchProductResult

    = await fetchProduct(productID: productID) switch fetchProductResult { case let .success(product): // 商品情報からお試しオファーの有無をチェックする。 guard let subscription = product.subscription, subscription.introductoryOffer != nil else { return false } // 購読中のサブスクリプションがある場合、お試しオファーは適用できない。 // 購読中のサブスクリプションがない場合、 // `isEligibleForIntroOffer`の値に基づいてお試しオファーの適用可否を判断する。 let groupID = subscription.subscriptionGroupID guard await getCurrentTransaction(groupID: groupID) == nil else { return false } return await Product.SubscriptionInfo.isEligibleForIntroOffer(for: groupID) case let .failure(error): throw error }
  31. AbemaTV, Inc. All Rights Reserved
 オファー-プロモーションオファー 37 ❏ サブスクリプション単位で同時的に10件有効なオファー設定が可能です。 ❏

    アプリ単位でサブスクリプション購読経験がないと適用できない。 ❏ サービス側の判定で何度でも提供が可能です。 ❏ 購入Request(product.purchase)にてオファーID指定が必要です。 ❏ 3種類のタイプでの提供が可能です。 ❏ 都度払い、前払い、無料
  32. AbemaTV, Inc. All Rights Reserved
 オファー-プロモーションオファー 38 プロモーションオファー適用条件を判定する // 該当ユーザーがABEMAでサブスクリプションを一度も購入したことがない場合、

    // プロモーションオファーを適用する権限がなし let allTransactions = await getAllTransactions().filter(\.isSubscription) guard !allTransactions.isEmpty else { return false } let fetchProductResult = await fetchProduct(productID: productID) switch fetchProductResult { case let .success(product): guard let subscription = product.subscription else { return false } // 該当商品のプロモーションに`OfferID`が見つからない場合、 // プロモーションオファーを適用する権限がなし return subscription.promotionalOffers.compactMap(\.id).contains(offerID) case let .failure(error): throw error }
  33. AbemaTV, Inc. All Rights Reserved
 オファー-プロモーションオファー 39 プロモーションオファー利用して商品購入する。 let options:

    [Product.PurchaseOption] = { [.promotionalOffer( offerID: offer.offerID, keyID: offer.keyID, nonce: offer.nonce, signature: offer.signature, timestamp: offer.timestamp )] }() let result = try await product.purchase(options: options) ❏ 注意①:署名が生成されてから24時間のみ有効する。 ❏ 注意②:購入リクエストにつき1度きり有効です、購入失敗になったら署名 が失効になり再生成が必要になる。 ❏ 対策:購入直前にオファー署名を再生成し、取得するようにする。
  34. AbemaTV, Inc. All Rights Reserved
 AbemaTV, Inc. All Rights Reserved


    40 リニューアル後のリリース ❏ 課金のSLIの整備 ❏ リリース戦略 ❏ リリース実施
  35. AbemaTV, Inc. All Rights Reserved
 リニューアル後のリリース-課金のSLI整備 41 ❏ なぜSLI計測が必要か? ❏

    アプリの運用品質を日々観測し、エラーなどが発生した際に早期 に検知したい。 ❏ AppleやGoogleなどとのやりとりを含む様々な事情により、バッ クエンドから見るとブラックボックスになってしまう。
  36. AbemaTV, Inc. All Rights Reserved
 リニューアル後のリリース-課金のSLI整備 42 enum SLIAttributeKey {

    // サブスクリプション商品 ID static let productID = "productID" // Abemaサーバー側持っているプランの ID static let planID = "planID" // サブスクリプショングループ ID static let groupID = "groupID" // 購入・復元・リトライ区別用のタイプ static let actionType = "actionType" // オファーID static let offerID = "offerID" // 結果タイプ static let resultType = "resultType" // 結果詳細 static let resultDescription = "resultDescription" // end時のサーバーレスポンス static let responseDescription = "responseDescription" // failure時のエラー詳細 orサーバーレスポンス static let errorDescription = "errorDescription" } ❏ 既存: ❏ 一部分不具合があり、正しい数値が得られない。 ❏ 失敗する時の詳細情報もほとんど送信していない。 ❏ 新規: ❏ New Relicへ送信処理をリニューアルした。 attributes: [:]
  37. AbemaTV, Inc. All Rights Reserved
 リニューアル後のリリース-リリース戦略 43 ❏ 通常の機能リリースへ影響しないために、専用のリリーススケジュールを用 意し、SLIの様子を見ながら手動リリースを行いました。

    ❏ App Store Connect経由して「段階的リリース」でリリースを行った。 ❏ リリース自体を既存状態に切り戻せるように、Feature Flagも利用した。
  38. AbemaTV, Inc. All Rights Reserved
 リニューアル後のリリース-リリース実施 44 ❏ New Relicに専用の

    DashBoardを用意して課金 起因の障害監視でを行っ た。 ❏ SLI整備でエラー詳細がすぐ に気づくように対応した。 ❏ 特に異常なしでリリースし ました、今までも特に大き な問題がなさそうです!
  39. AbemaTV, Inc. All Rights Reserved
 AbemaTV, Inc. All Rights Reserved


    45 新商品提供 ❏ 新商品構成 ❏ 商品作成 ❏ 販売開始 ❏ 商品入稿注意点①〜②
  40. AbemaTV, Inc. All Rights Reserved
 46 • 提供するサービスごとにサブスクリプショングループを定義した。 ABEMA de

    A ABEMA de B ABEMAプレミアム 期間: 1ヶ月 レベル: 1 期間: 1ヶ月 レベル: 1 期間: 1年 レベル: 1 期間: 1ヶ月 レベル: 1 新商品提供-新商品構成
  41. AbemaTV, Inc. All Rights Reserved
 新商品提供-販売開始 49 ❏ 予定の販売日時になると、アプリ側でサブスクリプション登録ページが表 示され、販売が開始できるようになる。

    ❏ 販売開始後に致命的な問題が発生した場合でも、サブスクリプション登録 ページを閉じて販売を停止することが可能です。
  42. AbemaTV, Inc. All Rights Reserved
 新商品提供-商品入稿注意点② 51 自動更新サブスクリプションの価格は為替レートに基づいて自動的に更 新されない。 ❏

    現象:円安や円高になったとしても、海外の自動更新サブスクリプション の価格は為替レートに基づいて自動的に更新されない。 ❏ 対策:地域ごとに自動更新サブスクリプションの価格を策定し、必要に応 じて為替レートに合わせて価格調整を行う必要がある
  43. AbemaTV, Inc. All Rights Reserved
 AbemaTV, Inc. All Rights Reserved


    52 StoreKit2へ移行することで課金実装が 簡潔になり、サブスクリンプションをよ り効果的に管理することができます! ぜひ、StoreKit 2の世界へお越し下さ い!
  44. AbemaTV, Inc. All Rights Reserved
 AbemaTV, Inc. All Rights Reserved


    53 以上になります。 ご清聴、ありがとうございました!