Tauri 2.x iOSアプリにAdMobリワード広告を統合:Swiftプラグイン開発ガイド

Tauri 2.x iOSアプリにAdMobリワード広告を統合:Swiftプラグイン開発ガイド

Tauri v2 iOSアプリにGoogle AdMobリワード広告を統合する全過程を解説します。公式プラグインがないため、独自のSwiftプラグインを開発した経験を共有します。

概要

Tauri 2.xでiOSアプリを開発しましたが、収益化のためにAdMob広告を統合するにはどうすればよいでしょうか?

残念ながら、Tauriには公式のAdMobプラグインがありません。この記事では、AdMobリワード広告を統合するためにSwiftプラグインを独自開発した全過程を共有します。

この記事はTauri + PixiJSでiOSゲーム開発の続編です。Shadow Dashゲームに広告を統合した実際の経験に基づいて執筆しました。

Shadow Dash ゲームオーバー画面

なぜAdMobか?

モバイルゲーム収益化オプションの比較

方式メリットデメリット
リワード広告UX良好、高いeCPM実装が複雑
バナー広告実装が簡単低いeCPM、UX悪化
アプリ内課金高収益実装複雑、決済審査

リワード広告を選んだ理由

Shadow Dashではコンティニューシステムにリワード広告を適用しました:

graph LR
    A[ゲームオーバー] --> B["広告を見て続ける"ボタン]
    B --> C[広告視聴]
    C --> D[ゲーム再開]

ユーザーが自発的に広告を視聴し、報酬(ゲーム継続)を受け取る構造で、ユーザー体験を損なわいません。

広告SDK選定プロセス

Tauriアプリで利用可能な広告SDKを検討しました:

SDKメリットデメリット採用
AdMob高いeCPM、多様な広告形式ネイティブプラグイン必要✅ 採用
AppLixirJS SDKのみで統合可能DAU 5,000人以上必要❌ 不採用
H5 Game AdsHTML5ゲーム特化ベータサービス(不安定)❌ 不採用

AppLixirを選ばなかった理由

  • 最低日間アクティブユーザー(DAU)5,000人以上で申請可能
  • 新規アプリでこの条件を満たすのは困難

H5 Game Adsを選ばなかった理由

  • まだベータサービス段階
  • 安定性と収益性が検証されていない

AdMob vs AppLixir 詳細比較

AdMobとAppLixirのパフォーマンスを比較すると:

項目AdMobAppLixir
eCPM(リワード)$20〜30$15〜25
Fill Rate95%+80〜90%
対応形式リワード、インタースティシャル、バナーリワードのみ
Mediation対応非対応
統合難易度高い(ネイティブ必要)低い(JS SDK)
参入障壁なしDAU 5,000人以上必要

結論:ネイティブプラグイン開発が必要ですが、AdMobは参入障壁がなく、高いeCPMとFill Rateを提供します。


Tauri CLIのアップグレード

XCFrameworkサポートが必要

Tauri v2でiOSプラグインを開発するには、Swift Package Manager(SPM)を使用する必要があります。GoogleMobileAds SDKのような外部フレームワーク依存が必要なプラグインを作るには、XCFrameworkサポートが必要です。

Tauri CLI 2.9.6+へのアップグレード

# CargoでTauri CLIをアップグレード
cargo install tauri-cli --force

# バージョン確認
cargo tauri --version
# 出力: tauri-cli 2.9.6(またはそれ以上)

—ios-frameworkオプション

プラグイン作成時にiOSフレームワークタイプを指定できます:

# Xcodeプロジェクト方式(SPM依存関係の追加が容易)- 推奨
cargo tauri plugin new admob --ios --ios-framework xcode

# XCFramework方式
cargo tauri plugin new admob --ios --ios-framework xcframework
オプション説明メリットデメリット
xcodeXcodeプロジェクト生成SPMで依存関係追加が容易プロジェクトファイル管理が必要
xcframeworkXCFramework生成配布が容易外部依存関係の追加が複雑

AdMobプラグインにはxcodeオプション推奨:GoogleMobileAds SDKをSPMで簡単に追加できます。


Tauriプラグイン構造の理解

ディレクトリ構造

