iOS 14のApp Tracking Transparency対応を通して学ぶUnityのiOSプラグイン開発術
サイバーエージェントのゲーム・エンターテイメント事業部(SGE)に所属する子会社QualiArtsで、基盤周りを担当しているUnityエンジニアの石黒です。基盤の守備範囲は幅広く時にはネイティブプラグインを実装することもあるため、長年培ったそのノウハウを今回寄稿します。
本記事はQualiArtsの定期ブログ「QualiArts Tech Note」第4弾の記事となります。QualiArtsでは会社で使われている様々な技術の知見をブログで紹介しています。興味のある方は、QualiArtsとタグの付いている他の記事もチェックしてみてください。
QualiArts Tech Note
App Tracking Transparencyとは
App Tracking Transparency (以降ATT) とは、ユーザーにIDFA(広告ID)を取得するための承認をリクエストするためのフレームワークです。iOS 14から提供され始めた機能ですが、beta版の当初の仕様では、iOS 14以上の端末はIDFAの取得をするためにはアプリ毎にユーザーの承認が必ず必要となるオプトイン方式への破壊的な仕様変更だったため、その対応のためにこのATTフレームワークを利用することが求められました。広告やマーケティングの世界ではIDFAに大きく依存しているところがあるため、どのアプリ開発者もIDFAの取得のためにATT対応を急ピッチで進める必要がありました。
実際にはApple側がオプトイン方式の強制化を2021年以降に先送りすることを正式版をリリースする直前に発表したため、開発者としては時間の猶予が与えられた状態ではありますが、義務化までに対応を終わらせてリリースする必要があることには変わりありません。
Unityアプリを開発・運用しているチームもこのATT対応を行う必要があるため、まずは既存の実装がないかUnity公式の機能や公開されているライブラリなどを探すと思います。ところがiOSの新機能のような場合は、
- Unity側が機能を提供していない、もしくは利用するためにバージョンをアップデートする必要がある
- アセットストアに存在しない、もしくは有償で売られている
- GitHubなどの公開リポジトリで見つからない
ということが多々あり、既存の実装が使えないというケースも少なくないと思います。
そこで自力での実装を選択することに至った場合の方のために、本記事でUnityでのATT対応を行うための実装を通して、UnityにおけるiOSプラグインの開発術を紹介します。
iOSプラグインのビルド方法
Unityプロジェクトへの組み込み方
まずUnityにおいてネイティブプラグインの組み込み方は主に2種類あります。1つ目がソースファイルを直接Unityプロジェクトに組み込む方法です。2つ目が予めUnityの外でソースファイルをビルドしてライブラリとしてUnityプロジェクトに組み込む方法です。それぞれメリット/デメリットが存在します。
ソースファイルを組み込む方法
mファイル(Objective-C)かmmファイル(Objective-C++)を直接プロジェクトに配置します。古いUnityではネイティブプラグインとして認識するフォルダに指定(Plugins/iOS)がありましたが、現在ではその指定はなく自由に配置できます。慣習的にはPluginsフォルダを利用することが多いです。この方法のメリデメは以下の通りです。
メリット
- プラグインの開発やデバッグのPDCAがスムーズ
- プロジェクトと同じXcodeのバージョンを利用してビルドできる
- プラグインをビルドするための単体のXcodeプロジェクトが不要
デメリット
- Xcodeプロジェクト上でアプリをビルドする際に都度ソースファイルからビルドするためある程度時間がかかる
よほど大きいライブラリでなければビルドにかかる時間も無視できるレベルなので、小さいライブラリを開発する際によく用います。
ビルド済みのライブラリを組み込む方法
予め単体のXcodeプロジェクトでビルドしたaファイルをプロジェクトに配置します。ファイルの種類は違うものの、配置方法やインスペクタの設定はソースファイルの場合と同じです。メリデメはソースファイルの場合の裏返しになるので、大きいライブラリやUnity以外でも使えるライブラリとして開発している場合などに用います。
こちらの手法の注意点としては、アプリをbitcode対応する場合に互換性の問題で、ライブラリをビルドする際に使用したXcodeバージョンよりアプリをビルドする際のXcodeバージョンのほうが新しくしないとビルド時にエラーが発生します。サードパーティのライブラリなどを利用する際に引っかかりやすいので、そのような場合はXcodeのバージョンを上げるか、ライブラリのXcodeバージョンを下げる必要があります。
Objective-C vs Objective-C++ vs Swift
iOSのネイティブアプリを開発する際は、開発の生産性が高いSwiftが利用されることが多いですが、Unityのネイティブプラグインを開発する際はSwiftではなくObjective-C/C++が利用されることが多いです。それはC#からiOSネイティブの関数を呼び出す際に、Swiftを直接呼び出すことができなく、Objective-C/C++を経由させる必要があるためです。
ではObjective-CとObjective-C++のどちらを利用するかと言うと、Objective-C++を利用する方が何かと便利です。それはCとC++を比較した際に、CにはなくC++にしかない機能が多いためです。
ただしObjective-C++で記述する場合、C#から呼び出す関数を定義する際にextern “C”キーワードが必要となるので、忘れないように記述しましょう。これはC++では名前マングリングが行われることによって関数名がコンパイラーによってネームスペースやクラス名を含んだ形に変換されるためで、怠るとUnity側から呼び出すことができなくなります。
#ifdef __cplusplus
extern "C" {
#endif
void Hoge() {}
#ifdef __cplusplus
}
#endif
またObjective-C/C++という名前ではありますが、上記のように行単位でC/C++の構文が利用可能であるため、C/C++に慣れている人であれば少しばかりのObjective-C/C++独特の構文を覚えてしまえば書けるようになると思います。
依存ライブラリ(フレームワーク)へのリンク
iOSのネイティブプラグインを開発する際、Apple側が提供するフレームワークを利用することが多いです。例えばスマホゲーム開発時は課金機能を作ることが多いですが、iOSにおける課金機能はStoreKitと呼ばれるフレームワークを利用します。課金用のプラグインを開発したとするならば、そのプラグインを組み込んだアプリをビルドする際はStoreKitフレームワークにリンクした状態にしなければ、ビルド時に参照を解決できずにエラーになります。そのためUnityではXcodeプロジェクト出力時のフレームワークのリンクを補助するために、ソースファイルのインスペクタでリンクしたいフレームワークを選べるようになっています。
ただしATTフレームワークなどのようにAppleが新しく提供を開始したフレームワークの場合、Unity側の対応が反映されたバージョンでないと選択肢の中に出てこないので、自分でリンク設定を追加してあげる必要があります。Xcodeプロジェクトを出力する度にリンク設定を手動で行うのは非現実的なので、ポストビルド処理で自動的に処理するようにします。出力したXcodeプロジェクトの中にあるpbxファイルをPBXProjectで読み込み、AddFrameworkToProjectでリンク設定を追加します。なおATTフレームワークのように古いiOSに存在しないようなフレームワークを利用する場合は、weak引数をtrueにしてあげることで、実行時に存在しなくても動作するようにできます。
#if UNITY_IOS
using System.IO;
using UnityEditor.iOS.Xcode;
using UnityEditor;
using UnityEditor.Callbacks;
namespace iOS.Editor
{
public class PostBuildProcessForIosAtt
{
private const string ATT_FRAMEWORK = "AppTrackingTransparency.framework";
/// <summary>
/// ポストビルドでSign in with Appleに必要なフレームワークをリンクする
/// </summary>
[PostProcessBuild]
public static void OnPostProcessBuild(BuildTarget buildTarget, string buildPath)
{
if (buildTarget != BuildTarget.iOS)
{
return;
}
// pbxに AuthenticationServices.framework を追加する
var pbxPath = PBXProject.GetPBXProjectPath(buildPath);
var pbx = new PBXProject();
pbx.ReadFromFile(pbxPath);
string target = GetUnityMainTargetGuidWithCompatible(pbx);
pbx.AddFrameworkToProject(target, ATT_FRAMEWORK, true);
pbx.WriteToFile(pbxPath);
}
/// <summary>
/// Unity 2019.3からXcodeプロジェクトの構成が変わるので、バージョンに応じて
ターゲットGUIDを取得する
/// </summary>
private static string GetUnityMainTargetGuidWithCompatible(PBXProject pbx)
{
#if UNITY_2019_3_OR_NEWER
return pbx.GetUnityFrameworkTargetGuid();
#else
return pbx.TargetGuidByName(PBXProject.GetUnityTargetName());
#endif
}
}
}
#endif
同期処理の書き方
まずは簡単な同期処理のサンプルとして、ATTフレームワークを利用してトラッキングの承認状態を取得するために、ATTrackingManagerのtrackingAuthorizationStatusプロパティを取得してみます。まずはネイティブ側は以下のように記述します。
#import <string.h>
#import <apptrackingtransparency attrackingmanager="" h="">
#ifdef __cplusplus
extern "C" {
#endif
int Sge_Att_getTrackingAuthorizationStatus()
{
if (@available(iOS 14, *)) {
return (int)ATTrackingManager.trackingAuthorizationStatus;
} else {
return -1;
}
}
#ifdef __cplusplus
}
#endif
ポイントとしてはenum値はそのままではC#に渡せないためにネイティブ側でintに変換してあげるのと、ATT自体がiOS 14以上でしか動作しないために動作するOSバージョンに応じて処理を変えてあげます。
これをC#側から呼び出すために、以下のように記述します。
#if UNITY_IOS
private const string DLL_NAME = "__Internal"; // iOSの場合は最終的にネイティブ実装もC#実装も1つのアセンブリに統合されるため、特別な指定になる
[DllImport(DLL_NAME)]
private static extern int Sge_Att_getTrackingAuthorizationStatus();
///
/// 現在のトラッキングの承認状態を取得する
///
/// トラッキングの承認状態
public static int GetTrackingAuthorizationStatus()
{
if (Application.isEditor)
{
return -1;
}
return Sge_Att_getTrackingAuthorizationStatus();
}
#endif
ネイティブ側で記述した関数と同じ名前でstatic externで定義してあげることで、XcodeでiOSアプリをビルドする際にネイティブ側が呼び出されるようになります。そのためビルドする前のエディタではもちろんネイティブの関数を呼び出すことができないため、多くの場合エディタ用のモック処理を書きます。その時に「#if UNITY_EDITOR」を利用して処理を分けることが一般的ですが、それをしてしまうとXcodeプロジェクトを出力するまでコンパイルエラーがあることに気づかない、リファクタリング時に漏れてしまうなどといった開発上の不都合が多いため、個人的にはApplication.isEditorを利用した処理の分岐がおすすめです。
非同期処理の書き方
いよいよユーザーにトラッキングの承認を依頼するための処理を書くことになるのですが、ATTrackingManagerのrequestTrackingAuthorizationWithCompletionHandlerメソッドは非同期処理となるため、非同期処理の結果をどうにかしてUnity側に渡す必要があります。
ネイティブにおける非同期処理のコールバック方法は主に2種類あります。1つ目はdelegate方式です。2つ目はUnity側が用意しているUnitySendMessageメソッドを利用する方法です。
delegate方式
iOSからUnityへのコールバックは、C#の関数をネイティブ側に呼び出してもらう形になります。通常のC#の関数をそのまま呼び出してもらうことはできませんが、幾つかの手続きを踏むことで呼び出してもらうことが可能となります。
1.呼び出してもらう関数はstaticにする
そのため同じ関数でコールバックを2つ以上同時に待つ際は工夫が必要となります
2.呼び出してもらう関数にAOT.MonoPInvokeCallback属性を付ける
3.C#とネイティブ側でコールバックされる関数のdelegateを定義する
4.C#のstatic関数をそのままネイティブ側に渡す(関数ポインタのイメージ)
#if UNITY_IOS
private const string DLL_NAME = "__Internal";
// 3. delegateの宣言
private delegate void OnCompleteCallback(int status);
[DllImport(DLL_NAME)]
private static extern void Sge_Att_requestTrackingAuthorization(OnCompleteCallback callback);
#endif
private static Action<int> _onComplete;
/// <summary>
/// AppTrackingTransparencyを利用してトラッキングの承認をユーザーに求める
/// </summary>
/// 完了時にコールバックされる
public static void RequestTrackingAuthorization(Action<int> onComplete)
{
if (Application.isEditor)
{
onComplete?.Invoke(0);
return;
}
#if UNITY_IOS
_onComplete = onComplete;
Sge_Att_requestTrackingAuthorization(OnRequestComplete); // 4. コールバックされたいC#の関数をネイティブ側に渡す
#endif
}
[AOT.MonoPInvokeCallback(typeof(OnCompleteCallback))] // 2. ネイティブから呼び出される関数はAOT.MonoPInvokeCallback属性を付与
private static void OnRequestComplete(int status) // 1. ネイティブから呼び出される関数はstatic
{
if (_onComplete != null)
{
_onComplete.Invoke(status);
_onComplete = null;
}
}
#import <string.h>
#import <apptrackingtransparency attrackingmanager="" h="">
#ifdef __cplusplus
extern "C" {
#endif
// 3. コールバックのdelegateを宣言
typedef void (*Callback)(int status);
void Sge_Att_requestTrackingAuthorization(Callback callback)
{
if (@available(iOS 14, *)) {
[ATTrackingManager requestTrackingAuthorizationWithCompletionHandler:^(ATTrackingManagerAuthorizationStatus status) {
if (callback != nil) {
callback((int)status);
}
}];
} else {
callback(-1);
}
}
#ifdef __cplusplus
}
#endif
注意したいのが、ネイティブ側がコールバックする際のスレッドが、Unityにおけるメインスレッドではない可能性があります。このままだとこのプラグインの利用者が困る可能性がありますので、その対策方法については後述します。
UnitySendMessage方式
delegate方式と比較して実装がお手軽に済むUnitySendMessageですが、私は一度も使ったことがありません。
UnitySendMessage("GameObjectName", "MethodName", "Message");
UnitySendMessageを使う場合には以下の制約が発生します、
- ネイティブ側がUnityのライブラリに依存する
- レスポンスを受け取るために予め決めた名前のGameObjectがシーン上に存在する必要がある
- 受け取るC#側のメソッドはMonoBehaviourのメソッドになる
- 渡せるパラメータがstring1つのみ
- UnitySendMessageを呼んだ次のフレームで必ずコールバックされる
このようにピュアC#ではなくUnity特有のC#実装が必要になるのと、GameObjectを事前に生成して維持しておく必要があるため、動作を完璧に保証することが難しいです。そのため基本的にはdelegate方式がおすすめです。
非同期処理のコールバックがメインスレッドでない問題
コールバックされる時に、それがメインスレッドではない場合どのような問題が起きるのでしょうか?それはコールバック処理の中で開発者がUnityのAPIを自然に呼び出すと、Unity APIの内部で検知してエラーが発生する可能性が高いためです。開発者としてはメインスレッドで戻ってくることを期待することが自然なので、この場合はプラグイン側でメインスレッドに処理を戻すのが適切です。
メインスレッドに処理を戻す方法としては主に2種類あります。Update方式とSyncrhonizationContext方式です。前者はUnityの標準的なイベントであり馴染み深いと思うのですが、後者が割と知られていないものの凄く便利なので、ぜひとも使っていただきたいです。
Update方式
MonoBehaviourのUpdateイベントを利用し、ネイティブからのコールバックで受け取った結果を一時的に保管しておき、Updateのタイミングで返すべき結果があれば利用者にコールバックするという流れです。UpdateはUnityではよく使うので極めて自然なのですが、幾つか問題があります。
それはコールバックのためにMonoBehaviourとGameObjectが必要になることです。つまりそれらの管理も付随して必要となります。またコールバックの状態に関わらずUpdateで結果があるかどうかを常にチェックする必要があるので、(気にする程ではないですが)僅かながらにパフォーマンスに影響があります。そのためUpdate方式はできれば避けたい実装方法です。
SyncrhonizationContext方式
C#にはSyncrhonizationContextというスレッド間でのコンテキストをやり取りするための基本的な仕組みがあります。こちらを利用することで、あるスレッドから別のスレッドに処理をディスパッチすることが可能です。
このSynchronizationContext自体は開発者側で実装をしないと何もしてくれないですが、幸いにもUnity側でSynchronizationContextを継承したUnitySynchronizationContextが実装されて使用できるようになっているため、こちらを利用することでどのスレッドからでもUnityのメインスレッドに処理を戻すことが可能です。
利用方法はとても簡単で、先ほどのC#のコードも以下のように表現できます。
#if UNITY_IOS
private const string DLL_NAME = "__Internal";
private delegate void OnCompleteCallback(int status);
[DllImport(DLL_NAME)]
private static extern void Sge_Att_requestTrackingAuthorization(OnCompleteCallback callback);
#endif
private static SynchronizationContext _context; // キャプチャするSynchronizationContextの入れ物
private static Action<int> _onComplete;
/// <summary>
/// AppTrackingTransparencyを利用してトラッキングの承認をユーザーに求める
/// <summary>
/// 完了時にコールバックされる
public static void RequestTrackingAuthorization(Action<int> onComplete)
{
if (Application.isEditor)
{
onComplete?.Invoke(0);
return;
}
#if UNITY_IOS
_context = SynchronizationContext.Current; // UnityのSynchronizationContextをキャプチャする(厳密にはこの関数がUnityのメインスレッドから呼び出されないとダメ)
_onComplete = onComplete;
Sge_Att_requestTrackingAuthorization(OnRequestComplete);
#endif
}
[AOT.MonoPInvokeCallback(typeof(OnCompleteCallback))]
private static void OnRequestComplete(int status)
{
if (_onComplete != null)
{
_context.Post(_ =>
{
_onComplete?.Invoke(status);
_onComplete = null;
}, null);
}
}
まとめ
iOSプラグインを開発するためには最低限Objective-C/C++とC#の2ファイルだけあれば事足りる(必要に応じてエディタ拡張でポストビルド処理も実装することもある)ので、Objective-C/C++の構文などの学習コストを除けば意外と敷居が低いのかなと思います。ぜひともネイティブエンジニアのいないUnityチームも積極的にiOSプラグインを開発してみてはいかがでしょうか?次回のまた私の機会があれば、iOS編に引き続きAndroidプラグインの開発術を寄稿する予定です。お楽しみに!
参考
Unity公式のiOSプラグイン開発ドキュメント: https://docs.unity3d.com/ja/current/Manual/PluginsForIOS.html