◉RORK LABEN
●GPT-6.1 SOL — Rork のモデルメニューに追加(09-29)。Low/Medium/High を選べ、Pro・Max に含まれます●SONNET 5.5 — UI づくりが得意で、Sonnet 5 より出力が速いモデルが選べます(09-28)●SDK 58 — Expo SDK 58 が 10/15 に正式リリースされました(Beta は 09/15 から)。上げる前に依存の対応状況を確認しましょう●WIDGET — iOS のウィジェットが本番ビルドだけ真っ黒になる原因を追った報告が出ています●OBSERVE — EAS Observe がネイティブクラッシュも記録します(SDK 57.0.21 以降・10/07)●NEW — 起動広告が、背面から戻った回にしか出ていませんでした。「前面に戻った」合図が、…●GPT-6.1 SOL — Rork のモデルメニューに追加(09-29)。Low/Medium/High を選べ、Pro・Max に含まれます●SONNET 5.5 — UI づくりが得意で、Sonnet 5 より出力が速いモデルが選べます(09-28)●SDK 58 — Expo SDK 58 が 10/15 に正式リリースされました(Beta は 09/15 から)。上げる前に依存の対応状況を確認しましょう●WIDGET — iOS のウィジェットが本番ビルドだけ真っ黒になる原因を追った報告が出ています●OBSERVE — EAS Observe がネイティブクラッシュも記録します(SDK 57.0.21 以降・10/07)●NEW — 起動広告が、背面から戻った回にしか出ていませんでした。「前面に戻った」合図が、…
記事一覧/ビジネス
⟐ ビジネス/2026-04-26上級

Rork Max のネイティブアプリを App Store 上位へ — 設計から AdMob・StoreKit 2・サブスクまでの順番

Rork Max で作った SwiftUI ネイティブアプリを、カテゴリ上位に残るアプリへ育てるための順番を書き残します。広告を出すかどうかを1か所で決める AdGate、StoreKit 2 の Transaction.updates 監視、ASO とリテンションの線引きまで、動くコード付きです。

Rork585Rork Max236App Store91AdMob73IAP6サブスクASO27個人開発218収益化68

ある週の月曜の朝、レビュー欄を開いて手が止まりました。「サブスクに入ったのに広告が出る」という星1つが、二件並んでいたのです。

原因はすぐに分かりました。広告を出すかどうかの判定が、バナーの画面とインタースティシャルの画面で別々に書かれていて、片方だけがサブスクの状態を見ていなかったのです。コードとしては動いていましたし、テストでも気づけませんでした。

Rork Max で SwiftUI のネイティブアプリを生成できるようになって、「作る」ところは驚くほど軽くなりました。一方で、App Store のカテゴリで上位に残り、毎月の収益になるところまでには、まだ段差があります。ここでは、その段差を設計・実装・運用の順に分けて、私が個人開発でいま採っている順番をお伝えします。

実装の前に書き出す3行

カテゴリ上位に長く残るアプリを眺めていると、多くのアプリに三つの共通点が見えてきます。起動してすぐ価値が分かる体験、毎日または毎週開く理由、そしてストアの第一印象で用途が一目で伝わること——この三つです。

最初のうち、私はこれを考えずに機能から作り始めていました。機能は揃うのに、開かれ続けないアプリがいくつもでき上がりました。いまは Rork Max にコード生成を頼む前に、次の3行を必ず書き出しています。

  • コア体験:起動から数分でユーザーが感じる「便利」の1文
  • 再訪トリガー:毎日・毎週開きたくなる理由の1つ(通知・記録の蓄積・新着)
  • マネタイズの位置:広告がどこに入り、どこで買い切りやサブスクを提案するか

Rork Max は1と2を実装の速さで形にしてくれます。3つ目のストアの第一印象はテキストと画像の問題なので、ここに最初の時間を投じておくと後が楽になるのです。

収益化コードを1か所に集めるプロジェクト構造

Rork Max が生成したコードを、私は次のかたちに組み替えてから育てています。

MyApp/
├── App/
│   └── MyAppApp.swift
├── Features/
│   ├── Home/
│   ├── Detail/
│   └── Settings/
├── Monetization/
│   ├── AdGate.swift          // 広告を出してよいかの唯一の判定
│   ├── AdManager.swift
│   ├── EntitlementStore.swift // 買い切り・サブスクの権利
│   └── Paywall/
└── Resources/
    └── Localizable.xcstrings

