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

社内SEが個人開発でiOSアプリをApp Store申請するまで——StoreKit 2 / AdMob / Core Data + CloudKit 全部入り構成の話

1
Last updated at Posted at 2026-05-02

はじめに

普段は社内SE(情シス)として企業のITインフラを管理しています。

ある日、育成中の若手社員が「AIが大丈夫と言ったので実行しました」と言い放った瞬間に、基礎知識なきAI活用の危うさをリアルに感じました。教えたくても時間も教材もない。ならば自分で作ろう——そう思い立って開発したのが、ITパスポート対応のiOS学習アプリ「情シスの教科書」です。

Swift・Xcodeはほぼゼロからのスタート。試行錯誤しながら実装した技術構成を、同じように個人開発に挑戦している方の参考になればと思い、まとめます。


アプリ概要

項目 内容
アプリ名 情シスの教科書
ターゲット 社内SE・IT系職種・ITパスポート学習者
対応OS iOS / iPadOS
収益モデル サブスクリプション(月額480円 / 年額3,800円) + 広告(無料プラン)
主な学習領域 IT基礎・ネットワーク・セキュリティ・クラウド・AI活用リテラシー
開発環境 Xcode 26.4.1 / Swift 6.3.1 / SwiftUI

技術スタック全体像

情シスの教科書
├── UI層
│   └── SwiftUI(NavigationSplitView でiPad対応)
├── 収益化
│   ├── StoreKit 2(サブスクリプション / 7日間無料トライアル)
│   └── Google AdMob(無料プラン向けバナー・インタースティシャル広告)
├── データ層
│   ├── Core Data(学習進捗・ローカル永続化)
│   └── CloudKit(iCloud経由のデバイス間同期)
└── その他
    └── App Store Connect(審査・サブスク設定)

1. StoreKit 2 でサブスクリプション実装

なぜ StoreKit 2 か

StoreKit 1(旧API)は SKPaymentQueue を介した複雑なデリゲート管理が必要でしたが、StoreKit 2 は async/await ベースのすっきりしたAPIです。個人開発でサーバーを持ちたくなかったので、レシート検証もオンデバイスで完結できる点が決め手でした。

サブスクリプションの購入フロー

import StoreKit

// Swift 6: @MainActor を付けて @Published の変更をメインスレッドに保証
@MainActor
final class SubscriptionManager: ObservableObject {
    @Published var isSubscribed = false
    private var products: [Product] = []

    // App Store Connect で設定した Product ID
    private let productIDs = [
        "com.yourapp.monthly",
        "com.yourapp.yearly"
    ]

    func loadProducts() async {
        do {
            products = try await Product.products(for: productIDs)
        } catch {
            print("商品取得エラー: \(error)")
        }
    }

    func purchase(_ product: Product) async throws {
        let result = try await product.purchase()
        switch result {
        case .success(let verification):
            // 検証(JWS署名の確認)
            switch verification {
            case .verified(let transaction):
                await transaction.finish()
                await updateSubscriptionStatus()
            case .unverified:
                throw SubscriptionError.verificationFailed
            }
        case .userCancelled:
            break
        case .pending:
            break
        @unknown default:
            break
        }
    }

    func updateSubscriptionStatus() async {
        // Swift 6: nonisolated なAsyncSequenceをfor-awaitする際はawaitが必要
        for await result in Transaction.currentEntitlements {
            if case .verified(let transaction) = result {
                isSubscribed = transaction.productType == .autoRenewable
            }
        }
    }
}

7日間無料トライアルの設定

コード側での特別な実装は不要で、App Store Connect の設定のみで実現できます。

  1. App Store Connect → サブスクリプショングループを作成
  2. 各プランに「オファー」を追加
  3. オファータイプ:無料トライアル、期間:7日間

ただし、introductoryOffer を使えばアプリ側でトライアル中かどうかを判定できます。

// トライアル中かどうかを表示に活用
if let intro = product.subscription?.introductoryOffer {
    Text("今なら\(intro.period.value)日間無料")
}

ハマりポイント