tauri-plugin-admob/
├── src/                    # Rustコード
│   ├── lib.rs             # プラグインエントリポイント
│   ├── mobile.rs          # iOS/Androidブリッジ
│   ├── desktop.rs         # デスクトップスタブ
│   ├── commands.rs        # Tauriコマンド
│   └── models.rs          # リクエスト/レスポンス型
├── ios/                    # iOSネイティブコード
│   └── tauri-plugin-admob/
│       └── AdmobPlugin.swift
├── guest-js/              # TypeScript API
│   └── index.ts
├── permissions/           # Tauri権限設定
│   └── default.toml
├── build.rs               # ビルドスクリプト
└── Cargo.toml

データフロー

graph TD
    A[TypeScript] -->|invoke| B[Rust Command]
    B -->|run_mobile_plugin| C[Swift Plugin]
    C -->|API Call| D[GoogleMobileAds SDK]
    D -->|Response| C
    C -->|Result| B
    B -->|JSON| A

プラグイン開発の開始

プラグインのスキャフォールディング

# Xcodeプロジェクト方式で作成
cargo tauri plugin new admob --ios --ios-framework xcode

GoogleMobileAds SDKの追加

XcodeでプラグインプロジェクトをIヒ、Swift Package ManagerでSDKを追加:

  1. tauri-plugin-admob/ios/tauri-plugin-admob.xcodeprojを開く
  2. File → Add Package Dependencies
  3. URL入力:https://github.com/googleads/swift-package-manager-google-mobile-ads
  4. GoogleMobileAdsを選択して追加

Swiftプラグインの実装

AdmobPlugin.swift

import SwiftRs
import Tauri
import UIKit
import WebKit
import GoogleMobileAds

// MARK: - Argument Types
class InitializeArgs: Decodable {}

class LoadRewardedArgs: Decodable {
    let adUnitId: String
}

class ShowRewardedArgs: Decodable {}

// MARK: - AdMob Plugin
class AdmobPlugin: Plugin {
    private var rewardedAd: GADRewardedAd?
    private var isInitialized = false
    private var pendingInvoke: Invoke?

    // テスト広告ID(本番では実際のIDを使用)
    private let testAdUnitId = "ca-app-pub-3940256099942544/1712485313"

    @objc public override func load(webview: WKWebView) {
        NSLog("[AdMob Plugin] Loaded")
    }

    // SDK初期化
    @objc public func initialize(_ invoke: Invoke) {
        if isInitialized {
            invoke.resolve(["success": true, "message": "Already initialized"])
            return
        }

        GADMobileAds.sharedInstance().start { status in
            self.isInitialized = true
            NSLog("[AdMob Plugin] SDK Initialized")
            invoke.resolve(["success": true, "message": "SDK initialized"])
        }
    }

    // リワード広告のロード
    @objc public func loadRewardedAd(_ invoke: Invoke) {
        do {
            let args = try invoke.parseArgs(LoadRewardedArgs.self)
            let adUnitId = args.adUnitId.isEmpty ? testAdUnitId : args.adUnitId

            let request = GADRequest()
            GADRewardedAd.load(withAdUnitID: adUnitId, request: request) { [weak self] ad, error in
                if let error = error {
                    invoke.resolve(["success": false, "error": error.localizedDescription])
                    return
                }

                self?.rewardedAd = ad
                self?.rewardedAd?.fullScreenContentDelegate = self
                invoke.resolve(["success": true])
            }
        } catch {
            invoke.reject(error.localizedDescription)
        }
    }

    // 広告準備状態の確認
    @objc public func isRewardedAdReady(_ invoke: Invoke) {
        let isReady = rewardedAd != nil
        invoke.resolve(["ready": isReady])
    }

    // 広告表示
    @objc public func showRewardedAd(_ invoke: Invoke) {
        guard let rewardedAd = rewardedAd else {
            invoke.resolve(["success": false, "rewarded": false, "error": "No ad loaded"])
            return
        }

        guard let rootViewController = getRootViewController() else {
            invoke.resolve(["success": false, "rewarded": false, "error": "No root view controller"])
            return
        }

        pendingInvoke = invoke

        DispatchQueue.main.async {
            rewardedAd.present(fromRootViewController: rootViewController) { [weak self] in
                let reward = rewardedAd.adReward
                if let pending = self?.pendingInvoke {
                    pending.resolve([
                        "success": true,
                        "rewarded": true,
                        "rewardAmount": reward.amount.intValue,
                        "rewardType": reward.type
                    ])
                    self?.pendingInvoke = nil
                }
            }
        }
    }

