🗂

Unity内で完結させる「AdMob広告付きアプリのiOS14対応」

2021/07/27に公開

はじめに

  • パブリックベータの審査ではダメ出しされなかったので、対応が必要なことに気が付いておらず、いざ製品リリースしようとしたらリジェクトされて焦りました。
  • この記事は、審査を通すまでに行った調査や対処をまとめたものです。

この記事でできること

  • Unityで作ったAdMob広告付きアプリのiOS14対応
    • AppTrackingTransparency(ATT)を導入してIDFAの取得を試み、不許可ならSKAdNetworkが使われるようにします。
    • ATTの説明文をローカライズします。
    • Unity側の作業のみで実現し、Xcodeでの手作業を不要にします。
  • Unityエディタの使い方や、Google Mobile Ads Unity PluginやUnity Data Privercyの導入、Xcodeでの作業を含むiOSビルドの方法などについては、この記事では扱いません。

前提

  • Xcode 12.5.1
    • iOS 14.5 supported
  • Unity 2018.4.x (LTS)
    • Unity Analytics を使用
    • Unity IAP を使用
  • Google Mobile Ads Unity Plugin v5.4.0
    • Google Mobile Ads iOS SDK 7.68.0

状況の理解

  • Appleが2020年末にポリシーを変更して、AppTrackingTransparencyとSKAdNetworkが導入されました。
  • 2021年4月26日にiOS 14.5がリリースされ、それまでオプトアウト方式だったIDFAの取得許可がオプトインに切り替わりました。
  • IDFAを使用する場合は、プライバシーラベルにその旨を記載する必要があり、アプリ内でATTを介してダイアログを表示してユーザーの許諾を得る必要があります。
  • プライバシーラベルに「トラッキング」がある場合は、アプリがATTを使用してユーザーから許諾を得るようになっていないと、審査でリジェクトされます。
  • IDFAを使用せずにSKAdNetworkだけを使用する場合は、プライバシーラベルでトラッキングの記載をなくすことができ、ATTを使用して許可を得る必要がなくなります。

用語と概念

IDFA(Identifier for Advertisers)

従来様式の広告トラッキング用のIDで、SKAdNetworkに比較して追跡性能が高いです。ユーザーは使用許諾やリセットなどをコントロール可能です。

ATT (App Tracking Transparency)

IDFAを使用するにあたって、ユーザーに許可を得る仕組みです。
ユーザの選択結果はOSで記憶され、ユーザが再度確認を求められることはありません。

SKAdNetwork

IDFAを使用せずに、プライバシーに配慮しつつ広告の最適化を行うための仕組みで、IDFAに比較して追跡性能が低いようです。
IDFAの使用許可が得られない場合は、自動的に使用されます。

プライバシーラベル

App Store Connectの「Appのプライバシー」で設定し、ストアに表示される個人情報の取り扱いに関する定型の情報表示です。

可能な選択肢

IDFAを使用しない

  • Appのプライバシーで「トラッキング」を申告する必要も、ATTを使用する必要もありません。
  • AdMobに対して特別な制御は不要です。
    • ATTを使わないので、IDFAを得ることはできません。
  • IDFAが得られないので、自動的にSKAdNetworkが使用されます。

IDFAの使用許可を求める

  • Appのプライバシーで「トラッキング」を申告しなければなりません。
  • ATTを使用してIDFAの使用許諾を求める必要があります。
    • システムの許可要請ダイアログを表示する前に、あらかじめアプリ側で説明を表示することも考えられます。
  • ATTの結果に拠らず、AdMobに対して特別な制御は不要です。
    • ユーザーの許可が無い限り、IDFAを得ることはできません。
  • IDFAが得られなかった場合は、自動的にSKAdNetworkが使用されます。

具体的な対処

以降は、「IDFAの使用許可を求める」場合について述べます。

