SwiftUIアプリにUaaLを組み込む ── Viewのライフサイクル管理方法の紹介

こんにちは!AnotherBallのiOSチームです。

以前の記事では、モバイルチームがAvvyのマルチリポジトリ構成を紹介しました。Kotlin Multiplatform (KMP) と Unity as a Library (UaaL)のビルド、配布、そして5つのリポジトリ間の連携について、全体像をお伝えしました。

Avvyの技術面で面白いところはUaaLとNativeの自然な組み込みにあると思います。今回の記事では、iOSに焦点を絞って、SwiftUIアプリの中にどのようにUaaLの画面を管理しているかを解説します。

前提:UnityとNativeの役割

Avvyの設計思想として、Unityは2Dアバターに関する機能のみを持つようにしています。Unityはアバターの描画やアバターのカスタマイゼーション(着せ替え)のUIを担っています。それ以外の機能やUIはNative側で実装して、Nativeの機能を最大限に使えるようにし、配信アプリらしい体験を提供できるように設計しました。

UaaLの制約:インスタンスは1つだけ

UaaLを使う上で大きな制約があります。Unityランタイムの複数インスタンスの読み込みはサポートされていないため、UaaLのViewは画面上に1つしか表示できません。同時に2つ表示しようとすると、片方が描画されません。

Avvyでは配信画面、アバターホーム、ガチャなど、複数の画面でアバターを表示しています。そのため、画面遷移のたびにUaaLのViewを付け替え、二つ同時に画面に存在しないようにする必要があります。しかし、各画面でこのライフサイクルを意識するのは煩雑で、意図しない不具合の可能性が高まります。

そこで、SwiftUIで専用View「UnityView」を作成して、管理を集約し、各画面からは通常のViewと同じ感覚で使えるようにしました。

配信画面の構成

例として配信画面を取り上げて解説します。配信画面ではUnityは一番下のレイヤーでアバターのレンダリングのみを担当し、NativeUIがその上にオーバーレイされています。

グレー色はUnityのアバターの描画領域で、黄色がNativeのオーバーレイです。Unityの領域はAvvyのiOSアプリではUnityViewと名付けています。SwiftUI側から見ると、通常のViewと同じようになります:

1
2
3
4
5
6
7
8
9
10
UnityView(displayType: .liveStream) // UnityでロードしたいSceneを指定
.frame(maxWidth: .infinity, maxHeight: .infinity)
.ignoresSafeArea() // セーフエリアを含めて全画面描画
.overlay {
if viewModel.isSceneLoading {
LoadingOverlay() // ローディングインジケーター
} else {
overlayContent // Nativeボタンやコメントリストなど
}
}

Unityのライフサイクルを一切気にせず、画面を開く・閉じるだけでアバターの表示・非表示が切り替わります。

UnityViewの実装

UnityViewUIViewControllerRepresentableで、内部にUnityViewControllerを持っています。なぜUIKitのViewControllerが必要かというと、UnityFrameworkが提供する描画ViewがUIKitのUIViewだからです。

例えばアバターホーム画面から配信画面をモーダルで表示した場合、UnityのViewを自動で最前の画面に付け替える必要があります。UnityViewControllerviewWillAppear/viewWillDisappearのライフサイクルでこれを実現しています:

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
28
29
30
31
32
33
34
35
36
37
38
public struct UnityView: UIViewControllerRepresentable {
public let displayType: DisplayType

public func makeUIViewController(context: Context) -> UnityViewController {
return UnityViewController(displayType: displayType)
}
}

public final class UnityViewController: UIViewController {
// UaaLが提供するUnityのView。記事用に簡略化しています。
// シングルトンなので、全画面で同じインスタンスを使い回しています。
private var unityView: UIView = UnityFramework.shared.rootView

public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
addUnityView() // Viewの追加
}

public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
removeUnityView() // Viewの削除
}

private func addUnityView() {
view.insertSubview(unityView, at: 0) // UnityのViewを最下層に追加
unityView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
unityView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
unityView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
unityView.topAnchor.constraint(equalTo: view.topAnchor),
unityView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}

private func removeUnityView() {
unityView.removeFromSuperview()
}
}

画面が表示されるとき(viewWillAppear)にUnityのViewを追加し、非表示になるとき(viewWillDisappear)にViewを外します。極めてシンプルな実装ですが、これだけで UnityのUIViewが一つのみ存在することを担保できます。

なお、View入れ替え処理と同時に、AvvyアプリではUnityの一時停止、再開も行なって、バッテリー消費とリソース負荷を抑えています。

まとめ

UaaLをNativeアプリに組み込む際、「同時に表示できるViewは1つだけ」という制約がありますが、UIViewControllerのライフサイクルを使ってViewの付け替えとリソース管理を自動化し、SwiftUIの UnityView としてラップすることで、それらを意識することなくアバター表示ができるようにしました。この構成により、AvvyはアバターアプリでありながらNativeアプリとしての操作感を保てています。

次回は、本記事に登場した「DisplayType」を使ってUnityに指定のSceneをロードさせる仕組みなど、NativeとUnityがどのようにコミュニケーションをしているかについてご紹介したいと思います。

We’re Hiring

AnotherBallでは、テスタブルでメンテナブルなアーキテクチャを大切にしており、同じ志を持つエンジニアを常に探しています。こうした仕事に興味があれば、ぜひお話しましょう!

AnotherBall Careers