バンドルIDとApp Store Connectの不一致には注意。開発途中でバンドルIDを変更すると、App Store Connect に登録済みの Product ID が紐付かなくなります。StoreKitTest フレームワークを使ったシミュレータテストでも購入フローが通るのに、実機では invalidProductID エラーが出て数時間溶かしました。


2. Google AdMob — 無料プランの収益化

構成方針

サブスク未加入の無料ユーザーには広告を表示し、加入後は非表示にするフリーミアム設計にしました。

// AdMob 初期化(AppDelegate or @main の init)
import GoogleMobileAds

GADMobileAds.sharedInstance().start(completionHandler: nil)

バナー広告の SwiftUI 対応

AdMob の GADBannerView は UIKit ベースなので、SwiftUI では UIViewRepresentable でラップします。

struct AdBannerView: UIViewRepresentable {
    func makeUIView(context: Context) -> GADBannerView {
        let banner = GADBannerView(adSize: GADAdSizeBanner)
        banner.adUnitID = "ca-app-pub-xxxxxxxx/xxxxxxxxxx"
        // Swift 6: @MainActor が必要なプロパティへのアクセスを明示
        banner.rootViewController = UIApplication.shared
            .connectedScenes
            .compactMap { $0 as? UIWindowScene }
            .first?.keyWindow?.rootViewController
        banner.load(GADRequest())
        return banner
    }

    func updateUIView(_ uiView: GADBannerView, context: Context) {}
}

サブスク状態に応じた表示切り替え

struct ContentView: View {
    // Swift 6 / iOS 17+: @EnvironmentObject の代わりに @Environment を推奨
    // (旧来の @EnvironmentObject も動作するが、新規コードでは Observable マクロが主流)
    @Environment(SubscriptionManager.self) private var subscriptionManager

    var body: some View {
        VStack {
            // メインコンテンツ
            MainContentView()

            // サブスク未加入時のみ広告表示
            if !subscriptionManager.isSubscribed {
                AdBannerView()
                    .frame(height: 50)
            }
        }
    }
}

ハマりポイント

App Store 審査では テスト広告ID のまま提出すると審査落ちします。本番用の広告ユニットIDに差し替えてからビルドすること。また、Info.plistGADApplicationIdentifier を設定しないとクラッシュするのですが、エラーメッセージが分かりにくいため要注意です。


3. Core Data + CloudKit — デバイス間の学習進捗同期

ここが今回の実装で一番面白く、かつ一番ハマった部分です。

なぜサーバーレスにしたか

個人開発でバックエンドを持つとランニングコストと保守コストが発生します。NSPersistentCloudKitContainer を使えば、iCloud経由で無料かつサーバーレスでデバイス間同期が実現できます。

基本セットアップ

通常の NSPersistentContainerNSPersistentCloudKitContainer に変えるだけで、CloudKit同期が有効になります。

import CoreData
import CloudKit

// Swift 6: Sendable に準拠させるため final + @unchecked Sendable
final class PersistenceController: @unchecked Sendable {
    static let shared = PersistenceController()

    let container: NSPersistentCloudKitContainer

    init() {
        container = NSPersistentCloudKitContainer(name: "JoshisTextbook")

        // CloudKit同期の設定
        guard let description = container.persistentStoreDescriptions.first else { return }
        description.setOption(
            true as NSNumber,
            forKey: NSPersistentHistoryTrackingKey
        )
        description.setOption(
            true as NSNumber,
            forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey
        )

        container.loadPersistentStores { _, error in
            if let error = error {
                fatalError("CoreData読み込みエラー: \(error)")
            }
        }

        container.viewContext.automaticallyMergesChangesFromParent = true
        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
    }
}

データモデル設計

学習進捗のエンティティ設計はシンプルにしました。

StudyProgress
├── id: UUID
├── categoryID: String       // "network", "security" など
├── questionID: String       // 問題の一意ID
├── isCorrect: Bool          // 正解 / 不正解
├── answeredAt: Date         // 回答日時
└── reviewFlag: Bool         // 復習フラグ

リモート変更の検知と UI 反映

