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?

【個人開発】広告アカウントを恒久停止された後、8本のiOSアプリをUnity LevelPlayへ1週間で移行した記録

0
Posted at

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

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


2026年7月30日、個人開発で使っていた広告アカウントが「無効なトラフィック」を理由に恒久的に無効化されました。再審査請求も通りませんでした。

そのとき私は、8本のiOSアプリを個人で運営していました。全部の収益導線が同時に死にました。

この記事は、そこから AppLovin MAX → Unity LevelPlay と移行先を2回変え、最終的に8本すべてを LevelPlay に統一し終えるまでの記録です。SDK の使い方紹介ではありません。実際にリジェクトを食らった箇所と、その原因を中心に書きます。

同じ状況になる人は少ないと思いますが、「メディエーションSDKの乗り換え」でハマる場所はかなり共通しているはずです。

なぜ止められたのか

原因はほぼ特定できています。開発中と TestFlight 検証中に、本番の広告ユニットIDでインプレッションとクリックを発生させていたことです。

「動作確認したいから、実機で1回だけ広告を表示してタップしてみる」——これをやっていました。数えるほどの回数でも、自己クリックは規約違反として扱われます。

そこで、移行後は広告をロード・表示するのは App Store 配信版のみという方針を絶対に崩さないことにしました。以降のコードはすべてこの前提で書かれています。

移行の判定ロジック

まず、どの環境なら広告を出してよいかを1か所に集約しました。

import Foundation
import IronSource
import Observation
import os

enum AdEnvironment {

    /// LevelPlay の App Key。空のままなら広告は一切ロードされない(安全側の既定)
    static let appKey = "YOUR_APP_KEY"

    /// 本番の広告を配信してよい環境か(App Store 配信版のみ true)
    static var isProduction: Bool {
        #if DEBUG
        return false
        #elseif targetEnvironment(simulator)
        return false
        #else
        guard let receiptURL = Bundle.main.appStoreReceiptURL else { return false }
        return receiptURL.lastPathComponent != "sandboxReceipt"
        #endif
    }

    /// 広告配信が利用可能か
    static var isAdDeliveryAvailable: Bool {
        !appKey.isEmpty && isProduction
    }
}

ポイントは #if DEBUG だけでは不十分なことです。TestFlight は Release ビルドなので、DEBUG 判定を通り抜けて本番広告が出ます。

レシートのファイル名で判定しています。TestFlight と Xcode から実機に直接入れた場合のレシートは sandboxReceipt、App Store 配信版だけが receipt です。ビルド構成の設定ミスでは破れない判定になるのが利点です。

App Key を空文字にしておけば広告が完全に止まるので、開発中の新規アプリはこの状態で進められます。

踏んだ罠

ここからが本題です。8本分の移行で実際に時間を溶かした箇所を挙げます。

1. SDKとアダプタのバージョン体系が別

これが最初の壁でした。XcodeGen の project.yml はこうなります。

packages:
  LevelPlay:
    url: https://github.com/ironsource-mobile/LevelPlay-Swift-Package
    from: "9.6.0"
  LevelPlayUnityAdsAdapter:
    url: https://github.com/ironsource-mobile/LevelPlay-UnityAds-Adapter-Swift-Package
    from: "5.10.0"

SDK が 9.6.0 なのに、Unity Ads アダプタは 5.10.0 です。

アダプタのバージョンを SDK に合わせて 9.x と書くと、SwiftPM の依存解決に失敗します。 アダプタの版数は「Unity Ads SDK 側の版」であって、LevelPlay SDK の版ではないためです。

「同じ製品群なんだから揃うだろう」と考えて 9.x を指定し、解決エラーの原因が分からずしばらく止まりました。

2. パッケージ名とプロダクト名が一致しない

    dependencies:
      # プロダクト名はパッケージ名と一致しない
      - package: LevelPlay
        product: UnityMediationSDK
      - package: LevelPlayUnityAdsAdapter
        product: UnityAdsAdapter

product: の明示が必須です。パッケージ名が LevelPlay なのにプロダクト名は UnityMediationSDK です。省略するとリンクできません。

