Unityとネイティブを型安全につなぐ ── 共通のAPI定義からC#・Kotlin・Swiftのコードを生成

こんにちは!AnotherBallモバイルチームの@apippoです。UnityとAndroid・iOSアプリの間で呼び出しを受け渡す「ブリッジ」のコードを生成するツールを作りました。C#で記述した共通のAPI定義をもとに、Unity、Android、iOS向けのAPIを生成します。

以前の記事では、ゲーム開発とネイティブアプリ開発の経験がAvvyでどう交わっているかを、Davideが紹介しました。

私もゲーム開発出身で、これまでUnity/C#、Unreal/C++、Kotlin、Swiftを扱ってきました。Unity、Android、iOSをつなぐブリッジ作りは、そうした経験をそのまま生かせるテーマでした。

この記事では、Unityとネイティブアプリの間の通信を取り上げます。C#、Kotlin、SwiftにまたがるブリッジAPIを、手作業で揃え続ける手間をなくした話です。

なぜコード生成が必要だったのか

Avvyでは、Unity as a Library(UaaL)を使って、AndroidとiOSのネイティブアプリにUnityを組み込んでいます。両プラットフォームでの組み込み方は、Unityが公開している公式UaaLサンプルで確認できます。

アバターの描画とその関連機能はUnityが受け持ち、それ以外の画面や機能はネイティブで実装しています。iOSでのこの構成の扱い方は、SwiftUIアプリにUaaLを組み込む ── Viewのライフサイクル管理方法の紹介で紹介しています。

この役割分担はAvvyに合っていますが、開発上の課題もあります。Unity、Android、iOSの間では頻繁にやり取りが発生するため、C#、Kotlin、SwiftにまたがるブリッジAPIを常に揃えておかなければなりません。

Native --> Unity の基本はメッセージ送信

UaaLには、ネイティブコードからUnityのメソッドを呼び出す簡単な仕組みが用意されています。

Androidでは、UnitySendMessageでUnityのGameObjectにあるメソッドを呼び出します。

1
2
3
4
5
6
// Kotlin
UnityPlayer.UnitySendMessage(
"AvatarRoot",
"SetExpression",
"happy",
)

iOSでは、UnityFrameworkのメッセージAPIで同じように呼び出します。Swiftで書くと次のとおりです。

1
2
3
4
5
6
// Swift
unityFramework.sendMessageToGO(
withName: "AvatarRoot",
functionName: "SetExpression",
message: "happy"
)

Unity側では、コンポーネントのメソッドでこの呼び出しを受け取ります。

1
2
3
4
5
// C#
void SetExpression(string expression)
{
...
}

小さなブリッジなら、これだけでも十分です。ネイティブアプリから送るのは、GameObject名、メソッド名、それに文字列1つのペイロード(送るデータ)だけです。

UnitySendMessageとUnityFrameworkのメッセージAPIは、どちらも名前や値を文字列で指定するため、型安全ではありません。"AvatarRoot"が存在するか、"SetExpression"という名前が変わっていないか、"happy"が正しい値かは、KotlinやSwiftのコンパイラでは検証できません。

さらに、送信先のGameObjectは、メッセージを送る時点でアクティブになっている必要があります。つまり、ネイティブコードの動作がUnityのシーンの状態に左右されます。ペイロードを文字列1つから構造化したアバターの状態に変えるなら、シリアライズ形式もUnity、Android、iOSの間で取り決めておかなければなりません。

Native <-- Unity にはプラットフォームごとの接続コードが必要

逆方向のNative <-- Unityは、プラットフォームごとに書き方が違います。Androidでは、UnityからAndroidJavaClass経由でネイティブコードを呼び出します。

1
2
3
4
5
6
7
// Android向けC#
#if UNITY_ANDROID
using (var bridge = new AndroidJavaClass("com.example.NativeAvatarBridge"))
{
bridge.CallStatic("openAvatarPicker", selectedAvatarId);
}
#endif

Kotlin側には、呼び出しを受けるstaticメソッドを用意します。

1
2
3
4
5
6
7
// Kotlin
object NativeAvatarBridge {
@JvmStatic
fun openAvatarPicker(selectedAvatarId: String) {
...
}
}

iOSでは、同じ呼び出しをネイティブプラグインの関数として実装します。

1
2
3
4
5
// iOS向けC#
#if UNITY_IOS
[DllImport("__Internal")]
private static extern void openAvatarPicker(string selectedAvatarId);
#endif

Swift側では、Unityがインポートする名前とまったく同じシンボル名で関数を公開します。

1
2
3
4
5
6
// Swift
@_cdecl("openAvatarPicker")
func openAvatarPicker(_ selectedAvatarIdPointer: UnsafePointer<CChar>) {
let selectedAvatarId = String(cString: selectedAvatarIdPointer)
...
}