    // Root View Controllerの取得
    private func getRootViewController() -> UIViewController? {
        if let windowScene = UIApplication.shared.connectedScenes
            .compactMap({ $0 as? UIWindowScene })
            .first(where: { $0.activationState == .foregroundActive }),
           let keyWindow = windowScene.windows.first(where: { $0.isKeyWindow }),
           let rootVC = keyWindow.rootViewController {
            var topController = rootVC
            while let presented = topController.presentedViewController {
                topController = presented
            }
            return topController
        }
        return nil
    }
}

// MARK: - GADFullScreenContentDelegate
extension AdmobPlugin: GADFullScreenContentDelegate {
    func adDidDismissFullScreenContent(_ ad: GADFullScreenPresentingAd) {
        rewardedAd = nil
        if let pending = pendingInvoke {
            pending.resolve(["success": true, "rewarded": false])
            pendingInvoke = nil
        }
    }

    func ad(_ ad: GADFullScreenPresentingAd, didFailToPresentFullScreenContentWithError error: Error) {
        rewardedAd = nil
        if let pending = pendingInvoke {
            pending.resolve(["success": false, "rewarded": false, "error": error.localizedDescription])
            pendingInvoke = nil
        }
    }
}

// MARK: - Plugin Export
@_cdecl("init_plugin_admob")
func initPlugin() -> Plugin {
    return AdmobPlugin()
}

重要なポイント

  1. @objcアノテーション:Objective-Cランタイム経由でRustから呼び出すために必要
  2. pendingInvokeパターン:非同期広告コールバックをTauriの同期invokeパターンにブリッジ
  3. GADFullScreenContentDelegate:広告の閉じる/エラーイベントを処理
  4. @_cdecl("init_plugin_admob"):Rustプラグインロード用のC関数エクスポート

Rustブリッジの実装

models.rs

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InitializeResponse {
    pub success: bool,
    pub message: Option<String>,
}

#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LoadRewardedAdRequest {
    pub ad_unit_id: String,
}

#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LoadRewardedAdResponse {
    pub success: bool,
    pub error: Option<String>,
}

#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct IsRewardedAdReadyResponse {
    pub ready: bool,
}

#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ShowRewardedAdResponse {
    pub success: bool,
    pub rewarded: bool,
    pub reward_amount: Option<i32>,
    pub reward_type: Option<String>,
    pub error: Option<String>,
}

mobile.rs

use serde::de::DeserializeOwned;
use tauri::{
    plugin::{PluginApi, PluginHandle},
    AppHandle, Runtime,
};
use crate::models::*;

#[cfg(target_os = "ios")]
tauri::ios_plugin_binding!(init_plugin_admob);

pub fn init<R: Runtime, C: DeserializeOwned>(
    _app: &AppHandle<R>,
    api: PluginApi<R, C>,
) -> crate::Result<Admob<R>> {
    #[cfg(target_os = "ios")]
    let handle = api.register_ios_plugin(init_plugin_admob)?;
    Ok(Admob(handle))
}

pub struct Admob<R: Runtime>(PluginHandle<R>);

impl<R: Runtime> Admob<R> {
    pub fn initialize(&self) -> crate::Result<InitializeResponse> {
        self.0.run_mobile_plugin("initialize", ()).map_err(Into::into)
    }

    pub fn load_rewarded_ad(&self, ad_unit_id: String) -> crate::Result<LoadRewardedAdResponse> {
        self.0.run_mobile_plugin("loadRewardedAd",
            LoadRewardedAdRequest { ad_unit_id }).map_err(Into::into)
    }

    pub fn is_rewarded_ad_ready(&self) -> crate::Result<IsRewardedAdReadyResponse> {
        self.0.run_mobile_plugin("isRewardedAdReady", ()).map_err(Into::into)
    }

    pub fn show_rewarded_ad(&self) -> crate::Result<ShowRewardedAdResponse> {
        self.0.run_mobile_plugin("showRewardedAd", ()).map_err(Into::into)
    }
}

build.rs(重要!)

const COMMANDS: &[&str] = &[
    "initialize",
    "load_rewarded_ad",
    "is_rewarded_ad_ready",
    "show_rewarded_ad"
];

fn main() {
    // iOSビルド時にフレームワークをリンク
    let target = std::env::var("TARGET").unwrap_or_default();
    if target.contains("ios") {
        println!("cargo:rustc-link-lib=framework=GoogleMobileAds");
        println!("cargo:rustc-link-lib=framework=UserMessagingPlatform");
    }

    tauri_plugin::Builder::new(COMMANDS)
        .android_path("android")
        .ios_path("ios")
        .build();
}

