0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SwiftUI × AdMob】リワード広告で「1回限りプレミアム機能解放」を実装した話(Google Mobile Ads SDK 12.0+)

0
Posted at

自己紹介

株式会社Good Labでエンジニアをしている コータロー です。
日々、Java・SQL・Gitなどの技術情報や、新人エンジニア向けの学習ノウハウ、
AI活用についての情報を発信しています。

Good Labについて気になった方は、コーポレートサイトもぜひご覧ください。
コーポレートサイト

はじめに

個人開発している筋トレ記録アプリ WorkoutDiary で、AdMob のリワード広告を使った「1回視聴 = 1回だけプレミアム機能を解放」というUXを実装しました。

サブスク非加入のユーザーでも、広告を見ることで一時的にプレミアム機能(インスタグラムストーリー用フォーマット、限定シェアカード等)を試せる仕組みです。シェアが完了したらまた再ロックされるため、広告の視聴回数とプレミアム機能の利用回数を1:1で対応させています。

この記事では以下を共有します。

  • Google Mobile Ads SDK 12.0+ の新APIに対応した SwiftUI 実装(旧API名から大きく変更されています)
  • RewardedAd と StoreKit 2(StoreManager)を組み合わせた「プレミアムユーザーには広告を出さない」実装
  • 「1シェア = 1広告視聴」を強制するための hasTemporaryAccess フラグ管理
  • AdMob ポリシー違反にならないリワード広告の設計指針

Ad Unit ID はサンプルとして自分のIDをそのまま記載していますが、実装時は必ずご自身のAdMob管理画面で発行したIDに置き換えてください

動作環境

  • iOS 18.0+
  • Xcode 16
  • Swift 6 / SwiftUI
  • Google Mobile Ads SDK 12.0+
  • StoreKit 2(サブスク管理)

UXフロー

実装した「広告視聴で機能解放 → シェアで再ロック」のフローを図示します。

[ユーザーがプレミアム限定機能を選択]
            │
            ▼
   ┌─────────────────────┐
   │ isPremium == true ? │── Yes ──▶ そのまま機能を使える
   └─────────────────────┘
            │ No
            ▼
   ┌──────────────────────────────┐
   │ confirmationDialog 表示       │
   │ ・サブスク登録                │
   │ ・広告視聴で1回限り解放        │
   │ ・キャンセル                  │
   └──────────────────────────────┘
            │ 広告視聴を選択
            ▼
   [リワード広告を表示]
            │
            ▼
   [リワード付与 → hasTemporaryAccess = true]
            │
            ▼
   [プレミアム機能を1回だけ利用]
            │
            ▼
   [シェア完了時 resetTemporaryAccess() で再ロック]

ポイントは シェア完了をトリガーに hasTemporaryAccessfalse に戻す こと。これにより「1広告 = 1利用」を担保します。

1. AdMob SDK のセットアップ

Swift Package Manager で導入

Xcode の File > Add Package Dependencies... から以下を追加します。

https://github.com/googleads/swift-package-manager-google-mobile-ads

バージョンは 12.0.0 以上を選択します。

Info.plist の必須設定

Info.plist に AdMob のアプリIDと、ATT(App Tracking Transparency)対応の文言を追加します。

<key>GADApplicationIdentifier</key>
<string>ca-app-pub-XXXXXXXXXXXXXXXX~XXXXXXXXXX</string>

<key>NSUserTrackingUsageDescription</key>
<string>あなたに最適な広告を表示するために、トラッキングの許可をお願いします。</string>

<key>SKAdNetworkItems</key>
<array>
  <!-- AdMob 公式の SKAdNetwork ID 一覧をここに追加 -->
</array>

GADApplicationIdentifier は AdMob 管理画面で発行されたアプリID(Ad Unit ID とは別物)を入れます。これを入れ忘れるとアプリがクラッシュするので注意。

AppDelegate での初期化

import SwiftUI
import GoogleMobileAds

@main
struct WorkoutDiaryApp: App {
    init() {
        MobileAds.shared.start(completionHandler: nil)
    }

    var body: some Scene {
        WindowGroup {
            RootView()
        }
    }
}

SDK 12.0+ の変更点その1:旧 GADMobileAds.sharedInstance()MobileAds.shared に変わっています。

2. リワード広告マネージャの実装

