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

ABEMAの課金機能の刷新について:- StoreKit2によるiOSのアプリ内課金のリニューアル

ABEMAの課金機能の刷新について:- StoreKit2によるiOSのアプリ内課金のリニューアル

昨年、ABEMAの課金機能の基盤であるStoreKitを最新のStoreKit2に刷新しました。この発表では、刷新を決定した背景、開発中に直面した技術的課題とその解決策について詳しく解説します。

CyberAgent

April 18, 2024
Tweet

More Decks by CyberAgent

Other Decks in Technology

Transcript

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


    1 株式会社 AbemaTV StoreKit2によるiOSのアプ リ内課金のリニューアル 
 ABEMAの課金機能の刷新について Apr 15th, 2024 AbemaTV 康 建斌
  2. AbemaTV, Inc. All Rights Reserved
 自己 紹介 2 康 建斌(コウ

    ケンヒン) ❏ 株式会社サイバーエージェント ❏ 2022年中途入社 ❏ Product Engineering Div.のiOSリーダー ❏ 中国(山西省)出身 ❏ 趣味:映画とアニメ ❏ X: @kangnuxlion ※肉ちゃんの写真です
  3. AbemaTV, Inc. All Rights Reserved
 主な内容 3 ❏ ABEMAの課金について ❏

    リニューアルの背景 ❏ StoreKit2 ❏ オファーの適用 ❏ サブスク課金フローリニューアル ❏ リリースとリリース後の監視
  4. AbemaTV, Inc. All Rights Reserved
 リニューアル背景 8 ❏ iOS側 ❏

    2023年9月30日(金)よりiOS14のサポート終了、iOS15からサ ポートしているStoreKit2の導入 ❏ プロモーションオファーの導入 ❏ 課金のSLI (Service Level Indicator)の整備 ❏ StoreKit Testの導入 ※SLI(Service Level Indicator):サービスの運用品質(パフォーマンス)を計測するのに使われる指標
  5. AbemaTV, Inc. All Rights Reserved
 リニューアル背景 9 ❏ バックエンド側 ❏

    システムをシンプルにするためにサブスク課金フローのマイクロ サービス化 ❏ App Store Server NotificationをV2へのアップデートとイベ ントハンドリングの改修
  6. AbemaTV, Inc. All Rights Reserved
 AbemaTV, Inc. All Rights Reserved


    10 StoreKit2 ❏ StoreKit2とは? ❏ シンプルさ ❏ トランザクション①〜⑤ ❏ トランザクション注意点①〜② ❏ API検証 ❏ レシート検証 ❏ 新機能①〜②
  7. AbemaTV, Inc. All Rights Reserved
 StoreKit2 11 StoreKit2とは? ❏ StoreKit

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

    ❏ StoreKitTest活用してUTで一部分Caseがテストできる! ❏ 課金処理が理解しやすくなる!
  9. AbemaTV, Inc. All Rights Reserved
 StoreKit2-シンプルさ-トランザクション① 13 プロダクト情報の取得-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 }
  10. AbemaTV, Inc. All Rights Reserved
 StoreKit2-シンプルさ-トランザクション② 14 プロダクトの購入-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() }
  11. AbemaTV, Inc. All Rights Reserved
 StoreKit2-シンプルさ-トランザクション③ 15 購読中トランザクションの取得-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)) }
  12. AbemaTV, Inc. All Rights Reserved
 StoreKit2-シンプルさ-トランザクション④ 16 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)) } \
  13. AbemaTV, Inc. All Rights Reserved
 StoreKit2-シンプルさ-トランザクション⑤ 17 トランザクションの監視-Transaction.updates for await

    result in Transaction.updates { do { let transaction = try self.checkVerified(result) // **権限付与などABEMA側の処理を行う** // トランザクションを完了する await transaction.finish() } catch { ... } } ❏ 説明: ❏ アプリ外部と内部の購入が簡単に分けて処理できる。 ❏ Transaction.updatesよりアプリ内で購入した時は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 { ... } }
  14. AbemaTV, Inc. All Rights Reserved
 StoreKit2-トランザクション注意点① 18 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 ❏ 対策:iOS15.4未満のバージョンに対してTransaction.updatesとTransaction.unfinished両方 とも監視するように対策した。 // 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
  15. AbemaTV, Inc. All Rights Reserved
 StoreKit2-トランザクション注意点② 19 Transaction.updatesの不具合 • Transaction.updates

    ❏ 現象:Transaction.updatesよりアプリ内で購入した時はTransactionが流れないと書 いてあるが! ❏ AppStoreの購入結果画面が表示されて時間が経つとTransaction.updatesより流 れることがある! ❏ 対策:アプリで購入する途中でTransaction.updatesより流れた同じ商品IDのトランザク ションのハンドリング処理をスキップするように対策した。 // 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
  16. AbemaTV, Inc. All Rights Reserved
 StoreKit2-レシート検証 21 アプリ側でレシート検証の結果が確認できる func checkVerified<T>(_

    result: VerificationResult<T>) throws -> T { switch result { case .unverified: // レシート検証失敗 throw StoreError.failedVerification case .verified(let safe): // レシート検証成功 return safe } } ❏ アプリ側にレシート検証ができるようになったが、ABEMAでは従来と同 じようなサーバー側に検証するような方針で対応した。 ❏ かわりにレシート検証する際にjwsRepresentationをサーバー側へ送 る必要がある。
  17. AbemaTV, Inc. All Rights Reserved
 StoreKit2-新機能① 22 アプリ内にユーザーへの返金動線を用意できる try await

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

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


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

    サブスクグループごとに1度だけお試しオファーが利用可能です。 ❏ 適用条件はApple側に判定するため、購入Request(product.purchase)にて オファー適用の指定が必要なし。 ❏ 同じサブスクグループにユーザーが購読中のサブスクがあれば適用できな い。 ❏ 3種類のタイプでの提供が可能です。 ❏ 都度払い、前払い、無料
  21. AbemaTV, Inc. All Rights Reserved
 オファーの適用-お試しオファー 26 お試しオファー適用条件を判定する 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 }
  22. AbemaTV, Inc. All Rights Reserved
 オファーの適用-プロモーションオファー 27 ❏ サブスク単位で10件有効なオファー設定が可能です。 ❏

    アプリ単位でサブスク購読経験がないと適用できない。 ❏ サービス側の判定で何度でも提供が可能です。 ❏ 購入Request(product.purchase)にてオファーID指定が必要です。 ❏ 3種類のタイプでの提供が可能です。 ❏ 都度払い、前払い、無料
  23. AbemaTV, Inc. All Rights Reserved
 オファーの適用-プロモーションオファー 28 プロモーションオファー適用条件を判定する // 該当ユーザーが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 }
  24. AbemaTV, Inc. All Rights Reserved
 オファーの適用-プロモーションオファー 29 プロモーションオファー利用して購入する。 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時間のみ有効する。 ❏ AppStoreへの購入リクエストにつき1度きり有効です、購入失敗になったら 署名が失効になり再生成が必要になる。
  25. AbemaTV, Inc. All Rights Reserved
 AbemaTV, Inc. All Rights Reserved


    30 サブスク課金フローリニューアル ❏ 従来のサブスク課金フロー ❏ 新たなサブスク課金フロー
  26. AbemaTV, Inc. All Rights Reserved
 サブスク課金フローリニューアル-従来のサブスク課金フロー 31 支払い状態取得 ユーザー情報取得 AppStore経由購入

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

    ❏ AppStore経由して課金を行う前と課金後に叩くABEMA サーバー側の複数APIを一本化にした。 ❏ 購入前のチェック ❏ リトライ必要かのチェック ❏ 購入後のレシート検証と権限付与 ❏ 復元対象の確認など ❏ リトライフローも合わせて用意してバックフォアなどの場 合リトライを行ってユーザーの購入が早めに反映できるよ うになった。 購入完了 購入ボタンタップ
  28. AbemaTV, Inc. All Rights Reserved
 AbemaTV, Inc. All Rights Reserved


    33 リリースとリリース後の監視 ❏ 課金のSLIの整備 ❏ リリースと監視
  29. AbemaTV, Inc. All Rights Reserved
 リリースとリリース後の監視-課金のSLI整備 34 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" } ❏ 既存: ❏ 一部分不具合があり、正しい数値が得ない。 ❏ 失敗する時の詳細情報もほとんど送信していない。 ❏ 新規: ❏ NewRelicへ送信処理をリニューアルした。 attributes: [:]
  30. AbemaTV, Inc. All Rights Reserved
 リリースとリリース後の監視-リリースと監視 36 ❏ NewRelicに課金監視専用の DashBoardを用意して課金

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


    37 StoreKit2への対応により、課金がより 簡単になり、サブスクリプションをより 効果的に管理することができます。 ぜひ、StoreKit2の世界へようこそ!
  32. AbemaTV, Inc. All Rights Reserved
 AbemaTV, Inc. All Rights Reserved


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