戻り値が要ると往復のやり取りになる

ここまでのメッセージAPIは一方向です。戻り値を受け取るには、反対方向からもう一度呼び出してもらう必要があります。

例えばNative --> Unityで、ネイティブコードから次のように値を取得したいとします。

1
2
// Kotlin
val state = unityBridge.getAvatarState()

UnitySendMessageやUnityFrameworkのメッセージAPIは、Unityへメッセージを送るだけで、結果は返しません。手作業で実装するなら、例えば次のような流れになります。

  1. ネイティブ側でリクエストIDを作る
  2. そのリクエストIDに対応づけて、保留中のコールバックを保存する
  3. リクエストIDをUnityへ送る
  4. Unityが値を計算する
  5. Unityが同じリクエストIDと結果をネイティブ側へ返す
  6. ネイティブ側で保留中のコールバックを探し、元の呼び出しを再開する

ネイティブ側でこの流れを組むと、次のようなコードになります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Kotlin。記事用に簡略化した疑似コード
suspend fun getAvatarState(): AvatarState =
suspendCancellableCoroutine { continuation ->
// 後から再開できるように、中断したKotlin呼び出しを保持する
val requestId = pendingAvatarStateRequests.store(continuation)

// UnityへリクエストIDを送る。この呼び出しはAvatarStateを返さない
UnityPlayer.UnitySendMessage(
"AvatarRoot",
"GetAvatarState",
requestId,
)

// 呼び出し元がキャンセルされたら、保留中のリクエストを削除する
continuation.invokeOnCancellation {
pendingAvatarStateRequests.remove(requestId)
}
}

// Unityが結果を作り終えた後に呼び出される
fun onAvatarStateResult(requestId: String, payload: String) {
// リクエストIDから元のKotlin呼び出しを探す
val continuation = pendingAvatarStateRequests.remove(requestId) ?: return

// 元のgetAvatarState()がここで値を返せるようになる
continuation.resume(decodeAvatarState(payload))
}

この例では、タイムアウト、デコードエラー、重複レスポンス、Unity終了時の後処理を省いています。本番で使うには、こうした処理も欠かせません。

Unity側には、結果を返すための処理を用意します。

1
2
3
4
5
6
7
8
9
10
// C#
// NativeからUnitySendMessage経由で呼び出される
void GetAvatarState(string requestId)
{
// Nativeが要求した値を作る
var payload = avatarController.ExportStateJson();

// 同じリクエストIDを使ってNativeへ結果を返す
NativeAvatarBridge.OnAvatarStateResult(requestId, payload);
}

逆に、Unityがネイティブ側から値を受け取るNative <-- Unityでも、同じように往復の処理が必要です。

1
2
3
// C#
var selectedAvatarId = await NativeAvatarBridge.OpenAvatarPicker(currentAvatarId);
avatarController.ApplyAvatar(selectedAvatarId);

OpenAvatarPickerの戻り値は、Unity側で使っている非同期タスク型のUniTask<string>です。ネイティブのピッカー(選択画面)を開いても、ユーザーが選び終わるまで値は返りません。

ネイティブ側はUIを表示してユーザーの操作を待ち、選ばれた値を後からUnityへ返します。Unity側にも、完了待ちのタスクソース、キャンセル処理、レスポンスを受け取るメソッドが必要です。同じ呼び出しを同時に複数受け付けるかどうかで、リクエストIDで対応づけるか、メソッドごとに完了待ちを1つだけ持つかが変わります。

ここまで来ると、ブリッジはもう単なるメッセージ送信ではありません。リクエストID、保留中の呼び出し、シリアライズしたレスポンス、完了時のコールバックまで扱うことになります。作っているのは、RPC(Remote Procedure Call、別の実行環境の関数を呼び出す仕組み)に近いものです。

大変なのは、Unityとネイティブの間で値を渡すこと自体ではありません。戻り値が要る呼び出しのたびに、リクエストとレスポンスを管理する仕組みを作らなければならないことです。

機能が増えるほど手間も増える

ブリッジのメソッドが1つだけなら、ここまでの処理を手で書いても何とかなります。しかし、アプリのあちこちでブリッジを使うようになると、負担は一気に大きくなります。

Avvyでは、次のような用途でブリッジを使っています。

  • ネイティブ画面からUnityのViewを切り替える
  • 配信レイアウト情報をUnityへ送る
  • ライブ配信を開く前にUnityからアバター状態を取得する
  • Unityからネイティブのモーダルを開く
  • Unity上の操作に応じてネイティブUIを更新する

