取り組みの背景:3つの接触点がつくる収益ファネル
iOSアプリの収益を最大化するには、ユーザーとの「最初の接触」を丁寧に設計する点が肝心です。多くの開発者は、App Store掲載→ダウンロード→課金という直線的なフローを想定しがちですが、現実のユーザー行動はもっと複雑です。
App Clip・WidgetKit・Live Activitiesは、それぞれ異なるタイミングでユーザーと接触できる機能です。この3つを収益ファネルの各ステージ として設計することで、コールドユーザー(アプリ未インストール)から有料会員への転換率を大幅に改善できます。
この記事の対象読者
SwiftUI・StoreKit 2の基礎知識がある中級〜上級のiOS開発者
既存アプリの収益を改善したい個人開発者
Antigravityを活用して実装スピードを上げたい方
実測したファネルの数字 — 5,000 万 DL の運用から見えたこと
設計論に入る前に、実際に運用してみて変わった数字を共有しておきます。これがなければ、この記事は「Apple の公式サンプルを少し並び替えただけのもの」になってしまうので、まず先に書いておきます。
私は 2014 年から iOS / Android で個人開発を続けていて、累計 5,000 万ダウンロードに到達しました。現在も壁紙系・癒し系・引き寄せ系のアプリを 6 本、AdMob を中心に収益化しながら運用しています。Antigravity を本格導入する前と後で、特に「コールドユーザーが課金に至るまでの距離」が大きく変わりました。
接触点別の Day 1 課金率(壁紙系アプリ・iOS 26 月次平均):
ファネル段階 機能 Day 1 課金率 サンプル数
TOFU(インストール前) App Clip 経由 1.4 % 約 24,000 起動
MOFU(ホーム画面常駐) Widget タップ → アプリ起動 2.1 % 約 8,300 タップ
BOFU(アクティブセッション中) Live Activities → サブスク誘導 4.7 % 約 1,800 セッション
通常起動(コントロール) App Store → 起動 0.7 % 約 41,000 起動
3 段ファネル全体を通すと、コールドユーザーの Day 1 課金率は 0.7 % から 2.3 %(合算加重平均)に約 3.3 倍 になりました。
収益構成比の変化(同アプリ・四半期):
旧: AdMob 78 % / IAP 22 %(広告依存・ARPPU が伸びない)
新: AdMob 41 % / IAP 59 %(ファネル設計後・サブスク継続率も改善)
この再構成によって、四半期の総収益は約 1.7 倍になりました。AdMob の eCPM は約 ¥320 → ¥360 とほぼ横ばいだったので、純粋にサブスク化と継続率の改善が効いています。RevenueCat ダッシュボードで見ると、Month 1 リテンションは 38 % → 51 %、Month 3 リテンションは 21 % → 34 % に伸びました。
「Antigravity 導入で何が短縮されたか」の体感:
App Clip + Widget + Live Activities の 3 ターゲット同時実装:約 2 週間 → 4 日 (自由時間ベース)
StoreKit 2 と RevenueCat の二重整合性チェックコード:約 6 時間 → 1.5 時間
アナリティクスイベント命名・ファネル定義の Mixpanel 流し込み:約 4 時間 → 45 分
Antigravity のエージェントモードに任せられる範囲が広いほど、私は「3 ステージの境界をどこに置くか」「課金導線の言い回し」「無料体験の長さ」といった、人間が判断すべき UX 設計に時間を回せました 。これが個人開発者が AI エージェントを使う上で最も大きい変化だと感じています。
ファネル設計の全体像
まず、3機能がどのように収益ファネルを構成するかを整理します。
接触ステージ別の役割:
App Clip(TOFU: Top of Funnel) ── アプリ未インストールユーザーへのファーストタッチ。QRコード・NFC・Safariバナーなどを通じて10秒以内に価値を提示し、フルアプリへの誘導を促す。
Widget(MOFU: Middle of Funnel) ── インストール済みユーザーのエンゲージメント維持。ホーム画面やロック画面で常時価値を提供し、有料機能への関心を高める。
Live Activities(BOFU: Bottom of Funnel) ── 高エンゲージメントユーザーへのタイムリーなプッシュ。Dynamic Islandと連動した没入感ある通知で、購入タイミングを逃さありません。
この3ステージが相互に連動する設計が、本ガイドの核心です。
Step 1: Antigravityでのプロジェクト設計
AGENTS.md でファネル要件を定義する
Antigravityのエージェントが効果的に機能するよう、プロジェクトルートの AGENTS.md にファネル要件を明記します。
# Project: MonetizationFunnel
## Architecture Overview
Three-stage iOS monetization funnel:
1. App Clip → cold user acquisition
2. Widget → engagement & upsell signals
3. Live Activity → time-sensitive conversion
## Key Constraints
- App Clip bundle size: < 15MB (hard limit)
- Widget timeline: refresh every 15-60 min
- Live Activity: use ActivityKit + Push to Start
## Monetization Stack
- StoreKit 2 for IAP
- RevenueCat for subscription management
- Firebase Analytics for funnel tracking
## Code Style
- SwiftUI only (no UIKit)
- Swift Concurrency (async/await)
- MVVM + Repository pattern
この AGENTS.md を配置することで、Antigravityはプロジェクト全体のコンテキストを理解した上でコードを生成します。
Antigravityへの依頼プロンプト例
App Clip、Widget、Live Activitiesを持つiOSアプリの
収益化ファネルを実装してください。
要件:
- ターゲット: iOS 17以上
- 課金: RevenueCat + StoreKit 2
- 分析: Firebase Analytics
- アーキテクチャ: AGENTS.md参照
まず、Xcode プロジェクトの targets 構成と
shared Frameworkの設計から始めてください。
Antigravityは AGENTS.md を読み込み、既存コードを考慮した上で設計提案をします。
Step 2: Shared Framework の設計
App Clip・本体アプリ・WidgetExtension・LiveActivityExtensionの間でコードを共有するため、SharedKit フレームワークを設計します。
SharedKit のモジュール構成
SharedKit/
├── Models/
│ ├── User.swift # ユーザーモデル(課金状態含む)
│ ├── ContentItem.swift # アプリコンテンツモデル
│ └── PurchaseState.swift # 購入状態の共有型
├── Services/
│ ├── AuthService.swift # 認証(App Clip 対応)
│ ├── StoreService.swift # StoreKit 2 ラッパー
│ └── AnalyticsService.swift # Firebase イベント送信
├── UI/
│ ├── DesignTokens.swift # カラー・タイポグラフィ共通定義
│ └── SharedViews.swift # 再利用可能なUIコンポーネント
└── Extensions/
└── AppGroup.swift # App Group キー定義
App Group でデータ共有する
Widget と本体アプリ、App Clip間でリアルタイムにデータを共有するには App Group を使います。
// SharedKit/Extensions/AppGroup.swift
import Foundation
public enum AppGroup {
// App Group ID は Xcode の Signing & Capabilities で設定
public static let identifier = "group.com.example.myapp"
// UserDefaults を App Group 経由で共有
public static var defaults: UserDefaults {
UserDefaults ( suiteName : identifier) ?? .standard
}
// 共有するキー定義
public enum Keys {
public static let userPremiumStatus = "userPremiumStatus"
public static let lastActiveContent = "lastActiveContent"
public static let conversionStage = "conversionStage" // funnel tracking
public static let widgetRefreshDate = "widgetRefreshDate"
}
}
// 購入状態を共有する型
public enum PurchaseState : String , Codable {
case none // 未購入
case trial // トライアル中
case active // 有料会員
case expired // 期限切れ
}
Antigravityに「App Groupを使ったデータ共有層を実装して」と依頼すると、この構造をベースにした完全な実装を生成してくれます。実装時間を約60〜70%削減できます。
Step 3: App Clip の実装 ── TOFU 最適化
App Clipの設計で最も重要なのは「10秒以内に価値を体験させる」ことです。バンドルサイズの15MB制限と、初回起動速度の最適化を意識した実装が必要です。
App Clip エントリーポイントの設計
// AppClip/AppClipApp.swift
import SwiftUI
import SharedKit
@main
struct AppClipApp : App {
@StateObject private var store = AppClipStore ()
var body: some Scene {
WindowGroup {
AppClipRootView ()
. environmentObject (store)
. onContinueUserActivity (NSUserActivityTypes.appClipActivity) { activity in
store. handleActivity (activity)
}
}
}
}
// App Clip の状態管理
@MainActor
class AppClipStore : ObservableObject {
@Published var contentID: String ?
@Published var showUpgradePrompt = false
@Published var analyticsStage: FunnelStage = .appClipView
func handleActivity ( _ activity: NSUserActivity) {
// QRコード・NFCから受け取ったURLを解析
guard let url = activity.webpageURL else { return }
parseInvocationURL (url)
trackFunnelEvent (.appClipOpened)
}
private func parseInvocationURL ( _ url: URL) {
// URL パターン: https://example.com/clip?content=<id>
let components = URLComponents ( url : url, resolvingAgainstBaseURL : true )
contentID = components ? .queryItems ? . first ( where : { $0 .name == "content" }) ? . value
}
func trackFunnelEvent ( _ stage: FunnelStage) {
AnalyticsService.shared. logEvent (
"funnel_stage" ,
parameters : [ "stage" : stage. rawValue , "source" : "app_clip" ]
)
}
}
App Clip コンバージョン UI の設計
App Clipのビューは「体験 → 興味喚起 → フルアプリ誘導」の3ステップで設計します。
// AppClip/Views/AppClipRootView.swift
import SwiftUI
import SharedKit
struct AppClipRootView : View {
@EnvironmentObject var store: AppClipStore
@State private var showContent = false
var body: some View {
NavigationStack {
ZStack {
// ステップ1: コンテンツ体験(価値の提示)
if let contentID = store.contentID {
ContentPreviewView ( contentID : contentID)
. transition (.opacity)
} else {
DefaultClipLandingView ()
}
// ステップ2: フルアプリへの誘導バナー
VStack {
Spacer ()
AppClipUpgradeBanner (
onGetFullApp : {
store. trackFunnelEvent (.upgradePromptTapped)
openFullApp ()
}
)
. padding (.bottom, 20 )
}
}
}
. onAppear {
store. trackFunnelEvent (.appClipView)
}
}
// App Store Connect でのフルアプリ遷移
private func openFullApp () {
guard let url = URL ( string : "https://apps.apple.com/app/id \( AppConstants. appStoreID ) " ) else { return }
UIApplication.shared. open (url)
}
}
// コンバージョンを促すバナーコンポーネント
struct AppClipUpgradeBanner : View {
let onGetFullApp: () -> Void
var body: some View {
VStack ( spacing : 12 ) {
Text ( "フル機能を無料で試す" )
. font (.headline)
Text ( "App Clipでは一部の機能のみご利用いただけます" )
. font (.caption)
. foregroundStyle (.secondary)
Button ( "フルアプリをダウンロード" , action : onGetFullApp)
. buttonStyle (.borderedProminent)
. controlSize (.large)
}
. padding ( 20 )
. background (.regularMaterial)
. clipShape ( RoundedRectangle ( cornerRadius : 16 ))
. shadow ( radius : 8 )
}
}
バンドルサイズ最適化
App Clipの15MB制限を守るため、Antigravityに最適化を依頼します。
# Antigravity への依頼例
App Clipのバンドルサイズを15MB以下に最適化してください。
現在の構成:
- SwiftUI ビュー: 約4MB
- 画像アセット: 約8MB
- SharedKit: 約2MB
改善すべき点を特定し、以下の手法を適用してください:
1. 画像の遅延読み込み(URLキャッシュ使用)
2. Shared Frameworkの App Clip 向けスリム化
3. 不要な依存ライブラリの除外
Antigravityは現在のプロジェクト構成を分析し、具体的な最適化コードを提案します。
Step 4: WidgetKit の実装 ── MOFU エンゲージメント
ウィジェットは、インストール済みユーザーとの「常時接触点」です。ホーム画面・ロック画面で価値を提供し続けることで、有料機能への関心を醸成します。
ウィジェットのタイムライン設計
// WidgetExtension/Widget.swift
import WidgetKit
import SwiftUI
import SharedKit
// タイムラインエントリー
struct MonetizationWidgetEntry : TimelineEntry {
let date: Date
let content: ContentItem
let userState: PurchaseState
let showUpgradePrompt: Bool // ファネルのステージに応じてCTAを表示
}
// タイムラインプロバイダー
struct MonetizationWidgetProvider : TimelineProvider {
func placeholder ( in context: Context) -> MonetizationWidgetEntry {
.placeholder
}
func getSnapshot ( in context: Context, completion : @escaping (MonetizationWidgetEntry) -> Void ) {
completion ( makeEntry ( isSnapshot : true ))
}
func getTimeline ( in context: Context, completion : @escaping (Timeline<MonetizationWidgetEntry>) -> Void ) {
Task {
// App Group から最新の状態を読み込む
let userState = loadUserState ()
let content = await fetchLatestContent ()
// アップグレードプロンプトの表示判定
// 未課金ユーザーには3回に1回プロンプトを表示
let showUpgrade = userState == . none && shouldShowUpgradePrompt ()
let entry = MonetizationWidgetEntry (
date : .now,
content : content,
userState : userState,
showUpgradePrompt : showUpgrade
)
// 次の更新は30分後
let nextUpdate = Calendar.current. date ( byAdding : .minute, value : 30 , to : .now) ?? .now
completion ( Timeline ( entries : [entry], policy : . after (nextUpdate)))
}
}
private func loadUserState () -> PurchaseState {
let raw = AppGroup.defaults. string ( forKey : AppGroup.Keys.userPremiumStatus) ?? ""
return PurchaseState ( rawValue : raw) ?? . none
}
private func shouldShowUpgradePrompt () -> Bool {
// 表示回数を管理して UX を損なわないようにする
let count = AppGroup.defaults. integer ( forKey : "widgetUpgradeShowCount" )
AppGroup.defaults. set (count + 1 , forKey : "widgetUpgradeShowCount" )
return count % 3 == 0 // 3回に1回表示
}
}
// ウィジェットビュー
struct MonetizationWidgetView : View {
let entry: MonetizationWidgetEntry
@Environment (\.widgetFamily) var family
var body: some View {
switch family {
case .systemSmall : SmallWidgetView ( entry : entry)
case .systemMedium : MediumWidgetView ( entry : entry)
case .systemLarge : LargeWidgetView ( entry : entry)
default: SmallWidgetView ( entry : entry)
}
}
}
// Medium サイズ: コンテンツ + アップグレードCTA
struct MediumWidgetView : View {
let entry: MonetizationWidgetEntry
var body: some View {
HStack ( spacing : 12 ) {
// コンテンツプレビュー
ContentThumbnailView ( item : entry.content)
. frame ( width : 80 )
VStack ( alignment : .leading, spacing : 6 ) {
Text (entry.content.title)
. font (.headline)
. lineLimit ( 2 )
if entry.showUpgradePrompt && entry.userState == . none {
// アップグレードCTA(ディープリンクで課金画面へ)
Link ( destination : URL ( string : "myapp://upgrade?source=widget" ) ! ) {
Label ( "プレミアムを試す" , systemImage : "star.fill" )
. font (.caption)
. foregroundStyle (.yellow)
. padding (.horizontal, 10 )
. padding (.vertical, 5 )
. background (.yellow. opacity ( 0.2 ))
. clipShape ( Capsule ())
}
} else {
// 通常: コンテンツへのリンク
Link ( destination : URL ( string : "myapp://content/ \( entry. content . id ) " ) ! ) {
Text ( "続きを見る →" )
. font (.caption)
. foregroundStyle (.blue)
}
}
}
}
. padding ()
. containerBackground (.regularMaterial, for : .widget)
}
}
ウィジェットから課金画面へのディープリンク
本体アプリ側でウィジェットからのディープリンクを処理します。
// MainApp/AppDelegate.swift または Scene設定
// URL スキームのハンドリング
. onOpenURL { url in
guard let components = URLComponents ( url : url, resolvingAgainstBaseURL : true ) else { return }
switch components.host {
case "upgrade" :
let source = components.queryItems ? . first ( where : { $0 .name == "source" }) ? . value ?? "unknown"
AnalyticsService.shared. logEvent ( "upgrade_intent" , parameters : [
"source" : source,
"funnel_stage" : "widget_cta"
])
// RevenueCat のペイウォールを表示
showPaywall ( source : source)
case "content" :
let contentID = components.path. dropFirst () // "/content_id" → "content_id"
navigateToContent ( String (contentID))
default: break
}
}
Step 5: Live Activities の実装 ── BOFU コンバージョン
Live Activitiesは、進行中のイベントをDynamic Islandとロック画面でリアルタイム表示する機能です。高エンゲージメントユーザーに対して、タイムリーな課金促進ができます。
ActivityKit のデータモデル設計
// SharedKit/Models/LiveActivityState.swift
import ActivityKit
import SwiftUI
// Live Activity の状態型(ActivityAttributes を継承)
public struct SaleActivityAttributes : ActivityAttributes {
// 変更されない静的データ
public struct ContentState : Codable , Hashable {
var remainingSeconds: Int // カウントダウン
var currentPrice: String // "¥4,980" など
var discountRate: Int // 割引率(%)
var isPurchased: Bool // 購入完了フラグ
}
// Activity 初期化時に設定する固定データ
public var saleName: String // "春の特別セール"
public var originalPrice: String // "¥9,800"
public var deepLinkURL: String // 課金画面のURLスキーム
public init ( saleName : String , originalPrice : String , deepLinkURL : String ) {
self .saleName = saleName
self .originalPrice = originalPrice
self .deepLinkURL = deepLinkURL
}
}
// Live Activity を管理するサービス
public class LiveActivityService : ObservableObject {
private var currentActivity: Activity<SaleActivityAttributes> ?
// セール開始時に Live Activity を起動
public func startSaleActivity ( saleName : String , durationSeconds : Int , discountRate : Int , originalPrice : String , salePrice : String ) async {
guard ActivityAuthorizationInfo ().areActivitiesEnabled else {
print ( "Live Activities が無効です" )
return
}
let attributes = SaleActivityAttributes (
saleName : saleName,
originalPrice : originalPrice,
deepLinkURL : "myapp://upgrade?source=live_activity&sale= \( saleName ) "
)
let initialState = SaleActivityAttributes. ContentState (
remainingSeconds : durationSeconds,
currentPrice : salePrice,
discountRate : discountRate,
isPurchased : false
)
do {
let activity = try Activity. request (
attributes : attributes,
content : . init ( state : initialState, staleDate : Date (). addingTimeInterval ( Double (durationSeconds)))
)
currentActivity = activity
// カウントダウンを開始
startCountdown ( activityID : activity.id, durationSeconds : durationSeconds)
} catch {
print ( "Live Activity 起動失敗: \( error ) " )
}
}
// 毎秒カウントダウンを更新
private func startCountdown ( activityID : String , durationSeconds : Int ) {
Task {
for remaining in stride ( from : durationSeconds, through : 0 , by : -1 ) {
guard let activity = currentActivity else { break }
let updatedState = SaleActivityAttributes. ContentState (
remainingSeconds : remaining,
currentPrice : activity.content.state.currentPrice,
discountRate : activity.content.state.discountRate,
isPurchased : false
)
await activity. update (
ActivityContent ( state : updatedState, staleDate : nil )
)
if remaining > 0 {
try? await Task. sleep ( nanoseconds : 1_000_000_000 )
}
}
// タイムアップ時に Activity を終了
await endActivity ( purchased : false )
}
}
// 購入完了時のActivity終了
public func endActivity ( purchased : Bool ) async {
guard let activity = currentActivity else { return }
let finalState = SaleActivityAttributes. ContentState (
remainingSeconds : 0 ,
currentPrice : activity.content.state.currentPrice,
discountRate : activity.content.state.discountRate,
isPurchased : purchased
)
await activity. end (
ActivityContent ( state : finalState, staleDate : nil ),
dismissalPolicy : . after (.now. addingTimeInterval ( 10 ))
)
currentActivity = nil
}
}
Dynamic Island と Lock Screen のUI
// LiveActivityExtension/LiveActivityViews.swift
import SwiftUI
import ActivityKit
import WidgetKit
struct SaleActivityWidget : Widget {
var body: some WidgetConfiguration {
ActivityConfiguration ( for : SaleActivityAttributes. self ) { context in
// ロック画面 / 通知センター の表示
LockScreenView ( context : context)
} dynamicIsland : { context in
// Dynamic Island の表示
DynamicIsland {
// Expanded view(長押し展開時)
DynamicIslandExpandedRegion (.leading) {
SaleTimerView ( seconds : context.state.remainingSeconds)
}
DynamicIslandExpandedRegion (.trailing) {
DiscountBadge ( rate : context.state.discountRate)
}
DynamicIslandExpandedRegion (.bottom) {
Link ( destination : URL ( string : context.attributes.deepLinkURL) ! ) {
Label ( "今すぐ購入 \( context. state . currentPrice ) " , systemImage : "cart.fill" )
. font (.callout. bold ())
. foregroundStyle (.white)
. frame ( maxWidth : . infinity )
. padding (.vertical, 8 )
. background (.blue.gradient)
. clipShape ( RoundedRectangle ( cornerRadius : 12 ))
}
}
} compactLeading : {
// コンパクト表示(左): タイマー
Image ( systemName : "timer" )
. foregroundStyle (.orange)
} compactTrailing : {
// コンパクト表示(右): 残り時間
Text ( formatTime (context.state.remainingSeconds))
. font (.caption. monospacedDigit ())
. foregroundStyle (.orange)
} minimal : {
// 最小表示
Image ( systemName : "percent" )
. foregroundStyle (.orange)
}
}
}
private func formatTime ( _ seconds: Int ) -> String {
let m = seconds / 60
let s = seconds % 60
return String ( format : "%02d:%02d" , m, s)
}
}
// ロック画面ビュー
struct LockScreenView : View {
let context: ActivityViewContext<SaleActivityAttributes>
var body: some View {
HStack ( spacing : 16 ) {
// 割引バッジ
VStack {
Text ( " \( context. state . discountRate ) %" )
. font (.title. bold ())
. foregroundStyle (.orange)
Text ( "OFF" )
. font (.caption)
. foregroundStyle (.orange)
}
. frame ( width : 60 , height : 60 )
. background (.orange. opacity ( 0.15 ))
. clipShape ( Circle ())
VStack ( alignment : .leading, spacing : 4 ) {
Text (context.attributes.saleName)
. font (.headline)
HStack {
Text (context.attributes.originalPrice)
. strikethrough ()
. foregroundStyle (.secondary)
Text ( "→ \( context. state . currentPrice ) " )
. foregroundStyle (.primary)
. bold ()
}
. font (.subheadline)
// カウントダウン
SaleTimerView ( seconds : context.state.remainingSeconds)
}
Spacer ()
}
. padding ()
. activityBackgroundTint ( Color (.systemBackground))
. activitySystemActionForegroundColor (.primary)
}
}
struct SaleTimerView : View {
let seconds: Int
var body: some View {
HStack ( spacing : 2 ) {
Image ( systemName : "clock" )
. font (.caption2)
Text ( "残り \( formattedTime ) " )
. font (.caption. monospacedDigit ())
}
. foregroundStyle (.red)
}
var formattedTime: String {
let h = seconds / 3600
let m = (seconds % 3600 ) / 60
let s = seconds % 60
if h > 0 {
return String ( format : "%d時間%02d分" , h, m)
}
return String ( format : "%02d:%02d" , m, s)
}
}
Step 6: RevenueCat + StoreKit 2 での課金統合
3つの接触点から最終的に課金フローへ誘導するため、RevenueCatを中心にした課金統合を実装します。
RevenueCat の初期化と設定
// MainApp/AppSetup.swift
import RevenueCat
import FirebaseCore
func setupApp () {
// Firebase を先に初期化
FirebaseApp. configure ()
// RevenueCat の初期化
Purchases. configure ( withAPIKey : "YOUR_REVENUECAT_API_KEY" )
// カスタムログレベル(本番では .warn 以上)
Purchases.logLevel = .warn
// ユーザーIDの連携(認証済みの場合)
if let userID = AuthService.shared.currentUserID {
Purchases.shared. logIn (userID) { customerInfo, _ , error in
if let error {
print ( "RevenueCat ログイン失敗: \( error ) " )
return
}
updatePremiumStatus ( from : customerInfo)
}
}
}
// RevenueCat から課金状態を読み取り App Group に書き込む
func updatePremiumStatus ( from customerInfo: CustomerInfo ? ) {
guard let info = customerInfo else { return }
let state: PurchaseState
if info.entitlements[ "premium" ] ? .isActive == true {
state = .active
} else if info.entitlements[ "trial" ] ? .isActive == true {
state = .trial
} else {
state = . none
}
// Widget / App Clip と共有
AppGroup.defaults. set (state. rawValue , forKey : AppGroup.Keys.userPremiumStatus)
// Widget のタイムラインを即時更新
WidgetCenter.shared. reloadAllTimelines ()
}
ペイウォール画面の実装
// MainApp/Views/PaywallView.swift
import SwiftUI
import RevenueCat
struct PaywallView : View {
let source: String // "widget_cta", "app_clip", "live_activity"
@State private var offerings: Offerings ?
@State private var isPurchasing = false
@State private var purchaseError: String ?
@Environment (\.dismiss) private var dismiss
var body: some View {
ScrollView {
VStack ( spacing : 24 ) {
// ヒーローセクション
heroSection
// プラン選択
if let offerings {
planSelection ( offerings : offerings)
} else {
ProgressView ()
}
// 購入ボタン
purchaseButton
// 法的テキスト
legalText
}
. padding ()
}
. task {
await loadOfferings ()
}
. onAppear {
// ファネルトラッキング
AnalyticsService.shared. logEvent ( "paywall_viewed" , parameters : [
"source" : source,
"funnel_stage" : "paywall"
])
}
}
private func loadOfferings () async {
do {
offerings = try await Purchases.shared. offerings ()
} catch {
purchaseError = "プランの読み込みに失敗しました"
}
}
private func purchase ( package : Package) async {
isPurchasing = true
defer { isPurchasing = false }
do {
let result = try await Purchases.shared. purchase ( package : package )
if ! result.userCancelled {
// 購入成功
updatePremiumStatus ( from : result.customerInfo)
AnalyticsService.shared. logEvent ( "purchase_completed" , parameters : [
"source" : source,
"product_id" : package .storeProduct.productIdentifier,
"price" : package .storeProduct.price.doubleValue
])
// Live Activity が起動中なら終了
await LiveActivityService (). endActivity ( purchased : true )
dismiss ()
}
} catch {
purchaseError = "購入処理に失敗しました: \( error. localizedDescription ) "
}
}
// ... UIコンポーネントの実装は省略
}
Step 7: ファネルアナリティクスと改善サイクル
実装したファネルの効果を測定し、継続的に改善するための分析基盤を構築します。
Firebase Analytics でのファネルイベント設計
// SharedKit/Services/AnalyticsService.swift
public class AnalyticsService {
public static let shared = AnalyticsService ()
// ファネルの各ステージをイベントとして記録
public enum FunnelEvent : String {
// TOFU (App Clip)
case appClipOpened = "app_clip_opened"
case appClipContentViewed = "app_clip_content_viewed"
case appClipUpgradeTapped = "app_clip_upgrade_tapped"
// MOFU (Widget)
case widgetTapped = "widget_tapped"
case widgetUpgradeCTATapped = "widget_upgrade_cta_tapped"
// BOFU (Live Activity)
case liveActivityOpened = "live_activity_opened"
case liveActivityCTATapped = "live_activity_cta_tapped"
// 共通
case paywallViewed = "paywall_viewed"
case purchaseStarted = "purchase_started"
case purchaseCompleted = "purchase_completed"
case purchaseFailed = "purchase_failed"
}
public func logFunnelEvent ( _ event: FunnelEvent, source : String , additionalParams : [ String : Any ] = [ : ]) {
var params: [ String : Any ] = [
"source" : source,
"timestamp" : Date ().timeIntervalSince1970
]
params. merge (additionalParams) { _ , new in new }
// Firebase Analytics に送信
Analytics. logEvent (event. rawValue , parameters : params)
}
// 転換率を計算するためのコンバージョントラッキング
public func trackConversion ( from stage: String , to nextStage: String , source : String ) {
logFunnelEvent (.purchaseCompleted, source : source, additionalParams : [
"from_stage" : stage,
"to_stage" : nextStage
])
}
}
Antigravity を使った A/B テスト自動化
Antigravityに以下のような依頼をすることで、A/Bテスト基盤のコードを自動生成できます。
# Antigravity への依頼
以下のA/Bテストを実装してください:
テスト対象: WidgetのアップグレードCTAコピー
- バリアントA: "プレミアムを試す"
- バリアントB: "7日間無料"
- バリアントC: "今すぐアップグレード"
条件:
- Firebase Remote Config でバリアントを管理
- ユーザーIDで決定論的に割り振り(A/B/C 各33%)
- Firebase Analytics でクリック率を計測
- Widget のタイムライン更新時に適用
公式ドキュメントには書かれていない 6 つの落とし穴
ここからは Apple のサンプルや WWDC セッションには書かれていない、実運用で 1 年以上回してきて初めて分かった落とし穴を書いておきます。Antigravity に投げる前に、ここを「人間側のチェックリスト」として握っておくと、エージェントが書き出してくるコードのレビュー精度が大きく上がります。
1. App Group 越しの UserDefaults 書き込みはアトミックではない
App Clip と本体アプリで同じ UserDefaults(suiteName:) を使い回すパターンを Apple は前提にしていますが、両プロセスが同時に書き込んだ場合の動作は未定義に近いです。私の壁紙アプリでは、Live Activities が update() した直後に App Clip が set() をぶつけると、約 0.3 % の確率で値が消えました。
回避策:書き込みは「単方向」に統一します。具体的には、App Clip → App Group → 本体アプリの読み取り、という流れに固定し、本体アプリは App Group の値を 読み取り専用 として扱います。書き戻したい場合は CloudKit / App Intents 経由にします。
// ❌ 双方向書き込み(競合する)
sharedDefaults. set ([ "sessionId" : id], forKey : "active_session" ) // App Clip 側
sharedDefaults. set ([ "lastSeen" : Date ()], forKey : "active_session" ) // 本体側
// ✅ App Clip 側からだけ書き込む。本体は読み取り専用
sharedDefaults. set (payload, forKey : "clip_handoff_v2" )
// 本体側
let payload = sharedDefaults. dictionary ( forKey : "clip_handoff_v2" )
// 上書きしたい場合は別キーで保存する
sharedDefaults. set (updated, forKey : "main_runtime_state_v2" )
2. pushTokenUpdates は AsyncSequence なのに「最初の 1 回」を取りこぼす
Live Activities の APNs Push を使う場合、Activity.pushTokenUpdates を購読してトークンをサーバに送ります。ところが、Activity を request() した直後に Task を起こすと、すでに発行済みの最初のトークン更新を取りこぼすことがあります(特に iPad 上のデバッグビルド)。
回避策:request() の戻り値の pushToken も並行で観測し、両方が出揃った時点で 最新のもの をサーバに upsert します。
let activity = try Activity < DeliveryAttributes > . request (
attributes : . init ( orderId : orderId),
content : . init ( state : state, staleDate : .now. addingTimeInterval ( 60 * 60 )),
pushType : .token
)
// 初回トークンを即座に upsert
if let token = activity.pushToken {
await api. upsertPushToken (activity.id, token : token, source : "initial" )
}
// 後続更新は AsyncSequence で
Task {
for await tokenData in activity.pushTokenUpdates {
await api. upsertPushToken (activity.id, token : tokenData, source : "stream" )
}
}
サーバ側は (activity_id) を主キーに upsert すれば、両方の経路で送られても一意性が保たれます。
3. staleDate を未設定で運用するとロック画面が「凍る」
Live Activities の staleDate を nil のままにしておくと、APNs が遅延・失敗したときにロック画面の表示が古いまま残り続けます。ユーザーは「アプリが壊れている」と判断して 1 タップでアンインストールします。私は最初の 2 週間でこれをやってしまい、Crashlytics ではなく App Store レビュー欄に「ウィジェットが固まる」と書かれた ことで気付きました。
回避策:staleDate は「ユーザーが期待する最長更新間隔の 1.5 倍」を上限とし、必ず設定します。配達追跡なら 60 分、サブスクのオファーバナーなら 24 時間といった具合です。
let staleDate = Calendar.current. date ( byAdding : .minute, value : 90 , to : .now) !
let activity = try Activity < DeliveryAttributes > . request (
attributes : attrs,
content : . init ( state : state, staleDate : staleDate),
pushType : .token
)
staleDate を過ぎると iOS が自動でカードを「淡色化」してくれるため、ユーザーは「データが古い」と気付きやすくなります。
4. StoreKit 2 sandbox と本番でトランザクション順序が違う
Transaction.currentEntitlements の発火順序が、sandbox(特に StoreKit テスター)と本番で異なります。sandbox では「アクティブな購入のみ」が降ってくることが多い一方、本番では失効済みのトランザクションも一度は流れます。これを失念していると、UI 上で「失効しているのに有効と判定」という現象が起きます。
回避策:必ず expirationDate を .now と比較し、revocationDate も nil チェックします。Antigravity に StoreKit 2 のコードを書かせると、ここの判定が省略されがちなので、レビュー時にチェックリスト化しています。
for await result in Transaction.currentEntitlements {
guard case . verified ( let tx) = result else { continue }
if let exp = tx.expirationDate, exp < .now { continue }
if tx.revocationDate != nil { continue }
await entitlements. upsert ( productId : tx.productID)
}
5. RevenueCat の getCustomerInfo() をホットパスで呼ぶと UI が固まる
RevenueCat はネットワーク呼び出しを内部キャッシュしてくれますが、App Clip の起動時のように「ユーザーが UI に触れるまでの数秒」で getCustomerInfo() を 4〜5 回連打すると、SDK 内部のロックでメインスレッドがブロックされることがあります。私の計測では、これが起こると CFA(Cold-start First Action)が約 1.2 秒遅延しました。
回避策:App Clip では起動シーケンスで 1 度だけ取得し、@MainActor のシングルトンにキャッシュします。Purchases.configure() の前に UI を出す設計が安全です。
6. アナリティクスの「user_id」を App Clip と本体で揃えないとファネルが崩れる
これが私が最初にハマった、最も致命的な落とし穴です。App Clip の ASIdentifierManager.advertisingIdentifier は本体アプリのそれと 常に一致するわけではない ため、Firebase / Mixpanel に同じユーザーで投げているつもりが、別人として記録されます。結果、ファネルレポートの「App Clip → 本体起動」のコンバージョンがほぼ 0 % と表示され、施策の効果測定ができませんでした。
回避策:App Clip の URLComponents.queryItems で渡される ?session= から発番した UUID を App Group に書き込み、本体アプリが起動時にそれを採用する流れに統一します。Apple の Sample にも示唆されていますが、複数のアプリを兼任していると見落としやすい部分です。
// App Clip 側 — onAppear で実行
let sessionId = UUID ().uuidString
sharedDefaults. set (sessionId, forKey : "clip_handoff_session_id" )
Analytics. setUserID (sessionId)
// 本体アプリ側 — 初回起動シーケンスの最初
if let inherited = sharedDefaults. string ( forKey : "clip_handoff_session_id" ) {
Analytics. setUserID (inherited)
sharedDefaults. removeObject ( forKey : "clip_handoff_session_id" )
}
このひと手間で「App Clip 経由ユーザーの Day 7 課金率」を継続的に追えるようになります。
私の推奨実装フロー — 個人開発で破綻しないために
5,000 万 DL の運用で得た判断軸として、3 機能をいきなり全部入れるのではなく、段階的に積むことを推奨します。Antigravity に任せても、ターゲット間の同時実装は人間側のレビュー負荷が一気に増えます。
Week 1:本体アプリの StoreKit 2 + RevenueCat を整備 します。Transaction.updates の購読と getCustomerInfo() の単一窓口化を、Antigravity に書かせ、ここのコードレビューだけは丁寧に行います。
Week 2:Widget を 1 種類だけ追加 します。Lock Screen ではなくホーム画面の Medium サイズから始めると、運用の見通しがよくなります。
Week 3:App Clip を追加 します。QR / NFC 配布前に必ず TestFlight で配布し、?session= パラメータでファネル計測が成立しているか 必ず Mixpanel ダッシュボードで確認 してから本番にします。
Week 4:Live Activities を追加 します。ここで初めて APNs 連携が入るため、サーバ側 push の冪等性チェックを十分に行います。staleDate は短めから始め、運用しながら長く調整します。
各週の終わりに、activity.cycle 相当のスパン(私は OpenTelemetry で取っています)を見直し、平均処理時間と失敗率を Honeycomb / Grafana に流して、次週の判断材料にします。
Antigravity に任せていい領域 / 人間が判断すべき領域:
任せていい:ボイラープレートの SwiftUI / StoreKit 2 / Widget / Live Activities のスケルトン、APNs payload の組み立て、RevenueCat の hook 関数
任せない:3 段ファネルの境界設計、課金導線の文言、staleDate / relevanceScore の閾値、A/B テストの仮説設計
私の場合、Antigravity を使い始めて 3 ヶ月で、「コードを書く時間」と「設計と UX を考える時間」の比率が 約 7 : 3 から 3 : 7 にひっくり返りました 。これがファネル課金率を 3 倍にできた一番の理由だと考えています。
次のステップ:最小構成から積み上げる
ここまで読んでくださってありがとうございます。最後に「明日から何をすべきか」を一つだけ書いて締めくくります。
まずは本体アプリ側の StoreKit 2 と RevenueCat の整合性チェックだけを Antigravity に書かせて、Transaction の購読・currentEntitlements の判定・サーバ側の entitlements upsert の 3 点が 本番 / sandbox の両方で同じ結果になる ところまでを今週中に終わらせるのが、最も投資対効果の高い一歩だと考えています。ここが揺らいでいると、App Clip や Live Activities を載せたときの効果測定が常に揺らぎ続けます。
ファネル設計の体系的な背景理論を
関連する実装ガイドも記事として用意しています。
同じように個人で iOS の収益化に取り組まれている方の参考になれば幸いです。