冒頭のレビューの件は、広告・買い切り・サブスクの判定が画面ごとに散らばっていたことが原因でした。Monetization/ を最初から切り出したうえで、広告を出すかどうかの判断は、アプリの中で1か所にしか書きません。この線引きにしてから、同じ種類の星1つは届いていません。

権利の状態を StoreKit 2 で1つにまとめる

広告の判定より先に、「このユーザーは広告なしの権利を持っているか」を確かめる仕組みが要ります。買い切りの「広告を消す」とサブスクの両方を、1つのストアで扱います。

// EntitlementStore.swift — 買い切りとサブスクの権利を一元管理(StoreKit 2)
import StoreKit
 
@MainActor
final class EntitlementStore: ObservableObject {
    static let shared = EntitlementStore()
 
    @Published private(set) var hasRemoveAds = false
    @Published private(set) var isSubscribed = false
    @Published private(set) var products: [Product] = []
 
    private let removeAdsID = "com.example.myapp.removeads"
    private let subscriptionIDs: Set<String> = [
        "com.example.myapp.monthly",
        "com.example.myapp.yearly"
    ]
    private var updatesTask: Task<Void, Never>?
 
    /// 広告を出さなくてよい状態か(AdGate が参照するのはこれだけ)
    var isAdFree: Bool { hasRemoveAds || isSubscribed }
 
    init() {
        // 端末外で起きた更新(更新・返金・ファミリー共有・承認待ちの完了)を拾う
        updatesTask = Task { [weak self] in
            for await result in Transaction.updates {
                if case .verified(let transaction) = result {
                    await transaction.finish()
                }
                await self?.refresh()
            }
        }
    }
 
    deinit { updatesTask?.cancel() }
 
    func loadProducts() async {
        do {
            products = try await Product.products(for: [removeAdsID] + Array(subscriptionIDs))
        } catch {
            print("loadProducts failed: \(error)")
        }
        await refresh()
    }
 
    /// 起動時とフォアグラウンド復帰時に呼ぶ
    func refresh() async {
        var removeAds = false
        var subscribed = false
        for await result in Transaction.currentEntitlements {
            guard case .verified(let transaction) = result,
                  transaction.revocationDate == nil else { continue }
            if transaction.productID == removeAdsID { removeAds = true }
            if subscriptionIDs.contains(transaction.productID) { subscribed = true }
        }
        hasRemoveAds = removeAds
        isSubscribed = subscribed
    }
 
    func purchase(_ product: Product) async -> Bool {
        do {
            guard case .success(let verification) = try await product.purchase(),
                  case .verified(let transaction) = verification else { return false }
            await transaction.finish()
            await refresh()
            return true
        } catch {
            print("purchase failed: \(error)")
            return false
        }
    }
}

以前の私の実装には、Transaction.updates の監視がありませんでした。購入画面を通らない変化——サブスクの自動更新、返金、保護者の承認待ちが後から通った購入——は、この監視がないと次に購入画面を開くまで反映されません。

もう1つ、クラスに @MainActor を付けています。@Published の値をバックグラウンドから書き換えると、SwiftUI の更新が不安定になるためです。小さな一行ですが、権利の表示が一瞬だけ古いまま残る症状は、これで止まりました。

広告を出すかどうかは AdGate だけが決める

ここが、冒頭の失敗から作り直した部分です。権利の確認に加えて、「作業を1つ終えた直後か」「前回からどれだけ時間が空いたか」も同じ場所で判断します。

// AdGate.swift — インタースティシャルを出してよいかの唯一の判定
import Foundation
 
@MainActor
final class AdGate {
    static let shared = AdGate()
 
    private let entitlements = EntitlementStore.shared
    private let minInterval: TimeInterval = 180    // 前回表示からの最短間隔(秒)
    private let actionsPerAd = 3                   // 何回の「完了」ごとに1回まで
    private let graceLaunches = 2                  // 初回起動から数回は出さない
 
    private var completedActions = 0
    private var lastShownAt: Date?
 
    private var launchCount: Int {
        UserDefaults.standard.integer(forKey: "launchCount")
    }
 
    /// 保存・読了など「ひと区切り」の直後に呼ぶ
    func shouldShowInterstitial(now: Date = .now) -> Bool {
        if entitlements.isAdFree { return false }        // 権利の確認は必ず最初
        if launchCount <= graceLaunches { return false }
 
        completedActions += 1
        guard completedActions >= actionsPerAd else { return false }
        if let last = lastShownAt, now.timeIntervalSince(last) < minInterval { return false }
 
        completedActions = 0
        lastShownAt = now
        return true
    }
 