注意#[cfg(target_os = "ios")]はbuild.rsでは機能しません。build.rsはホストマシン(macOS)で実行されるため、std::env::var("TARGET")でターゲットを確認する必要があります。


TypeScript APIの実装

guest-js/index.ts

import { invoke } from '@tauri-apps/api/core'

export interface InitializeResponse {
  success: boolean;
  message?: string;
}

export interface LoadRewardedAdResponse {
  success: boolean;
  error?: string;
}

export interface IsRewardedAdReadyResponse {
  ready: boolean;
}

export interface ShowRewardedAdResponse {
  success: boolean;
  rewarded: boolean;
  rewardAmount?: number;
  rewardType?: string;
  error?: string;
}

export async function initialize(): Promise<InitializeResponse> {
  return await invoke<InitializeResponse>('plugin:admob|initialize');
}

export async function loadRewardedAd(adUnitId: string = ''): Promise<LoadRewardedAdResponse> {
  return await invoke<LoadRewardedAdResponse>('plugin:admob|load_rewarded_ad', {
    adUnitId,
  });
}

export async function isRewardedAdReady(): Promise<IsRewardedAdReadyResponse> {
  return await invoke<IsRewardedAdReadyResponse>('plugin:admob|is_rewarded_ad_ready');
}

export async function showRewardedAd(): Promise<ShowRewardedAdResponse> {
  return await invoke<ShowRewardedAdResponse>('plugin:admob|show_rewarded_ad');
}

アプリへのプラグイン統合

Cargo.tomlにプラグインを追加

[dependencies]
tauri-plugin-admob = { path = "../tauri-plugin-admob" }

lib.rsにプラグインを登録

pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_admob::init())
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

権限設定

src-tauri/capabilities/default.json:

{
  "permissions": [
    "core:default",
    "admob:default"
  ]
}

tauri-plugin-admob/permissions/default.toml:

[default]
description = "Default permissions for the AdMob plugin"
permissions = [
    "allow-initialize",
    "allow-load-rewarded-ad",
    "allow-is-rewarded-ad-ready",
    "allow-show-rewarded-ad"
]

Info.plistにアプリIDを追加

src-tauri/gen/apple/アプリ名_iOS/Info.plist:

<key>GADApplicationIdentifier</key>
<string>ca-app-pub-XXXXXXXXXXXXXXXX~XXXXXXXXXX</string>
<key>SKAdNetworkItems</key>
<array>
    <dict>
        <key>SKAdNetworkIdentifier</key>
        <string>cstr6suwn9.skadnetwork</string>
    </dict>
</array>

ゲームでの広告使用

広告設定ファイル

src/lib/config/admob.ts:

// 広告有効化フラグ(承認まではfalse)
export const ADS_ENABLED = false;

const TEST_AD_UNITS = {
  rewardedAd: 'ca-app-pub-3940256099942544/1712485313',
  appId: 'ca-app-pub-3940256099942544~1458002511',
};

const PRODUCTION_AD_UNITS = {
  rewardedAd: 'ca-app-pub-XXXXXXXX/XXXXXXXXXX',  // 実際の広告ユニットID
  appId: 'ca-app-pub-XXXXXXXX~XXXXXXXXXX',       // 実際のアプリID
};

const isDevelopment = import.meta.env.DEV;

export const AD_UNITS = isDevelopment ? TEST_AD_UNITS : PRODUCTION_AD_UNITS;
export const isTestMode = isDevelopment;

ゲームオーバー画面での使用

import { AD_UNITS, ADS_ENABLED } from '$lib/config/admob';
import * as admob from 'tauri-plugin-admob-api';

let adLoading = false;
let adError = '';

async function handleWatchAd() {
  if (!ADS_ENABLED) {
    // 広告無効時は直接コンティニュー
    game.startContinue();
    return;
  }

  adLoading = true;
  adError = '';

  try {
    // 広告準備状態を確認
    const readyCheck = await admob.isRewardedAdReady();

    if (!readyCheck.ready) {
      // 広告が準備されていなければロード
      const loadResult = await admob.loadRewardedAd(AD_UNITS.rewardedAd);
      if (!loadResult.success) {
        adError = loadResult.error || 'Failed to load ad';
        adLoading = false;
        return;
      }
    }

    // 広告表示
    const showResult = await admob.showRewardedAd();

    if (showResult.rewarded) {
      // 報酬獲得 - ゲーム継続
      game.startContinue();
    } else if (showResult.error) {
      adError = showResult.error;
    }
  } catch (error) {
    adError = String(error);
  } finally {
    adLoading = false;
  }
}