Xcode の GUI から追加すれば選択肢が出るので気づけますが、project.yml を手で書くと踏みます。

3. 初期化完了前の loadAd() は失敗する

AppLovin MAX では初期化直後にロードしても通っていましたが、LevelPlay は初期化が終わるまでロードが失敗します

    static func initializeSDK(completion: (@MainActor @Sendable () -> Void)? = nil) {
        guard isAdDeliveryAvailable else { return }

        let request = LPMInitRequestBuilder(appKey: appKey).build()
        LevelPlay.initWith(request) { _, error in
            Task { @MainActor in
                if let error {
                    // 黙って落とすと「広告が出ない」原因調査ができないため必ず記録する
                    logger.error("LevelPlay の初期化に失敗: \(error.localizedDescription, privacy: .public)")
                    return
                }
                AdReadiness.shared.isInitialized = true
                completion?()
            }
        }
    }

プリロードは必ず完了コールバックの中から呼びます。

4. 初期化完了を static var で持つとバナーが二度と出ない

これは SwiftUI 側の落とし穴で、原因の特定に一番時間がかかりました。

初期化完了フラグを素の static var で持つと、初期化が終わってもバナーの表示条件が再評価されませんUIViewRepresentablemakeUIView は一度しか呼ばれないため、初期化前に描画された画面ではバナーが永久に出なくなります。

/// SDK の初期化完了を SwiftUI へ伝えるための観測可能なストア
@Observable
@MainActor
final class AdReadiness {
    static let shared = AdReadiness()
    fileprivate(set) var isInitialized = false
    private init() {}
}

@Observable にすることで、初期化完了と同時に SwiftUI が再描画されます。

「広告が出ない」ときに SDK 側ばかり疑ってしまいましたが、原因は自分の状態管理でした。

5. リワードのコールバックが2回来る

報酬付与のタイミングで didRewardAddidCloseAd両方が呼ばれます。素直に書くと二重付与します。

    private let rewardGrantedBox = OSAllocatedUnfairLock(initialState: false)

    /// 報酬を付与すべきタイミング
    nonisolated func didRewardAd(with adInfo: LPMAdInfo, reward: LPMReward) {
        // フラグの確定はここで同期的に行う。二重付与の判定も同じロック内で完結させる
        let alreadyGranted = rewardGrantedBox.withLock { granted -> Bool in
            let was = granted
            granted = true
            return was
        }
        guard !alreadyGranted else { return }
        Task { @MainActor [weak self] in
            self?.onReward?()
            self?.onReward = nil
        }
    }

Task { @MainActor } の中でフラグを見ると、2つのコールバックが競合したときに両方がガードを通り抜けます。判定と更新を同じロック内で同期的に完結させる必要があります。

6. App Open(起動時広告)のフォーマットが存在しない

これは実装の問題ではなく、機能そのものがありません

起動時広告を収益の柱にしていたアプリが1本あったので、そこは広告枠ごと廃止する判断になりました。移行前にフォーマットの対応表を確認しておけば、設計を変えずに済んだかもしれません。

7. ダッシュボードが2つある

App Key と広告ユニットIDは LevelPlay 側platform.ironsrc.com)で発行します。

cloud.unity.com/monetization にも似た画面がありますが、こちらは Unity Ads を bidder として供給する側の設定で、別物です。最初ここを見て「App Key がない」と探し回りました。

リジェクトされた2件

移行そのものより、審査で落ちた2件のほうが痛手でした。

ITMS-91064:プライバシーマニフェスト

NSPrivacyTrackingtrue にしたまま NSPrivacyTrackingDomains を空配列にすると、アップロード時に弾かれます。

ITMS-91064: Invalid tracking information

「じゃあドメインを書けばいい」と考えるところですが、ここが罠でした

NSPrivacyTrackingDomains に載せたドメインは、ATT 非許可のユーザーに対して iOS が通信ごとブロックします。Unity Ads は広告配信とトラッキングで同じドメインを使うため、載せると「ATT を拒否したユーザーに広告が1本も出ない」状態になります。日本の ATT 許可率は3〜4割程度と言われているので、これは致命的です。