SKAdNetwork 対応

  • info.plist該当のIDを登録することでアクティベートされるようです。
  • 「ビルドの都度に手動で登録する、あるいは自動的に登録するコードを実装する」記事と、「AdMob SDKが自動的に登録する」と主張する記事が混在しています。
    • 古いSDKではできなかったのかとも考えましたが、より新しい記事で自前の実装を行っているようです。
    • Google Mobile Ads Unity Plugin v5.4.0には、自動的にinfo.plistに挿入されると書かれています。
    • 実際に試してみたところ、何もしなくても自動的に登録されていました。
      ss-1.png
    • 他のIDも登録する場合は、対処が必要という話なのでしょうか。

結論

  • 特に対処は不要でした。

AppTrackingTransparency 対応

  • AdSupport.frameworkおよびAppTrackingTransparency.frameworkを手動で導入する必要があると書かれている記事がありましたが、前者は何もしなくても導入されていました。
    • PostProcessBuildで、PBXProject.AddFrameworkToProjectを使用することでXcodeプロジェクトに導入できます。
  • ATTを呼び出すと、IDFAの使用許可を求めるシステムダイアログが表示されます。
    • 呼び出す際に説明文を渡すようになっていて、そのローカライズが必要になります。

ネイティブプラグインで ATT を呼び出す

Assets/Plungins/iOS/AttService.mm
#import <Foundation/Foundation.h>
#import <AppTrackingTransparency/ATTrackingManager.h>

extern "C" { 

	/// <summary>ATT 許可状態取得</summary>
	/// <return>
	/// ATTracking Manager.Authorization Status
	///  0: Not Determined, 1: Restricted, 2: Denied, 3: Authorized (, -1: No Needs)
	///  https://developer.apple.com/documentation/apptrackingtransparency/attrackingmanager/authorizationstatus
	/// </return>
	int GetTrackingAuthorizationStatus() {
		if (@available(iOS 14, *)) {
			return (int)ATTrackingManager.trackingAuthorizationStatus;
		} else {
			return -1;
		}
	}

	/// <summary>コールバック型</summary>
	/// <param name="status">ATTracking Manager.Authorization Status</param>
	typedef void (*Callback)(int status);

	/// <summary>ATT 許可要求</summary>
	/// <param name="callback">コールバック関数</param>
	void RequestTrackingAuthorization(Callback callback) {
		if (@available(iOS 14, *)) {
			[ATTrackingManager requestTrackingAuthorizationWithCompletionHandler:^(ATTrackingManagerAuthorizationStatus status) {
				if (callback != nil) {
					callback((int)status);
				}
			}];
		} else {
			callback(-1);
		}
	}

}

Appleのフレームワークを呼び出すネイティブコードです。

参考

プラグインを呼び出してコールバックから結果を得る

Assets/Scripts/AttService.cs
#if UNITY_IOS
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;

public static class AttService {
	private static TaskCompletionSource<bool> AttTcs; // boolを返すタスク
	private static SynchronizationContext Context; // Unityのスレッドの同期環境を保持

	/// <summary>クラス初期化</summary>
	static AttService () {
		Context = SynchronizationContext.Current;
	}

	/// <summary>ATT 許可状態取得 プラグイン</summary>
	[DllImport ("__Internal")]
	private static extern int GetTrackingAuthorizationStatus ();

	/// <summary>コールバック型</summary>
	private delegate void OnCompleteStatusCallback (int status);

	/// <summary>ATT 許可要求 プラグイン</summary>
	/// <param name="callback">コールバック関数</param>
	[DllImport ("__Internal")]
	private static extern void RequestTrackingAuthorization (OnCompleteStatusCallback callback);

	/// <summary>ATT の同意状態を取得する</summary>
	/// <return>true: Authorized or No Needs, false: Denied or Restricted, null: Not Determined</return>
	public static bool? GetIOSTrackingAuthorizationStatus () {
#if UNITY_EDITOR
		return true;
#else
        switch (GetTrackingAuthorizationStatus ()) {
            case -1: // No Needs
            case 3: // Authorized
                return true;
            case 0: // Not Determined
                return null;
            default:
                return false;
        }
#endif
	}