この規模でブリッジのコードを手で書くと、次の問題が出てきます。

  • メソッド名やオブジェクト名を、ただの文字列で指定することになる
  • C#、Kotlin、Swiftの間で名前を揃え続けなければならない
  • 同じUnityの機能に対して、AndroidとiOSで別々の接続コードが要る
  • 構造化したペイロード、コールバック、非同期のレスポンスを扱う処理を毎回作らなければならない

コード生成がなければ、レビューでは次のような点も確認しなければなりません。

  • Kotlinのメソッド名がC#と一致しているか
  • Swiftが同じペイロードのフィールドをエンコードしているか
  • Kotlinが、保留中のコールバックのうち正しいものを再開しているか
  • Swiftも同じように非同期のレスポンスを処理しているか

レビューで本当に確認したいのは、こうした機械的な整合性ではありません。

ブリッジの通信処理が今後も増えることは分かっていました。そこで最初からコード生成に力を入れ、ブリッジのAPI定義はUnityのC#で一か所にだけ書くと決めました。

ブリッジのAPIはC#で定義する

考え方は、バックエンド開発でよく使われるコード生成と同じです。Protocol Buffers(.proto)やOpenAPI(.yml)はスキーマファイルからコードを生成しますが、私たちのブリッジでは.csファイルに書いたC#の定義から生成します。

[NativeCodegen]を付けたC#の定義が、唯一の正となる定義(Single Source of Truth)です。[NativeCodegen]は独自に作った属性です。ブリッジで使うインターフェースや、DTO(Data Transfer Object、データ受け渡し用のクラス)、列挙型(enum)などのデータ型に付けます。

コード生成は2か所で動きます。1つはC#のコンパイル時で、Roslyn(.NETのコンパイラー基盤)のソースジェネレーターが対象の定義を読み取り、C#側のブリッジ実装を生成します。もう1つはCLIで、同じ定義からKotlin、Swift、各プラットフォームのプラグインファイルを生成します。どちらも同じコードベースで同じ定義を解析するので、スキーマ用の別プロジェクトを同期させる必要はありません。

エンジニアが管理するのはC#のAPI定義1つだけで、あとはUnity、Android、iOSそれぞれのアプリ固有の処理を手で書きます。プラットフォーム間の決まりきった通信処理は、生成されたコードに任せます。

まず、ブリッジでやり取りするデータ型を定義します。文字列や数値などのプリミティブ値はそのまま扱い、複数の値を持つペイロードはDTOにまとめます。

1
2
3
4
5
6
7
8
9
// C#
[NativeCodegen]
[Serializable]
public class AvatarState
{
public string avatarId;
public string expression;
public bool isVisible;
}

ネイティブ向けブリッジジェネレーターは、このAvatarStateの定義からKotlinとSwiftのモデルを生成します。

Kotlinでは次の形になります。

1
2
3
4
5
6
7
// 生成されたKotlin
@Serializable
data class AvatarState(
val avatarId: String,
val expression: String,
val isVisible: Boolean,
)

Swiftでは次の形になります。

1
2
3
4
5
6
7
8
9
10
11
12
// 生成されたSwift
public final class AvatarState: Codable, Sendable {
public let avatarId: String
public let expression: String
public let isVisible: Bool

public init(avatarId: String, expression: String, isVisible: Bool) {
self.avatarId = avatarId
self.expression = expression
self.isVisible = isVisible
}
}

C#の定義でフィールドを追加したり名前を変えたりすると、ネイティブ向けブリッジジェネレーターを実行した後、同じ変更がKotlinとSwiftの生成コードの差分にも現れます。

通信の定型コードは生成し、アプリ固有の処理は手で書く

ソースジェネレーターとネイティブ向けブリッジジェネレーターが、通信の定型コードを生成します。エンジニアが書くのは、API定義とアプリ固有の処理だけです。

図では、エンジニアが書くコードと、そこから生成されるブリッジのコードを色で分けています。オレンジ色はエンジニアが書くファイルで、C#のAPI定義と各プラットフォームの処理が入ります。青色は生成されるファイルで、Unity、Android、iOSの間の呼び出しやコールバックを中継します。

開発は次の4ステップで進めます。

  1. C#のAPI定義を追加・変更する
  2. ネイティブ向けブリッジジェネレーターを実行する
  3. 生成されたファイルを、API定義を変更した結果としてレビューする
  4. Unity、Android、iOSそれぞれの処理を手で実装する

通信の定型部分を生成するので、実装とレビューの手間が減り、言語間で定義がずれることも少なくなります。レビューでは、API定義の中身と各プラットフォームの処理に集中できます。

C#のシグネチャで呼び出し方向を表す

別のスキーマ言語を増やさずに、C#の定義を読むだけで呼び出しの方向まで分かるようにしました。方向は、C#のメンバーの書き方で決まります。

Native <-- Unity

