ある週の月曜の朝、レビュー欄を開いて手が止まりました。「サブスクに入ったのに広告が出る」という星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万円のアプリを作る — 価格とリテンション実装 もあわせてご覧ください。
作ることが軽くなったぶん、判断を置く場所を丁寧に選ぶことが、いまの私の仕事の中心になりつつあります。