自己紹介
株式会社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() で再ロック]
ポイントは シェア完了をトリガーに hasTemporaryAccess を false に戻す こと。これにより「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()
}
}
}
実装上の注意点
-
CheckedContinuationのリーク防止
present(from:userDidEarnRewardHandler:)の handler はリワード付与時にしか呼ばれません。一方adDidDismissFullScreenContentは必ず呼ばれるので、resume はそちらに集約しています。 -
広告クローズ後すぐに再ロード
次回タップで即広告を出すためのプリロード戦略です。 -
nonisolated指定
FullScreenContentDelegateはMainActor制約のないプロトコルなので、@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.plistのGADApplicationIdentifierを設定したか(未設定で起動時クラッシュ) -
SDK 12.0+ で
GADプレフィックスが消えたAPIを使えているか - DEBUG ビルドでテスト Ad Unit ID を使っているか(本番IDで開発するとアカウント停止リスク)
-
keyWindow?.rootViewControllerではなくシェアシート上から呼ぶ場合、最前面のVCを取得しているか -
CheckedContinuationが二重 resume されていないか(adDidDismissとdidFailToPresentの両方でガード) -
プレミアムユーザーに広告を出していないか(
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回だけ試せます。
参考
- Google Mobile Ads SDK for iOS - Rewarded ads(公式ドキュメント)
- Google Mobile Ads SDK iOS Release Notes(v12.0.0 移行情報)
- AdMob Program Policies(広告ポリシー)
- StoreKit | Apple Developer Documentation
- App Tracking Transparency | Apple Developer Documentation
@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!