@Observable + @MainActor の SwiftUI モダンパターンで、シングルトンの RewardedAdManager を作ります。

import GoogleMobileAds
import Observation
import os
import UIKit

@Observable @MainActor
final class RewardedAdManager: NSObject {
    static let shared = RewardedAdManager()

    private let logger = Logger(
        subsystem: "com.happyboy1002.WorkoutDiary",
        category: "RewardedAd"
    )

    /// リワード広告のAd Unit ID
    /// DEBUGビルドではGoogle公式テストIDを使用
    private var adUnitID: String {
        #if DEBUG
        return "ca-app-pub-3940256099942544/1712485313"
        #else
        return "ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX"  // 自分のIDに置き換え
        #endif
    }

    private var rewardedAd: RewardedAd?
    private(set) var isAdReady = false
    private(set) var isLoading = false

    /// 広告視聴後に一時的にプレミアム機能を解放するフラグ
    private(set) var hasTemporaryAccess = false

    /// 二重resume防止用
    private var adContinuation: CheckedContinuation<Bool, Never>?
    private var rewardGranted = false

    private override init() {
        super.init()
    }

    /// リワード広告を事前読み込み
    func loadAd() async {
        guard !isLoading else { return }
        isLoading = true
        logger.info("リワード広告読み込み開始: \(self.adUnitID)")
        do {
            rewardedAd = try await RewardedAd.load(
                with: adUnitID,
                request: Request()
            )
            rewardedAd?.fullScreenContentDelegate = self
            isAdReady = true
            logger.info("リワード広告読み込み成功")
        } catch {
            isAdReady = false
            logger.error("リワード広告読み込み失敗: \(error.localizedDescription)")
        }
        isLoading = false
    }

    /// リワード広告を表示して1回だけプレミアム機能を解放
    func showAd() async -> Bool {
        guard let ad = rewardedAd else {
            logger.error("広告未ロードのため表示不可")
            return false
        }
        guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
              let rootVC = windowScene.keyWindow?.rootViewController else {
            logger.error("rootViewController取得失敗")
            return false
        }

        // シェアシート等のmodal上から呼ばれた時のために最前面のVCを探す
        var topVC = rootVC
        while let presented = topVC.presentedViewController {
            topVC = presented
        }

        rewardGranted = false
        logger.info("リワード広告表示開始")

        return await withCheckedContinuation { continuation in
            adContinuation = continuation
            ad.present(from: topVC) {
                Task { @MainActor in
                    self.rewardGranted = true
                    self.logger.info("リワード付与")
                }
            }
        }
    }

    /// 一時アクセスをリセット(シェア完了後など)
    func resetTemporaryAccess() {
        hasTemporaryAccess = false
    }
}

Delegate でリワード付与と再ロード

extension RewardedAdManager: FullScreenContentDelegate {
    /// 広告が閉じられた時(リワード付与の有無に関わらず必ず呼ばれる)
    nonisolated func adDidDismissFullScreenContent(_ ad: FullScreenPresentingAd) {
        Task { @MainActor in
            let granted = rewardGranted
            if granted {
                hasTemporaryAccess = true
            }
            rewardedAd = nil
            isAdReady = false

            // continuationを確実にresume(二重resume防止)
            if let continuation = adContinuation {
                adContinuation = nil
                continuation.resume(returning: granted)
            }

            // 次の広告を事前読み込み
            await loadAd()
        }
    }

    /// 広告の表示に失敗した時
    nonisolated func ad(
        _ ad: FullScreenPresentingAd,
        didFailToPresentFullScreenContentWithError error: Error
    ) {
        Task { @MainActor in
            rewardedAd = nil
            isAdReady = false
            if let continuation = adContinuation {
                adContinuation = nil
                continuation.resume(returning: false)
            }
            await loadAd()
        }
    }
}

実装上の注意点

  1. CheckedContinuation のリーク防止
    present(from:userDidEarnRewardHandler:) の handler はリワード付与時にしか呼ばれません。一方 adDidDismissFullScreenContent必ず呼ばれるので、resume はそちらに集約しています。
  2. 広告クローズ後すぐに再ロード
    次回タップで即広告を出すためのプリロード戦略です。
  3. nonisolated 指定
    FullScreenContentDelegateMainActor 制約のないプロトコルなので、@MainActor クラスの delegate メソッドには nonisolated を付け、内部で Task { @MainActor in ... } に切り替えます。Swift 6 のデータレース安全モードで必須です。

