0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ITパスポート300問アプリにStoreKit 2でサブスクを入れた時の小さな気づき

0
Posted at

ITパスポート300問アプリにStoreKit 2でサブスクを入れた時の小さな気づき

前回(Core Data + CloudKit で設計した話)の続きです。

個人開発でリリースした iOS アプリ「情シスの教科書」(ITパスポート学習アプリ、約300問収録)に、StoreKit 2 で月額/年額サブスクリプションを実装しました。

StoreKit 2 自体の使い方は 別記事 で書いたので、今回は 実装してから App Store に出すまでの間に気づいた、ちょっとした工夫 を4つ紹介します。どれも大事故ではないのですが、「最初から知っていれば寄り道せずに済んだな」というポイントです。

  • 気づき①: サブスク状態は Bool よりも3つの状態で持つと安心
  • 気づき②: 家族共有を ON にするなら ownershipType を見ておくと親切
  • 気づき③: 無料トライアル表示は eligibility を確認してから出す
  • 気づき④: 「購入を復元」ボタンは結果のフィードバックを丁寧に

学習・コンテンツ系の個人開発で StoreKit 2 を触る方の参考になればうれしいです。


前提: 何を売っているか

  • 月額プラン (com.okojyosoft.joshis.monthly)
  • 年額プラン (com.okojyosoft.joshis.yearly)
  • 同一 Subscription Group に所属(アップグレード/ダウングレードを Apple 側に任せる)
  • 7日間の無料トライアル付き(イントロオファー)
  • 課金で解放される機能: 全300問解放、SQL実践モード、詳細解説

サブスク状態は前回記事で書いた Core Data + CloudKit のユーザーデータ側には保存せず、毎回 StoreKit 2 の Transaction を参照する 設計にしました。これは結果的に良い判断だったと感じています(理由は最後に少しだけ)。


気づき① サブスク状態は Bool よりも3つの状態で持つと安心

最初はこう書いていました。

@MainActor
final class SubscriptionStore: ObservableObject {
    @Published private(set) var isPremium = false

    func refresh() async {
        var active = false
        for await result in Transaction.currentEntitlements {
            if case .verified(let tx) = result, tx.revocationDate == nil {
                active = true
            }
        }
        isPremium = active
    }
}

普段の通信環境では問題なく動くのですが、機内モードで起動したときなど、起動直後にまだ判定が終わっていない瞬間に isPremium = false で UI が描画される ことがありました。実害というほどではないのですが、サブスク済みのユーザーに一瞬ロック画面が見えてしまうのは申し訳ないなと。

やってみた工夫

「不明」という状態を持たせて、確定するまでロックも解放もしないようにしました。

enum PremiumState {
    case unknown   // 起動直後・確定前
    case premium
    case free
}

@MainActor
final class SubscriptionStore: ObservableObject {
    @Published private(set) var state: PremiumState = .unknown

    func refresh() async {
        var active = false
        for await result in Transaction.currentEntitlements {
            if case .verified(let tx) = result, tx.revocationDate == nil {
                active = true
            }
        }
        state = active ? .premium : .free
    }
}

UI 側はこう。

switch store.state {
case .unknown:
    ProgressView()
case .premium:
    PremiumContentView()
case .free:
    PaywallView()
}

判定が終わるまでのほんの一瞬ですが、「読み込み中」を見せるほうがユーザー体験として落ち着くなと感じました。

学んだこと

サブスク状態を Bool ではなく「まだ分からない」も表現できる型にしておくと、起動直後の表示が穏やかになる。


気づき② 家族共有を ON にするなら ownershipType を見ておく

App Store Connect の設定で「Family Sharing」を ON にしたとき、ちょっと考慮が必要なことに後から気づきました。

家族共有で利用しているユーザーの Transaction.ownershipType.familyShared で返ってきます。本人購入の .purchased とは区別されます。

最初はここを意識せず、家族共有のユーザーにも「サブスクリプションを管理」ボタンを出していました。実際にはそのボタンから家族共有メンバーが購入者のサブスクを管理することはできないので、押しても何もできない、という表示になってしまいます。

やってみた工夫

struct EntitlementInfo {
    let isActive: Bool
    let isFamilyShared: Bool
    let productID: String?
    let expirationDate: Date?
}

func currentEntitlement() async -> EntitlementInfo {
    for await result in Transaction.currentEntitlements {
        guard case .verified(let tx) = result,
              tx.revocationDate == nil else { continue }

        return EntitlementInfo(
            isActive: true,
            isFamilyShared: tx.ownershipType == .familyShared,
            productID: tx.productID,
            expirationDate: tx.expirationDate
        )
    }
    return EntitlementInfo(isActive: false, isFamilyShared: false,
                           productID: nil, expirationDate: nil)
}

家族共有ユーザーには、管理ボタンの代わりに案内文を出すようにしました。

if entitlement.isFamilyShared {
    Text("家族共有で利用中です。プランの変更や解約は、購入者の方が「設定 > Apple ID > サブスクリプション」から行えます。")
        .font(.footnote)
        .foregroundStyle(.secondary)
} else {
    Button("サブスクリプションを管理") {
        showManageSheet = true
    }
}