	/// <summary>ATT 許可を要求する</summary>
	/// <return>(Authorized or No Needs)なら真を結果とする非同期タスク</return>
	public static Task<bool> RequestTrackingAuthorization () {
#if UNITY_EDITOR
		return Task.FromResult (true);
#else
        AttTcs = new TaskCompletionSource<bool> ();
        RequestTrackingAuthorization (OnRequestTrackingAuthorizationComplete);
        return AttTcs.Task;
#endif
	}

	/// <summary>コールバックハンドラ</summary>
	[AOT.MonoPInvokeCallback (typeof (OnCompleteStatusCallback))]
	private static void OnRequestTrackingAuthorizationComplete (int status) {
		if (AttTcs != null) {
			Context.Post (_ => {
				switch (status) {
					case -1: // No Needs
					case 3: // Authorized
						AttTcs.TrySetResult (true);
						break;
					default:
						AttTcs.TrySetResult (false);
						break;
				}
			}, null);
		}
	}

}
#endif

C#で記述された、ネイティブ・プラグインのラッパーです。

参考

ATT を介してユーザーから許可を得る

#if UNITY_IOS
    var status = AttService.GetIOSTrackingAuthorizationStatus ();
    if (!status.HasValue) {
        status = await AttService.RequestTrackingAuthorization () as bool?;
    }
#endif

請求に対する結果(許可の有無)は使いません。
この後、AdMobがIDFAを請求した際に、許可があれば取得でき、なければ取得できないというだけです。IDFAが得られないとSKAdNetworkが使われます。

参考

ビルド後に Xcode プロジェクトへ追記する

Assets/Editor/iOS/PostBuildProcessForIOS.cs
#if UNITY_IOS
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.IO;
using UnityEngine;
using UnityEditor;
using UnityEditor.Callbacks;
using UnityEditor.iOS.Xcode;

public static class PostBuildProcessForIos {
    private const string ATT_FRAMEWORK = "AppTrackingTransparency.framework";
    private const string ATT_USAGE = "NSUserTrackingUsageDescription";
    private const string LOCALIZATION_ARRAY_KEY = "CFBundleLocalizations";
    private const string TargetDirectory = "Unity-iPhone Tests";
    private const string TargetFolder = "Unity-iPhone Tests";
    private static readonly string LocalizationFolderPath = Path.Combine (Application.dataPath, "Editor/iOS/Localizations");

	/// <summary>ビルド後処理</summary>
	[PostProcessBuild]
	public static void OnPostProcessBuild (BuildTarget buildTarget, string buildPath) {
		if (buildTarget != BuildTarget.iOS) { return; } // iOS専用
		#region edit project
		// read Project
		var pbxPath = PBXProject.GetPBXProjectPath (buildPath);
		var project = new PBXProject ();
		project.ReadFromFile (pbxPath);
		// ATT
		project.AddFrameworkToProject (project._GetUnityFrameworkTargetGuid (), ATT_FRAMEWORK, true);
		#region localization
		// アセットのローカライズフォルダをプロジェクトへコピーし、リージョン・リストを作る
		var targetMainGuid = project._GetUnityMainTargetGuid ();
		var targetFrameworkGuid = project._GetUnityFrameworkTargetGuid ();
		var localizationFolders = Directory.GetDirectories (LocalizationFolderPath);
		var regions = new List<string> (); // リージョン・リスト
		var targetPath = Path.Combine (buildPath, TargetDirectory); // コピー先パス
		for (int i = 0; i < localizationFolders.Length; i++) {
			var folderName = Path.GetFileName (localizationFolders [i]); // フォルダ名
			var regionName = Path.GetFileNameWithoutExtension (localizationFolders [i]); // リージョン名
			CopyWithoutMeta (localizationFolders [i], targetPath); // アセットのフォルダをプロジェクトへコピー
			regions.Add (regionName); // リストにリージョンを追加
			// コピーしたフォルダをプロジェクトに登録
			var guid = project.AddFolderReference (Path.Combine (targetPath, folderName), Path.Combine (TargetFolder, folderName));
			if (targetMainGuid != null) {
				project.AddFileToBuild (targetMainGuid, guid);
			}
			project.AddFileToBuild (targetFrameworkGuid, guid);
		}
		#region edit project with string
		// convert to string
		var pbxstr = project.WriteToString ();
		// modify knownRegions
		pbxstr = Regex.Replace (pbxstr, @"(?<!\w)(developmentRegion\s*=\s*)English;", "$1en;", RegexOptions.Singleline | RegexOptions.IgnoreCase);
		pbxstr = Regex.Replace (pbxstr,
			@"(?<!\w)(knownRegions\s*=\s*\()((?:[\s\w\-""]+,)*)?(\s*\)\s*;)",
			$"$1\n{string.Join ("\n", regions.ConvertAll (region => $"\t\t\t\t{region},"))}\n$3",
			RegexOptions.Singleline | RegexOptions.IgnoreCase);
		// convert from string
		project.ReadFromString (pbxstr);
		#endregion
		#endregion
		// write Project
		project.WriteToFile (pbxPath);
		#endregion
		#region edit plist
		// read Info.plist
		var plistPath = Path.Combine (buildPath, "Info.plist");
		var plist = new PlistDocument ();
		plist.ReadFromFile (plistPath);
		#region localization
		// リージョンを登録
		var lArray = plist.root.CreateArray (LOCALIZATION_ARRAY_KEY);
		foreach (var region in regions) {
			lArray.AddString (region);
		}
		#endregion
		// write Info.plist
		plist.WriteToFile (plistPath);
		#endregion
	}