トラブルシューティング

Swift型エラー:「cannot find type ‘RewardedAd’ in scope」

原因:GoogleMobileAds SDKはObjective-Cベースで、SwiftではGADプレフィックスが必要です。

// ❌ 間違い
private var rewardedAd: RewardedAd?

// ✅ 正解
private var rewardedAd: GADRewardedAd?

リンカーエラー:「Undefined symbols for architecture arm64」

原因:GoogleMobileAdsフレームワークがリンクされていない

解決策1:メインアプリのXcodeプロジェクトにもSDKを追加

  1. src-tauri/gen/apple/アプリ名.xcodeprojを開く
  2. File → Add Package Dependencies
  3. GoogleMobileAds SDKを追加

解決策2:build.rsでフレームワークをリンク

let target = std::env::var("TARGET").unwrap_or_default();
if target.contains("ios") {
    println!("cargo:rustc-link-lib=framework=GoogleMobileAds");
}

Tauri権限エラー

エラーメッセージ

admob.is_rewarded_ad_ready not allowed.
Permissions associated with this command: admob:allow-is-rewarded-ad-ready

解決策permissions/default.tomlcapabilities/default.jsonに権限を追加

広告が表示されない

症状:ボタンクリックしても広告が表示されない

確認事項

  1. テスト広告IDを使用しているか確認
  2. GADMobileAds.sharedInstance().start()が呼ばれているか確認
  3. 実機でテスト(シミュレーターのサポートは限定的)

収益化戦略の考察

段階的導入計画

graph TD
    subgraph Phase1["第1段階(現在)"]
        A[リワードコンティニュー広告のみ]
    end

    subgraph Phase2["第2段階(1〜2ヶ月後)"]
        B[インタースティシャル広告追加]
        C[コイン2倍広告]
    end

    subgraph Phase3["第3段階(3ヶ月後)"]
        D[IAP:広告削除]
        E[IAP:コインパック]
    end

    Phase1 --> Phase2
    Phase2 --> Phase3

収益化3つの原則

  1. ゲームバランス保護:見た目のみ課金、能力値販売禁止
  2. 無課金ユーザー尊重:全コンテンツ無料で獲得可能
  3. 価値提供優先:ユーザーが「買う価値がある」と感じる商品

予想収益(DAU 10,000人基準)

段階広告タイプ日間収益月間収益
1リワードのみ〜$130〜$3,900
2リワード + インタースティシャル〜$425〜$12,750
3広告 + IAP〜$600〜$18,000

まとめ

学んだこと

  1. Tauri CLIを最新に保つ:XCFrameworkサポートなど重要な機能が継続的に追加
  2. Tauriプラグイン構造の理解:Rust ↔ Swiftブリッジの理解が必須
  3. クロスコンパイル:build.rsでのHOST vs TARGET区別が重要
  4. Tauri v2権限システム:capabilitiesとpermissionsが必須

チェックリスト

  • 最新のTauri CLIにアップグレード(cargo install tauri-cli --force
  • --ios-framework xcodeオプションでプラグイン作成
  • GoogleMobileAds SDKをメインアプリとプラグイン両方に追加
  • build.rsでstd::env::var("TARGET")でiOSを検出
  • permissions/default.tomlに全コマンド権限を定義
  • capabilities/default.jsonadmob:defaultを追加
  • Info.plistにGADApplicationIdentifierを追加
  • テスト/本番広告IDの分離管理

次のステップ

  • Androidサポート追加
  • インタースティシャル広告実装
  • バナー広告実装
  • StoreKit 2連携(IAP)

Shadow Dashをダウンロード

この記事で扱った技術で開発したShadow Dashをぜひプレイしてみてください!

Download on the App Store

フィードバック歓迎! プレイして改善点やバグがあれば、App Storeレビューやメールでお知らせください。

📱 Shadow Dash詳細情報: ポートフォリオページでゲームの核心メカニズムとスクリーンショットをご確認ください。

参考資料

他の言語で読む

この記事は役に立ちましたか?

より良いコンテンツを作成するための力になります。コーヒー一杯で応援してください!☕

著者について

JK

Kim Jangwook

AI/LLM専門フルスタック開発者

10年以上のWeb開発経験を活かし、AIエージェントシステム、LLMアプリケーション、自動化ソリューションを構築しています。Claude Code、MCP、RAGシステムの実践的な知見を共有します。