家族共有の挙動は Xcode の StoreKit Configuration File では再現できないので、実機テストが必要でした。家族のうち誰かにテスト用に協力してもらうと良いと思います。

学んだこと

Family Sharing を有効にする場合は ownershipType で分岐して、家族共有ユーザー向けの案内を別に用意しておく。


気づき③ 無料トライアル表示は eligibility を確認してから

ペイウォール画面で「7日間無料、その後 月額○○円」と表示していました。これ、過去にトライアルを使い終わったユーザーが再訪したときも同じ表示になっていて、「あれ、もう一度無料で使えるのかな?」と誤解させてしまう可能性がありました。

トライアルは Subscription Group ごとに1回までなので、2回目以降は無料期間なしで課金されます。

やってみた工夫

subscription.isEligibleForIntroOffer で対象ユーザーかどうかを確認してから表示を分岐します。

import StoreKit

func isEligibleForIntroOffer(productID: String) async -> Bool {
    guard let product = try? await Product.products(for: [productID]).first,
          let subscription = product.subscription else {
        return false
    }
    return await subscription.isEligibleForIntroOffer
}
if isEligibleForTrial {
    Text("7日間無料、その後 \(product.displayPrice) / 月")
    Button("無料で始める") { purchase() }
} else {
    Text("\(product.displayPrice) / 月")
    Button("購読する") { purchase() }
}

isEligibleForIntroOffer は Subscription Group 単位で判定されます。月額でトライアルを使い終わったユーザーが年額のペイウォールを見たときも「対象外」になるので、年額側の表示も同じ仕組みで分岐しました。

学んだこと

トライアル表示を出す前に isEligibleForIntroOffer を確認する。判定は Subscription Group 単位。


気づき④ 「購入を復元」は結果のフィードバックを丁寧に

最初はこう書いていました。

Button("購入を復元") {
    Task {
        try? await AppStore.sync()
        await store.refresh()
    }
}

Sandbox 環境だと一瞬で終わるので問題なく見えるのですが、本番のネットワーク経由だと数秒かかることがあります。その間ユーザーは何も反応がない状態になって、「押せたのかな?」と不安になりそうだなと思いました。

やってみた工夫

復元処理の状態を持って、それぞれの段階でメッセージを出すようにしました。

enum RestoreState {
    case idle
    case syncing
    case succeededWith(Bool)
    case failed(Error)
}

@MainActor
final class RestoreCoordinator: ObservableObject {
    @Published var state: RestoreState = .idle

    func restore(store: SubscriptionStore) async {
        state = .syncing
        do {
            try await AppStore.sync()
            await store.refresh()
            let restored = store.state == .premium
            state = .succeededWith(restored)
        } catch {
            state = .failed(error)
        }
    }
}
switch coordinator.state {
case .syncing:
    ProgressView("購入を復元中...")
case .succeededWith(true):
    Text("購入を復元しました。")
case .succeededWith(false):
    Text("復元できる購入が見つかりませんでした。")
case .failed(let error):
    Text("復元できませんでした: \(error.localizedDescription)")
case .idle:
    EmptyView()
}

「復元できませんでした」と「該当する購入がありませんでした」を分けると、ユーザーが次にどうすればいいか判断しやすくなります。

学んだこと

「購入を復元」は「処理中」「成功」「該当なし」「失敗」の4状態を区別してフィードバックすると親切。


おまけ: サブスク状態を Core Data に同期しなかった話

前回記事で書いた通り、このアプリのユーザーデータは Core Data + CloudKit で同期しています。「サブスク状態も同期したほうが起動が速くなりそう」と最初は考えていたのですが、結局やめました。

理由は3つほど。

  1. サブスク状態の本当の情報は Apple のサーバーにある。アプリ側にキャッシュした値が古くなった瞬間ややこしいことになる
  2. CloudKit 同期は数秒〜数分の遅延がある。Transaction.currentEntitlements を直接見たほうが速いケースが多い
  3. サブスクは購入時の Apple ID に紐づくが、CloudKit ユーザーデータは現在の iCloud アカウントに紐づく。別の Apple ID で課金して同じ iCloud で学習している、というケースを考えると同期しないほうが素直

毎回 StoreKit から取る、というシンプルな構成にしておいて良かったなと感じています。


まとめ

StoreKit 2 は StoreKit 1 と比べてかなり書きやすくなりましたが、それでも「動く」と「ちゃんと使える」の間にはいくつか小さな配慮ポイントがあります。

  • サブスク状態は Bool ではなく unknown を含む3状態で持つ
  • Family Sharing を有効にするなら ownershipType で分岐する
  • トライアル表示は isEligibleForIntroOffer で確認してから
  • 「購入を復元」は処理中・成功・該当なし・失敗の4状態でフィードバック
  • サブスク状態の本当の情報源は Apple のサーバー。アプリ側に保存しない

どれも派手な話ではないですが、リリース前に気づけると寄り道が減るポイントだと思います。

次回はリリース後に少し気になっている「サブスクの継続率をどう測るか」という話を書く予定です。StoreKit 2 だけでは継続率の分析が難しい場面があるので、そのあたりを考えていきたいなと。

質問やツッコミがあれば、コメントでお気軽にどうぞ。


参考リンク

0
1
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
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?