	/// <summary>指定のアセットフォルダを('.meta'を除外して)コピーする</summary>
	/// <param name="spath">コピー元フォルダのフルパス (ノーチェック)</param>
	/// <param name="ddir">コピー先ディレクトリ (ノーチェック)</param>
	private static void CopyWithoutMeta (string spath, string ddir) {
		var dpath = Path.Combine (ddir, Path.GetFileName (spath));
		if (!Directory.Exists (dpath)) {
			Directory.CreateDirectory (dpath);
		}
		foreach (var file in Directory.GetFiles (spath)) {
			if (!file.EndsWith (".meta")) {
				var dest = Path.Combine (dpath, Path.GetFileName (file));
				File.Copy (file, dest, true);
			}
		}
	}

    /// <summary>Returns GUID of the framework target in Unity project.</summary>
    private static string _GetUnityFrameworkTargetGuid (this PBXProject project) {
#if UNITY_2019_3_OR_NEWER
        return project.GetUnityFrameworkTargetGuid ();
#else
        return project.TargetGuidByName (PBXProject.GetUnityTargetName ());
#endif
    }
    /// <summary>Returns GUID of the main target in Unity project.</summary>
    private static string _GetUnityMainTargetGuid (this PBXProject project) {
#if UNITY_2019_3_OR_NEWER
        return project.GetUnityMainTargetGuid ();
#else
        return null;
#endif
    }

}
#endif

Xcodeでの手作業を避けるため、引き渡すべき設定を行います。

  • ATT
    • PBXプロジェクトへ、フレームワークの導入を設定
  • ローカライズ
    • PBXプロジェクトへ、アセット中に用意したリソースとリージョンを登録
    • Info.plistへ、リージョンを登録
参考

ローカライズ用ファイル

/Assets/Editor/iOS/Localizations/en.lproj/InfoPlist.strings
/* Localized versions of Info.plist keys */

CFBundleDisplayName = "AppName";
NSUserTrackingUsageDescription = "This identifier will be used to deliver personalized ads to you.";
/Assets/Editor/iOS/Localizations/ja.lproj/InfoPlist.strings
/* Localized versions of Info.plist keys */

CFBundleDisplayName = "アプリ名";
NSUserTrackingUsageDescription = "この識別子は、パーソナライズされた広告を配信するために使用されます。";
/Assets/Editor/iOS/Localizations/ja-JP.lproj/InfoPlist.strings
/* Localized versions of Info.plist keys */