    /// バナーも同じ判定を通す
    var shouldShowBanner: Bool { !entitlements.isAdFree }
}

数値の 180 や 3 は、私がいま使っている出発点にすぎません。アプリの性格で大きく変わりますので、ご自身の計測で動かしていただければと思います。大事なのは値そのものより、判定が1か所にしかないことのほうです。

AdMob の SwiftUI 実装は AdGate の後ろに置く

AdMob 側は、読み込みと表示だけを担当させます。以下は Google Mobile Ads SDK 12 以降の Swift 名(GAD 接頭辞なし)で書いています。11 以前をお使いの場合は GADInterstitialAd などの旧名に読み替えてください。

// AdManager.swift — 読み込みと表示だけを持つ(判定は AdGate)
import GoogleMobileAds
import SwiftUI
 
@MainActor
final class AdManager: NSObject, ObservableObject, FullScreenContentDelegate {
    static let shared = AdManager()
    @Published private(set) var isInterstitialReady = false
    private var interstitial: InterstitialAd?
    private let unitID = "ca-app-pub-XXXXXXXXXXXXXXXX/YYYYYYYYYY"   // 本番IDに置き換え
 
    func start() {
        MobileAds.shared.start()
        load()
    }
 
    private func load() {
        InterstitialAd.load(with: unitID, request: Request()) { [weak self] ad, error in
            Task { @MainActor in
                guard let self else { return }
                if let error {
                    print("interstitial load failed: \(error.localizedDescription)")
                    self.isInterstitialReady = false
                    return
                }
                self.interstitial = ad
                self.interstitial?.fullScreenContentDelegate = self
                self.isInterstitialReady = true
            }
        }
    }
 
    /// 呼び出し側は必ず AdGate を通してから呼ぶ
    func presentIfAllowed(from root: UIViewController) {
        guard AdGate.shared.shouldShowInterstitial(),
              let ad = interstitial else { return }
        ad.present(from: root)
    }
 
    // 閉じられたら次を先読み
    nonisolated func adDidDismissFullScreenContent(_ ad: FullScreenPresentingAd) {
        Task { @MainActor in
            self.isInterstitialReady = false
            self.load()
        }
    }
}
 
struct BannerSlot: View {
    let unitID: String
    @ObservedObject private var entitlements = EntitlementStore.shared
 
    var body: some View {
        if AdGate.shared.shouldShowBanner {
            BannerAdView(unitID: unitID)
                .frame(height: 50)
        }
    }
}
 
struct BannerAdView: UIViewRepresentable {
    let unitID: String
 
    func makeUIView(context: Context) -> BannerView {
        let banner = BannerView(adSize: AdSizeBanner)
        banner.adUnitID = unitID
        banner.rootViewController = UIApplication.shared.connectedScenes
            .compactMap { ($0 as? UIWindowScene)?.keyWindow?.rootViewController }
            .first
        banner.load(Request())
        return banner
    }
 
    func updateUIView(_ uiView: BannerView, context: Context) {}
}

以前は、表示した直後に次の広告を読み込んでいました。いまは adDidDismissFullScreenContent で閉じられてから読み込む形にしています。表示中に次の読み込みが走ると、まれに同じ広告オブジェクトを二度扱う状態が生まれたためです。

BannerSlot が EntitlementStore を観察しているので、購入が完了した瞬間にバナーの枠ごと消えます。購入したのに画面が変わらない数秒間は、ユーザーにとって思ったより不安なものなのかもしれません。

サブスクは「無料で十分使える」を前提にする

サブスクに入らないと使えない設計は、ストアの低評価に直結しやすいと感じています。私は、無料のままで主要な使い方が完結し、サブスクは「もっと深く使いたい人」のための上乗せ、という線を守っています。

サブスクに置く候補として、私が使ってきたのは次のようなものです。

  • 広告の非表示(買い切りと併存させます)
  • 記録の保存期間の延長
  • テーマや上級フィルター
  • 新機能の先行利用

買い切りの「広告を消す」とサブスクを両方置く場合、isAdFree が両方を見ていることが前提になります。前のセクションの EntitlementStore は、そのためにあります。

ASO と、最初の数十件の評価

App Store 検索での見つかりやすさは、タイトル・サブタイトル・キーワードフィールドの影響が大きい領域です。私がいま使っている書き方の目安をお伝えします。