Unityから呼び出す方向です。KotlinとSwiftでは、生成されたAPIにコールバックを登録しておき、そこで呼び出しを受け取ります。

  • 戻り値なし
    • C#ではvoid Method(...)
    • KotlinとSwiftにはコールバックを登録するAPIが生成される
  • NativeがRを返す
    • C#ではUniTask<R> Method(...)
    • KotlinとSwiftには、Rを返す非同期コールバックを登録するAPIが生成される

Native --> Unity

ネイティブから呼び出す方向です。生成されたKotlinやSwiftのメソッドを呼ぶと、Unity側のコールバックが実行されます。

  • 戻り値なし
    • C#では次のどちらか
      • Action Event { get; set; }
      • Action<T> Event { get; set; }
    • KotlinとSwiftにはUnity側のコールバックを呼び出すメソッドが生成される
  • UnityがRを返す
    • C#では次のどちらか
      • Func<UniTask<R>> Callback { get; set; }
      • Func<T, UniTask<R>> Callback { get; set; }
    • KotlinとSwiftにはRを返す非同期メソッドが生成される

コード生成ツールは、この4つの書き方から呼び出しの方向と戻り値を判断します。そのため、別のスキーマファイルは要りません。

次のIAvatarBridgeは、4つの書き方を1つずつ使った例です。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// C#
[NativeCodegen]
public interface IAvatarBridge
{
// Native <-- Unity。戻り値なし
void CloseAvatarPicker();

// Native <-- Unity。Nativeが値を返す
UniTask<string> OpenAvatarPicker(string currentAvatarId);

// Native --> Unity。戻り値なし
Action<string> SetAvatarExpression { get; set; }

// Native --> Unity。Unityが値を返す
Func<UniTask<AvatarState>> GetAvatarState { get; set; }
}

このC#インターフェースから、次のKotlin APIが生成されます。Unityから呼び出すメンバーはコールバックを登録するプロパティに、ネイティブから呼び出すメンバー(ActionやFunc)は関数になります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 生成されたKotlin
interface IAvatarBridge {
// Native <-- Unity。戻り値なし
var onCloseAvatarPicker: (suspend () -> Unit)?

// Native <-- Unity。Nativeが選択したアバターIDを返す
var onOpenAvatarPicker: (suspend (String) -> String)?

// Native --> Unity。戻り値なし
suspend fun setAvatarExpression(expression: String)

// Native --> Unity。生成されたKotlinの戻り値はAvatarState?
suspend fun getAvatarState(): AvatarState?
}

Swiftには、同じ構造のActorプロトコルが生成されます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 生成されたSwift
public protocol AvatarBridgeProtocol: Actor {
// Native <-- Unity。戻り値なし
func registerCloseAvatarPickerCallback(
_ callback: @escaping (@Sendable () async -> Void)) async

// Native <-- Unity。Nativeが選択したアバターIDを返す
func registerOpenAvatarPickerCallback(
_ callback: @escaping (@Sendable (String) async -> String)) async

// Native --> Unity。戻り値なし
func setAvatarExpression(_ expression: String) async

// Native --> Unity。UnityがAvatarStateを返す
func getAvatarState() async -> AvatarState
}

関数呼び出しがコールバックに変わる

次の図はAndroidの例です。Native --> Unityでは、手で書いたAndroidのコードが生成されたKotlin APIを呼び出し、Unity側の手で書いたコードがそれをコールバックとして受け取ります。Native <-- Unityはその逆です。iOSでも、生成されたSwift APIを経由する点は変わりません。

コード生成で揃えられるのは、C#、Kotlin、Swiftのメソッドのシグネチャとペイロードの型です。ブリッジの実行時の状態までは検証できません。呼び出す時点でUnityの準備ができているか、コールバックが登録済みか、ネイティブプラグインが読み込まれているかは、別に保証しなければなりません。

次は高頻度データ向けのブリッジ

この記事で扱ったブリッジでは、型安全性、非同期処理、プラットフォーム間の一貫性を重視しました。

Avvyには、フェイストラッキングのデータを高い頻度で送る、別のUnityブリッジもあります。求められる性能が違うため、こちらはまったく別の方法で作りました。

このブリッジではメッセージを送らず、ネイティブ側とUnityで共有するメモリ領域とバイナリレイアウトをあらかじめ決めておきます。ネイティブ側が最新のフェイストラッキングの値を書き込み、Unityは毎フレームその値を直接読み取ります。

ゲーム開発出身の私には、どこか懐かしいやり方です。この仕組みは別の記事で紹介する予定です。

We’re Hiring

AnotherBallでは、Kotlin、Swift、Unityをまたいでモバイルアプリを開発しています。ネイティブとUnityの境界を含め、複数の技術領域を行き来しながらアプリを作りたいエンジニアを募集しています。

AnotherBall 採用情報