3. SDK 12.0+ で変わったAPI名(ハマりポイント)

ここが今回一番ハマったところです。SDK 12 では多くの主要型から GAD プレフィックスが取れています。古いQiita記事や公式の旧サンプルをコピペすると軒並みエラーになるので注意。

旧API(〜11.x) 新API(12.0+)
GADMobileAds.sharedInstance() MobileAds.shared
GADRewardedAd RewardedAd
GADRequest Request
GADBannerView BannerView
GADBannerViewDelegate BannerViewDelegate
GADAdSizeBanner AdSizeBanner
GADFullScreenContentDelegate FullScreenContentDelegate
GADFullScreenPresentingAd FullScreenPresentingAd

GADRewardedAd.load(withAdUnitID:request:completionHandler:)RewardedAd.load(with:request:) に変わり、async throws の Swift Concurrency 対応版になりました。

参考までにバナー広告も新APIで書くとこうなります。

import SwiftUI
import GoogleMobileAds

struct BannerAdView: UIViewRepresentable {
    private var adUnitID: String {
        #if DEBUG
        return "ca-app-pub-3940256099942544/6300978111"
        #else
        return "ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX"  // 自分のIDに置き換え
        #endif
    }

    func makeUIView(context: Context) -> BannerView {
        let bannerView = BannerView(adSize: AdSizeBanner)
        bannerView.adUnitID = adUnitID
        bannerView.delegate = context.coordinator

        if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
           let rootVC = windowScene.keyWindow?.rootViewController {
            bannerView.rootViewController = rootVC
        }

        bannerView.load(Request())
        return bannerView
    }

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

    func makeCoordinator() -> Coordinator { Coordinator() }

    final class Coordinator: NSObject, BannerViewDelegate {}
}

4. StoreKit 2 と組み合わせて「プレミアムユーザーには広告を出さない」

サブスク加入済みのユーザーに広告を出すのは UX 的にもポリシー的にもアウトです。StoreManager(StoreKit 2 ラッパー)の isPremium を見て、リワード広告の選択肢自体を出さない設計にします。

import StoreKit
import Observation

@Observable @MainActor
final class StoreManager {
    static let shared = StoreManager()

    private(set) var purchasedProductIDs: Set<String> = []

    var isPremium: Bool {
        !purchasedProductIDs.isEmpty
    }

    // ...購入・更新監視等は省略...
}

呼び出し側では「プレミアムでない」かつ「一時アクセスもない」場合だけロックアイコンを出します。

let isLocked = type.isPremiumOnly
    && !storeManager.isPremium
    && !rewardedAdManager.hasTemporaryAccess

この3条件でUI状態を制御するのがキモです。

5. 呼び出し側:広告視聴 → 機能解放 → シェアで再ロック

WorkoutShareSheet から該当部分を抜粋します。confirmationDialog で「サブスク」「広告視聴」「キャンセル」を出し、広告が読み込まれていない時は広告視聴ボタンを出さない(規約遵守)。

import SwiftUI

struct WorkoutShareSheet: View {
    @State private var storeManager = StoreManager.shared
    @State private var rewardedAdManager = RewardedAdManager.shared
    @State private var showingPaywall = false
    @State private var showingUnlockOptions = false
    @State private var pendingLockedFormat: ShareCardFormat?

    var body: some View {
        // ... カード選択UI ...
        .confirmationDialog(
            "この機能はプレミアム限定です",
            isPresented: $showingUnlockOptions
        ) {
            Button("プレミアムに登録する") {
                showingPaywall = true
            }

            // 広告がロード済みの時だけ表示
            if rewardedAdManager.isAdReady {
                Button("広告を見て1回だけ無料で使う") {
                    Task {
                        let rewarded = await rewardedAdManager.showAd()
                        if rewarded, let format = pendingLockedFormat {
                            withAnimation {
                                viewModel.selectedFormat = format
                            }
                        }
                    }
                }
            }

            Button("キャンセル", role: .cancel) {}
        }
        .onAppear {
            // 画面表示と同時にプリロード
            Task { await rewardedAdManager.loadAd() }
        }
    }
}