項目上限私の書き方
タイトル30文字アプリ名+何ができるかを表す語
サブタイトル30文字誰の・どの作業を・どう変えるか
キーワード100文字タイトルと重複しない複合語をカンマ区切りで

キーワードは、大きな語ばかりを狙うと競合に埋もれます。Apple Search Ads の検索人気度が中程度で、上位のアプリがまだ少ない複合語を数個拾うほうが、個人開発のアプリには現実的でした。

最初の評価を集める動きとしては、次の三つを組み合わせています。

  • note や X での開発記録の投稿(機能ではなく、判断と試行錯誤を書きます)
  • 完成度が7割ほどの段階でのコミュニティへの限定公開
  • 知人への直接の案内と、SKStoreReviewController の控えめな呼び出し

評価依頼は、AdGate と同じく「ひと区切りの直後」に限っています。広告と評価依頼が同じ瞬間に重なると、どちらの印象も悪くなるからです。

リテンションの目安と、通知の文面

リテンションは、私が自分のアプリで目安にしている線として、Day 1 で4割、Day 7 で2割、Day 30 で1割あたりを見ています。業界の基準ではなく、下回ったときに「再訪トリガーを見直す合図」として使っている数字です。

リテンションに効いたのは、通知と進捗の見える化でした。通知は UNUserNotificationCenter で SwiftUI からも組めます。文面は「明日も開きませんか?」のような汎用文より、そのユーザーの記録に紐づいた一文のほうが、開かれる手応えがありました。

よくある落とし穴

最後に、私自身がつまずいた点を三つ挙げます。

1. リリース直後に広告フォーマットを増やしすぎること 最初の数週間はバナーだけで運用し、評価が落ち着いてからインタースティシャルを足すほうが安全でした。AdGate の graceLaunches を大きめにしておくのも一つの方法です。

2. 広告の判定を画面ごとに書くこと 冒頭の失敗がこれです。判定が2か所に分かれた時点で、どちらかが権利を見落とします。

3. Transaction.updates を監視しないこと 購入画面を通らない権利の変化が、次の起動まで反映されません。サブスクの更新や返金は、アプリの外で起きるものだと考えておくのが安全です。

次の30分でやること

まずは、ご自身のアプリの中で広告を出している箇所を grep で洗い出してみてください。present( と BannerView の呼び出しが、それぞれいくつの場所にあるかを数えるだけで十分です。2か所以上あれば、そこが AdGate に寄せる最初の候補になります。

料金面の判断を先に整理したい場合は、Rork Max の料金で SwiftUI ネイティブアプリを収益化する3つの判断 が前提の整理に役立ちます。サブスクの価格とリテンションの実装をさらに詰めたい場合は、Rork で月10万円のアプリを作る — 価格とリテンション実装 もあわせてご覧ください。

作ることが軽くなったぶん、判断を置く場所を丁寧に選ぶことが、いまの私の仕事の中心になりつつあります。

シェア

お読みいただきありがとうございます

Rork Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • ✦コピー&ペーストで使える実装コード付き
  • ✦毎日新しい上級ガイドを追加
  • ✦¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

⟐ ビジネス2026-05-02
個人開発で月10万円を超えるアプリ収益化の実践戦略 — Rork で作って試した収益チューニングの全工程
個人開発のアプリで月10万円の壁を超えるための実践戦略。Rork で作ったアプリで実際に試した広告収益最大化、IAP 価格チューニング、サブスク継続率向上、ASO 強化までの全工程を、12 年間の収益化試行錯誤に基づく数値とコードで解説します。
⟐ ビジネス2026-05-15
運用中の壁紙アプリをRork Maxで作り直した記録:工程別の実作業時間と再リリース後の指標
運用中の壁紙アプリをRork Maxでゼロから再実装し、工程ごとに実作業時間を計測しました。生成コードが取り違えた仕様、既存サブスクリプションの引き継ぎ手順、再リリース後30日のクラッシュ率と転換率まで数字で記録しています。
⟐ ビジネス2026-05-03
Rork で作ったアプリを App Store でヒットさせる — ASO・価格・課金設計の実践ガイド
Rork で開発したアプリを App Store で見つけてもらい、適切な価格で売り、継続課金にも成功するための実践ガイドです。ASO戦略・価格心理学・IAP設計・サブスクリプション運用を、個人開発の現場で実際に使っている手順として解説します。