見出し画像

StoreKit 2によるnote iOSアプリのアプリ内課金(In-App Purchase)

2024/4/10に、noteはiOS/Androidともにアプリ内課金機能がリリースされました。

実は課金機能を1から実装するのは今回はじめてだったんですが、StoreKit 2を採用したら、めちゃくちゃ簡単に実装することができました!

あふれ出るStoreKit 2への感謝をこめて、この記事を書かせていただきます。

※ この記事はnote株式会社のアプリチーム1weekアドベントカレンダーの1日目の記事です


検証環境

Xcode 15.4 / Swift 5.10

前提

noteでアプリ内課金というと、記事を買うタイミングで課金が走ると予想するかと思いますが、違います。次の2ステップが必要です。

  • noteポイントを購入

  • ポイントを使って記事購入

マンガアプリやソーシャルゲームの購入体験に近いです。

なぜこんな仕様かというと、アプリ内課金はAppleの定めたTierに従う必要があり、従った場合、noteの記事の販売価格をユーザーが自由に決められなくなるためです。

StoreKit vs StoreKit 2

主な実装は2023年に行いました。

現在App Storeに企業が公開しているiOSアプリは、何らかの課金要素が入っていることかと思います。noteは大人の事情で長年モバイルアプリに課金機能をつけられませんでした。

しかし課金機能の実装が遅れたおかげで、最初からStoreKit 2を選択することができました。

StoreKitとStoreKit 2の違いは、下記のスライドが詳しいです。

簡単にStoreKit 2で改善した点を書きます。

  • ハンドリングが難しかったObserverパターンからSwift Concurrencyベースに設計変更

  • レシートのJWS化によりレシート検証が簡略化

今から新規実装するのであれば、StoreKit 2一択です。

ただ多くのアプリは既にStoreKitが入っていると思うので、共存させて使うと別のつらさがあると察せられます。今回僕は共存を考えないで良かったのが、本当に幸いでした。

なお一点注意があって、StoreKit 2はasync/awaitベースで処理することになる都合上、iOS 15以上である必要があります。

2023年時点だと、noteのiOSアプリはiOS 14以上対応だったので、課金機能実装の前に下位バージョン切りを行いました。

アプリ内課金の設計

課金機能を実現するためには、アプリ側の実装よりも、App Storeの設定だったり、サーバーサイドの実装だったりが発生して、考慮事項が多いです。

Appleの公式ドキュメントを一読すると、何となく全体像が見えるかと思います。

日本語

あと公式がサンプルプロジェクトあげてくれてて、WWDCのセッションもあるので、その辺見ると基本的なことはわかると思います。

ただこのドキュメントだと詳細についてはわからないと思うので、詳細を知りたいときは下記の岸川さんの記事を読むといいと思います。僕は実装終わってからこの記事知って、先に見たかったなあと思いました。

iOSアプリ側の課金処理のメインは、課金アイテムの一覧表示と、購入処理とにわけられます。

まず課金アイテムの一覧表示について。

  1. 社内サーバーから課金アイテムのproduct_idリストを取得する

  2. App Storeからproduct_idを用いて、Productリストを取得する

  3. ↑App Storeに登録した商品情報が取れるので、表示

雑に試すだけなら、アプリの中にplistファイルにしてproduct_idリスト持っちゃっても可ですが、マルチプラットフォームに展開しているサービスであれば、サーバーに持たせるのが良いと思います。

この図を見るとProductを取得している意味を感じないかもしれませんが、displayPrice というプロパティがあって、ここにユーザーの利用していると思われる通貨単位での価格が入ってきます。

続いて購入処理の流れです。

  1. App Storeにproduct_idを使って購入リクエストを送る

    1. (iOS標準の購入シートが表示される)

  2. レシートが送られてくる

  3. 社内サーバーにレシートを送信して検証してもらう

  4. 検証OKだったら購入トランザクションを終了する

アプリ内課金では、このレシート検証のステップがたぶん一番難易度高いところです。いや、「でした」と言ってもいいのかもしれません。StoreKit 2であれば、JWS化されたため、検証プロセスもだいぶ楽です。

詳述は省きますが、旧StoreKit時代はサブスクリプション情報がレシートデータ内に繰り返し含まれて、肥大化する問題がありましたが、これも解消しています。

レシート検証はなぜ必要か