シェア完了で再ロックする部分

UIActivityViewController.completionWithItemsHandler でシェア完了を検知して resetTemporaryAccess() を呼びます。これが**「1シェア = 1広告視聴」を強制する仕組み**です。

@MainActor
private func shareToSNS() {
    let images = viewModel.renderCards(/* ... */)
    guard !images.isEmpty else { return }

    let activityVC = UIActivityViewController(
        activityItems: images,
        applicationActivities: nil
    )

    // シェア完了時に一時アクセスをリセット
    activityVC.completionWithItemsHandler = { _, completed, _, _ in
        if completed {
            Task { @MainActor in
                RewardedAdManager.shared.resetTemporaryAccess()
            }
        }
    }

    // 最前面のViewControllerから提示
    if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
       let rootVC = windowScene.windows.first?.rootViewController {
        var topVC = rootVC
        while let presented = topVC.presentedViewController {
            topVC = presented
        }
        topVC.present(activityVC, animated: true)
    }
}

Instagram Stories のように外部アプリへ遷移してしまい完了検知ができないケースは、遷移直後に即リセットする方針にしています。

@MainActor
private func shareToInstagramStories() {
    // ...画像をInstagramへ渡す処理...
    rewardedAdManager.resetTemporaryAccess()
}

6. AdMob ポリシー上の注意点

「広告視聴で機能解放」は AdMob 公式に認められたユースケースですが、規約違反になりやすいパターンがいくつかあります。実装前にチェックしておきましょう。

OK なパターン

  • 視聴完了後にコイン/コンテンツ/限定機能をユーザーに付与する
  • 「広告を見るかサブスクするか」をユーザーが自由に選べる
  • 広告ロード状態に応じて選択肢を出し分ける(=ロード失敗時に固まらない)

NG なパターン

  • 広告のクリックや視聴を強要する文言(例:「クリックしないと進めません」)
  • ユーザーの意図しないタップを誘発するレイアウト(広告に近接配置されたボタン等)
  • リワードを得るために虚偽の操作を要求する
  • アプリのコア機能(既存ユーザーが普通に使えていた機能)をいきなりロックして広告視聴を要求する

特に最後は要注意で、リワード解放対象は「もともと有料 / 限定だった機能」に限るのが安全です。WorkoutDiary では「Instagram Stories 用フォーマット」のようなプレミアム限定機能だけを対象にしています。

7. ハマりどころまとめ

実装中に詰まったポイントをチェックリスト形式で。

  • Info.plistGADApplicationIdentifier を設定したか(未設定で起動時クラッシュ)
  • SDK 12.0+ で GAD プレフィックスが消えたAPIを使えているか
  • DEBUG ビルドでテスト Ad Unit ID を使っているか(本番IDで開発するとアカウント停止リスク)
  • keyWindow?.rootViewController ではなくシェアシート上から呼ぶ場合、最前面のVCを取得しているか
  • CheckedContinuation が二重 resume されていないか(adDidDismissdidFailToPresent の両方でガード)
  • プレミアムユーザーに広告を出していないか(storeManager.isPremium で分岐)
  • App Store Connect 用ビルドでテストIDに戻っていないか(CI で grep するのがおすすめ)

まとめ

  • Google Mobile Ads SDK 12.0+ では GAD プレフィックスが取れて API 名が大きく変わっている
  • RewardedAd.load(with:request:)async throws で書ける
  • hasTemporaryAccess フラグ + シェア完了で resetTemporaryAccess() を呼ぶことで「1広告 = 1利用」が綺麗に表現できる
  • StoreKit 2 の isPremium と組み合わせれば、プレミアムユーザーには広告を出さない実装が短いコードで実現できる
  • リワード広告は強要せず、コア機能をロックしないのが AdMob ポリシー上の安全圏

「サブスクの導線は欲しいけど、未加入ユーザーをいきなり弾きたくない」というジレンマがある個人開発アプリには、リワード広告での一時解放はかなり相性が良い設計だと感じました。

実際にこの仕組みを入れているアプリ

筋トレ記録アプリ WorkoutDiary にこの実装が入っています。シェアカード機能で Instagram Stories 用フォーマットや週次サマリーカードがプレミアム機能になっており、未加入でも広告視聴で1回だけ試せます。

👉 App Store で見る

参考


@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?