CFBundleDisplayName = "アプリ名";
NSUserTrackingUsageDescription = "この識別子は、パーソナライズされた広告を配信するために使用されます。";

概略のフロー

ATTフロー.png

App のプライバシーに関する質問への回答

  • 公式ガイド(GoogleUnityApple)に従って回答します。
  • Googleのガイドでは、読み替えが必要になります。
    • 例えば、Googleの言う「IP アドレス: デバイスのおおよその位置の推定に使われる場合があります。」は、「おおよその場所をユーザの個人情報に関連付けてサードパーティ広告とトラッキング目的に使用する」と解釈します。
    • こちらの記事「AdMob利用時の「Appのプライバシー」の入力方法虎の巻」では、解釈の結果がまとめられています。
  • Unityのガイドでは、Appleの質問毎に「収集するか? (Collected?)」、「ユーザに関連付けるか? (Linked to user?)」、「使途 (Purpose)」がまとめられているので、そのまま使えます。
    • トラッキングの有無は、「tracking」が使途に含まれるかどうかで判別できます。

参考資料

Apple公式

App StoreでのAppのプライバシーに関する詳細情報の表示

https://developer.apple.com/jp/app-store/app-privacy-details/

App のプライバシーの管理 (App Store Connect ヘルプ)

https://help.apple.com/app-store-connect/?lang=ja#/dev1b4647c5b

Google公式

Unity用 AdMob Plug-in

https://github.com/googleads/googleads-mobile-unity/

Google Mobile Ads Unity Plugin v5.4.0 ~ Google Mobile Ads iOS SDK 7.68.0

Apple の App Store データ開示要件に備える

https://developers.google.com/admob/ios/data-disclosure

このガイドでは、7.68.0 以降の Google Mobile Ads SDK のデータ収集での慣行を説明

iOS 14 以降に備える

前提条件: Google Mobile Ads SDK 7.64.0 以降

AdMobでSKAdNetworkに対応する

https://developers.google.com/admob/ios/ios14#skadnetwork

App Tracking Transparency で許可をリクエストする

https://developers.google.com/admob/ios/ios14#request

Google Mobile Ads SDK に同意を転送する

https://developers.google.com/admob/unity/eu-consent?hl=ja#forward_consent_to_the_google_mobile_ads_sdk

パーソナライズされていない広告だけをリクエストする
AdRequest request = new AdRequest.Builder().AddExtra("npa", "1").Build();

Unity公式

Unity Analytics

http://documentation.cloud.unity3d.com/en/articles/4694233-apple-nutritional-info-unity-analytics

Unity IAP

http://documentation.cloud.unity3d.com/en/articles/4694224-apple-nutritional-info-unity-iap

Unity Ads

https://unityads.unity3d.com/help/ios/apple-privacy-survey

UnityEditor.iOS.Xcode.PBXProject (2018.4)

https://docs.unity3d.com/ja/2018.4/ScriptReference/iOS.Xcode.PBXProject.html

UnityEditor.iOS.Xcode.PBXProject (2019.3)

https://docs.unity3d.com/ja/2019.3/ScriptReference/iOS.Xcode.PBXProject.html

UnityEditor.iOS.Xcode.PBXProject (2019.4)

https://docs.unity3d.com/2019.4/Documentation/ScriptReference/iOS.Xcode.PBXProject.html

Unity Data Privacy プラグインの使用

https://docs.unity3d.com/ja/2019.4/Manual/UnityAnalyticsDataPrivacy.html

一般記事

以下の記事を参考にさせていただきました。
どうもありがとうございました。

その他

おわりに

  • この記事は、あくまでも私の理解です。
  • この記事のコードは基本的に借り物です。
    • ただし、多少の手を入れている場合があります。
  • お気づきの点や疑わしい点、あるいは解りづらいところなどございましたら、コメントやリクエストをお寄せいただけると助かります。
  • 最後までお読みいただき、どうもありがとうございました。

Discussion