正解は NSPrivacyTrackingfalse にして NSPrivacyTrackingDomains のキーごと削除することでした。

	<key>NSPrivacyTracking</key>
	<false/>

トラッキングの申告は、マニフェストではなく App Store Connect の App Privacy(識別子=サードパーティ広告/トラッキングに使用)で行います。LevelPlay や Unity Ads の同梱マニフェストがトラッキングドメインを宣言していないのも、同じ理由だと思われます。

これに気づかず、5本のアプリで同時にリジェクトを食らいました。

Guideline 2.1:ATTダイアログが審査で表示されない

より痛かったのがこちらです。

ATT ダイアログの表示条件を、広告の配信可否と同じ isAdDeliveryAvailable でガードしていました。

// これをやるとリジェクトされる
guard AdEnvironment.isAdDeliveryAvailable else { return }
await requestTrackingAuthorizationIfNeeded()

isAdDeliveryAvailable には isProduction が含まれています。そして App Store の審査は TestFlight と同じ sandbox レシート環境で実行されます

つまり審査担当者の端末では isProductionfalse になり、ATT ダイアログが一度も表示されません。「トラッキングすると申告しているのに許可を求める画面が出てこない」として、Guideline 2.1 - Information Needed でリジェクトされました。

修正は、広告の「配信」と ATT の「表示」を別の条件に分けることです。

    /// ATT(トラッキング許可)ダイアログを表示すべきか
    ///
    /// 広告の「配信」は App Store 版のみ(`isAdDeliveryAvailable`)に限定するが、
    /// ATT ダイアログの「表示」は審査ビルドでも行わなければならない。
    static var shouldRequestTracking: Bool { !appKey.isEmpty }

呼び出し側もこちらに差し替えます。

guard AdEnvironment.shouldRequestTracking else { return }
guard ATTrackingManager.trackingAuthorizationStatus == .notDetermined else { return }
await ATTrackingManager.requestTrackingAuthorization()

App Key が空=広告を一切出さない版では、許可を求める理由がないので表示しません。理由なく ATT を出すと今度は Guideline 5.1.2 に当たります。ここの線引きが要ります。

学んだこと:アップロード前に必ず検証を通す

ITMS 系のエラーは、提出して初めて発覚すると往復が発生します。ビルド番号を上げて作り直しになるので、1回のミスで半日飛びます。

アップロード前に Apple の検証を通すようにしました。

xcrun altool --validate-app -f build/YourApp.ipa -t ios \
  --apiKey YOUR_KEY_ID --apiIssuer YOUR_ISSUER_ID
# → "No errors validating archive" を確認してからアップロードする

これを挟むだけで、ITMS 系はほぼ事前に潰せます。

あわせて分かったこと:

  • INVALID_BINARY は自動検証で弾かれた印です。人間の審査ではないので Resolution Center には「バイナリが無効」としか出ません。理由は Apple からのメールに ITMS 番号で届きます
  • 既にアップロード済みのビルド番号で --validate-app すると、内容検証まで到達せずに ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE で止まります。検証は必ず新しい番号で行います

まとめ

移行そのものは、SDK の差し替えという意味では大した作業ではありませんでした。時間を持っていったのは周辺です。

分類 内容
依存解決 SDKとアダプタでバージョン体系が別/プロダクト名が一致しない
実装 初期化完了前のロード失敗/@Observable にしないとバナーが出ない/リワードの二重付与
仕様差 App Open フォーマットが存在しない
審査 プライバシーマニフェストのトラッキング宣言/ATT を isProduction でガードしてはいけない

一番の教訓は、「広告の配信可否」と「ATTの表示可否」は別の条件であるということです。同じフラグでまとめたくなりますが、審査環境が sandbox である以上、まとめた瞬間にリジェクトが確定します。

そして大前提として、開発中に本番広告IDでインプレッションを出さないこと。これを守っていれば、そもそもこの1週間は要りませんでした。

参考

@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?