別デバイスで学習した内容を即座に反映するため、NSPersistentStoreRemoteChange 通知を受け取ります。

// Swift 6: @MainActor で UI更新をメインスレッドに保証
// Combine の代わりに Swift Concurrency(Task)で通知を受け取る
@MainActor
final class StudyViewModel: ObservableObject {
    @Published var progressList: [StudyProgress] = []
    private var remoteChangeTask: Task<Void, Never>?

    init() {
        // Swift 6: NotificationCenter.notifications(named:) で async/await に統一
        remoteChangeTask = Task { [weak self] in
            for await _ in NotificationCenter.default.notifications(
                named: .NSPersistentStoreRemoteChange
            ) {
                await self?.fetchProgress()
            }
        }
        Task { await fetchProgress() }
    }

    deinit {
        remoteChangeTask?.cancel()
    }

    func fetchProgress() async {
        let context = PersistenceController.shared.container.viewContext
        let request = StudyProgress.fetchRequest()
        request.sortDescriptors = [NSSortDescriptor(key: "answeredAt", ascending: false)]

        do {
            progressList = try context.fetch(request)
        } catch {
            print("フェッチエラー: \(error)")
        }
    }
}

ハマりポイント3選

① Capabilities の設定漏れ
Xcode の Signing & Capabilities で iCloud を追加し、CloudKit にチェックを入れ忘れると、ビルドは通るのに同期が一切動きません。沈黙の失敗なので気づくのに時間がかかります。

② スキーマの初期化タイミング
NSPersistentCloudKitContainer は初回起動時に CloudKit 側のスキーマを自動生成しますが、initializeCloudKitSchema() を明示的に呼ばないと開発中に同期されないことがあります。

// 開発時のみ呼び出す(本番では不要)
#if DEBUG
try container.initializeCloudKitSchema(options: [])
#endif

③ mergePolicy の選択
複数デバイスから同時に書き込まれた場合のコンフリクト解消ポリシーを適切に選ぶ必要があります。学習進捗アプリの場合は「後から書いた方を優先」で問題ないため NSMergeByPropertyObjectTrumpMergePolicy を採用しました。


4. iPad 対応 — NavigationSplitView

iPhone専用で開発を始めたあと、「せっかくならiPadでも使いたい」という考えから対応を追加しました。

struct RootView: View {
    @State private var selectedCategory: Category?

    var body: some View {
        NavigationSplitView {
            // サイドバー(カテゴリ一覧)
            CategoryListView(selectedCategory: $selectedCategory)
        } detail: {
            // メインエリア(問題表示)
            if let category = selectedCategory {
                QuestionView(category: category)
            } else {
                Text("カテゴリを選択してください")
                    .foregroundStyle(.secondary)
            }
        }
    }
}

iPhoneでは自動的に通常のナビゲーション遷移になり、iPadでは2ペイン表示になります。NavigationSplitView は思った以上に素直に動いてくれました。


App Store 申請で気をつけたこと

  • プライバシーマニフェスト(PrivacyInfo.xcprivacy):AdMob使用時は必須。AppTrackingTransparencyの許可ダイアログも実装すること
  • スクリーンショット:iPhone用(6.9インチ)とiPad用(13インチ)の両方が必要。サイズは 1320×2868px と 2064×2752px
  • サブスクリプションの説明文:審査官向けに「無料トライアルの仕組み」を明記しておくとスムーズ

まとめ

技術 難易度 ハマりやすさ 一言
StoreKit 2 ★★☆ ★★★ バンドルIDの一貫性が命
AdMob ★★☆ ★★☆ plistの設定を忘れずに
Core Data + CloudKit ★★★ ★★★ Capabilitiesを先に確認
NavigationSplitView ★★☆ ★☆☆ 思ったより素直に動く

個人開発は「わからないことがわからない」状態からのスタートで、公式ドキュメントとひたすら格闘する日々でした。同じように社内SEや非エンジニア職種からiOS開発に挑戦している方の参考になれば嬉しいです。

アプリは現在 App Store 審査中です。リリースしたらまた記事を書きます!


参考リンク

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