若干脱線します。

iOS開発をやり始めたときから、レシート検証というプロセス自体要らないんじゃないか?と実は思っていました。

明らかにユーザーが購入処理して、App Storeにリクエスト投げて成功レスポンスもらったんだから、これで購入処理完了で良いのでは?という疑問がありました。

大義名分としては不正購入を防ぐ、ということなんですが、イマイチ納得がいってませんでした。

レシートの改竄や偽造によって、通常買えないアイテムが買える……というロジックなんですが、iOSアプリの仕組み上、ユーザーが任意のレシートを任意のアプリに読みこませるって、開発者がそんな機能を実装しない限りムリでは?と思っていました。

しかし、脱獄(Jailbreak)した端末であれば、そうとも限らないことに気づきました。もちろん簡単に不正利用はできませんが、技術がある人だったらできてしまいそうな気がします。

そういうときに、レシート検証の仕組みがあれば、不正利用の抑止力になりうるかもしれません。

実装

さて、上記の設計に基づいて、実装していきましょう。

実装はめちゃくちゃ簡単です。

まず課金アイテムの一覧表示です。

product_idの一覧を社内サーバーから取得したら、後は下記のようにproducts(for:)を読んで、Product配列を取得します。以上。

import StoreKit

func getProducts(itemIdentifiers: Set<String>) async throws -> [Product] {
    try await Product.products(for: itemIdentifiers)
}

続いて購入処理ですが、この記述もめちゃくちゃ簡単です。手順をふりかえりましょう。

  1. App Storeにproduct_idを使って購入リクエストを送る

    1. (iOS標準の購入シートが表示される)

  2. レシートが送られてくる

  3. 社内サーバーにレシートを送信して検証してもらう

  4. 検証OKだったら購入トランザクションを終了する

まず1. 購入リクエスト。

func purchase(product: Product) async throws -> Product.PurchaseResult {
    try await product.purchase()
}

これだけです。PurchaseResultはStoreKit 2の中で定義されているenumです。

public enum PurchaseResult {
    case success(VerificationResult<Transaction>)
    case userCancelled
    case pending
}

このPurchaseResultをレシート検証のためのメソッドに渡してやります。手順でいうと3と4のステップです。

func verify(result: Product.PurchaseResult) async throws -> API.PointResponse {
    switch result {
    case let .success(verification):
        switch verification {
        case .unverified:
            throw PurchaseError.transactionFailed
        case let .verified(transaction):
            // 3. 社内サーバーにレシートを送信して検証してもらう
            let response = try await … 
            // 4. 検証OKだったら購入トランザクションを終了する
            await transaction.finish()
            return response
        }
    case .pending:
        throw PurchaseError.pending
    case .userCancelled:
        throw PurchaseError.cancelled
    @unknown default:
        throw PurchaseError.failed
    }
}

アプリからトランザクション開始を明示的に書いてないですが、終了に関してはtransaction.finish()を必ず書きます。

注意点はそのぐらいでしょうか。StoreKit 2の実装は本当に楽です。

その他考慮点

この記事は長くしたくないので、メイン機能だけに言及を留めます。その他の考慮点を列挙します。

  • リストア処理

    • 今回のnoteのケースでは消耗型アイテムだけだったので考慮不要だった

    • 非消耗型・サブスクリプションなどでは、購入したはずなのになぜか反映されていない状態に陥ったときにリストア(復元)する処理があるとベター

  • リスナータスクの生成

  • App Store Server Notifications

    • サーバーサイドでイベントを受ける

    • 失効や返金など、アプリ外で発生するイベント通知がある

  • テストまわり

    • sandbox

    • StoreKitTest

その他参考資料

若干古い資料にはなるんですが、それでも詳しくてわかりやすいので、参考になりました。

まとめ

以上、note iOSアプリにおけるアプリ内課金について書きました。

改めて強調しますが、StoreKit 2を選択できた時点で、実装的には格段に楽になりました。

最後にですが、noteは現在Androidエンジニアを絶賛募集しております。アプリ内課金が実装され、ますます盛り上がりを見せるnoteアプリに興味ある方は是非お声がけください!

iOSエンジニアの方も、カジュアル面談や個人的に話聞きたいなど大歓迎です!

(了)

この記事が気に入ったらサポートをしてみませんか?