iOS、Unity as a LibraryでのAR機能導入ベストプラクティスを考えてみる
この記事はNAVITIME JAPAN Advent Calendar 2020の、5日目の記事です。
こんにちは、パクチーこそ至高、ちゃーりーです。ナビタイムジャパンでは『ここ地図』というアプリの開発を行っています。
iOSアプリ『ここ地図』には、カメラをかざして周囲のコンビニ、カフェ、トイレといったスポットを探すことができるAR機能がリリースされています。
今回はここ地図にAR機能を導入するにあたって学んだ、既存iOSアプリへのUnity as a Library導入ベストプラクティスについてお話していきたいと思います。
ただし、Unityでの開発方法、iOSアプリやSwiftの基礎事項についての説明は省略します。
Unity as a Libraryとは?
Unity as a LibraryとはUnityが提供している、ネイティブアプリやWindowsといった様々なプラットフォームで利用できるUnityライブラリです。
Unity as a Libraryを使えば、Unityで開発したゲームやAR機能をアプリ上で動かせるようになり、アプリ内の1画面として機能させることができます。もちろんAR機能・ネイティブアプリ間の連携も可能です。
公式のサンプルアプリ
基本的なUnity as a Libraryの導入方法などはUnityが公開しており、以下から読むことができます。
また、Unity公式によりUnity as a Libraryを用いたサンプルアプリがGithub上に公開されています。
ただし、サンプルアプリはObjective-Cでの実装になっていたりと、なにかとすぐに参考にするには難しい部分もあります。
公式サンプルのSwift化
実は「Unity as a Library iOS」などで調べてみるとすでに公式サンプルをSwift化した方がいらっしゃいます。
こちらを参考に、ここ地図で採用しているMVVMパターンへの適用、AppDelegateには極力コードを書かない、など実践を意識して、既存アプリへ導入できるようリファクタしていきたいと思います。
いざ、リファクタ
まずは全体の構成から説明していきます。
NativeCallsProtocol, UnityFrameworkListener
これらはUnityViewControllerを通してUnityとの連携はライフサイクルを扱うためのProtocolです。詳しい用途は後述します。
UnityViewController
公式サンプルでMainViewControllerが担当していた部分を基本的にUnityViewControllerが担っています。「Unity」をViewとして捉え、起動や停止、表示や非表示などを行うためこのように命名にしました。他にもUnity・ネイティブ間の連携などの入り口となります(後述)。
UnityViewModel
一般的なViewModelとしての役割で、Unityから受け取ったイベント、AppDelegate内のライフサイクルなどでViewControllerとの橋渡しを行います。
AppDelegate
ネイティブのライフサイクルの通知や、アクティブになっていたUIWindowの参照などで必要となります。
UnityViewController.swift
以下はUnityViewController.swift全体のソースコードです。個々に解説を行うので一気にすべて目を通す必要はありません。
// UnityViewController.swift
import UIKit
import UnityFramework
final class UnityViewController: UIViewController {
// MARK: - Property
private var viewModel: UnityViewModelType?
private var unityFramework: UnityFramework?
/// Note: 一度UnityをQuitするとアプリ生存期間中に再度起動することはできないので、基本的にはQuitしません
private var didQuit: Bool = false
// MARK: - View LifeCycle
override func viewDidLoad() {
super.viewDidLoad()
initUnityWindow()
showUnity()
}
// MARK: - ViewFunctions
func inject(viewModel: UnityViewModelType) {
self.viewModel = viewModel
}
}
// MARK: - UnityFrameworkListener
extension UnityViewController: UnityFrameworkListener {
func unityDidUnload(_ notification: Notification!) {
unityFramework?.unregisterFrameworkListener(self)
unityFramework = nil
hideUnity()
}
// UnityFramework.h にて UnityFrameworkListenerとして宣言されているため実装するが、Quitしないので基本呼ばれない。
func unityDidQuit(_ notification: Notification!) {
unityFramework?.unregisterFrameworkListener(self)
unityFramework = nil
didQuit = true
hideUnity()
}
}
extension UnityViewController {
func showUnity() {
if !unityIsInitialized() {
print("please init unity first")
return
}
unityFramework?.showUnityWindow()
}
func hideUnity() {
guard let delegate = UIApplication.shared.delegate as? AppDelegate else { return }
delegate.window?.makeKeyAndVisible()
}
func unloadUnity() {
if unityIsInitialized() {
self?.unityFramework?.unloadApplication()
}
}
func initUnityWindow() {
guard !unityIsInitialized(),
let delegate = UIApplication.shared.delegate as? AppDelegate else {
showUnity()
return
}
unityFramework = loadUnityFramework()
unityFramework?.setDataBundleId("com.unity3d.framework")
unityFramework?.register(self)
NSClassFromString("FrameworkLibAPI")?.registerAPIforNativeCalls(self)
unityFramework?.runEmbedded(withArgc: CommandLine.argc, argv: CommandLine.unsafeArgv, appLaunchOpts: delegate.launchOptions)
}
private func unityIsInitialized() -> Bool {
unityFramework != nil && (unityFramework?.appController() != nil)
}
private func loadUnityFramework() -> UnityFramework? {
let bundlePath: String = Bundle.main.bundlePath + "/Frameworks/UnityFramework.framework"
let bundle = Bundle(path: bundlePath)
if bundle?.isLoaded == false {
bundle?.load()
}
let unityFramework = bundle?.principalClass?.getInstance()
if unityFramework?.appController() == nil {
let machineHeader = UnsafeMutablePointer<MachHeader>.allocate(capacity: 1)
machineHeader.pointee = _mh_execute_header
unityFramework?.setExecuteHeader(machineHeader)
}
return unityFramework
}
}
// MARK: - NativeCallsProtocol
extension UnityViewController: NativeCallsProtocol {
func finishAr() {
hideUnity()
unloadUnity()
}
func sendMessage() {
unityFramework?.sendMessageToGO(withName: "LibraryHelper", functionName: "receiveMessage", message: "hello unity from nativeApp")
}
}
Unityの起動や停止といった操作から、UnityViewControllerが実装しているProtocolやメソッドについて順番に説明していきます。
Unityの起動、停止
Unityの起動はinitUnityWindow()というUnityViewController内に定義したメソッドで行います。
// UnityViewController.swift
public func initUnityWindow() {
guard !unityIsInitialized(),
let delegate = UIApplication.shared.delegate as? AppDelegate else {
showUnity()
return
}
unityFramework = loadUnityFramework()
unityFramework?.setDataBundleId("com.unity3d.framework")
unityFramework?.register(self)
NSClassFromString("FrameworkLibAPI")?.registerAPIforNativeCalls(self)
unityFramework?.runEmbedded(withArgc: CommandLine.argc, argv: CommandLine.unsafeArgv, appLaunchOpts: delegate.launchOptions)
}
private func unityIsInitialized() -> Bool {
unityFramework != nil && (unityFramework?.appController() != nil)
}
private func loadUnityFramework() -> UnityFramework? {
let bundlePath: String = Bundle.main.bundlePath + "/Frameworks/UnityFramework.framework"
let bundle = Bundle(path: bundlePath)
if bundle?.isLoaded == false {
bundle?.load()
}
let unityFramework = bundle?.principalClass?.getInstance()
if unityFramework?.appController() == nil {
let machineHeader = UnsafeMutablePointer<MachHeader>.allocate(capacity: 1)
machineHeader.pointee = _mh_execute_header
unityFramework?.setExecuteHeader(machineHeader)
}
return unityFramework
}
起動にはUnityFrameworkをloadし、インスタンス変数などで保持する必要があります。このUnityFrameworkを利用して起動、停止、連携などを行うからです。逆にこのunityFrameworkというインスタンス変数がnilかどうかで初期化済みか、そうでないかという判定も行っています。
また起動にはlaunchOptionsが必要になるので、以下のようにAppDelegateからアクセスできるようにしてあります。
// AppDelegate.swift
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
private(set) var launchOptions: [UIApplication.LaunchOptionsKey: Any]?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
self.launchOptions = launchOptions
window = UIWindow(frame: UIScreen.main.bounds)
window?.rootViewController = RootViewController()
window?.makeKeyAndVisible()
return true
}
// 他のメソッドなどについては省略
}
Unityの停止は基本的にUnityFramework.unloadApplication()で行います。
// UnityViewController.swift
func unloadUnity() {
if unityIsInitialized() {
self?.unityFramework?.unloadApplication()
}
}
unloadとは別に、Unityを完全に終了するquitもあるのですが、こちらは一度quitしてしまうとアプリを再起動しない限りは再びUnityを起動することができなくなってしまいます。AR機能をアプリの1画面として扱う場合、「アプリ起動→AR機能起動→AR機能終了→ネイティブに戻る→再びAR機能起動」というフローが考えられるためquitは基本的に使用しない、という方針になりました。
Unity画面の表示、非表示
Unity自体の起動、停止とは別に画面を表示、非表示を行う必要があります。
// UnityViewController.swift
func showUnity() {
if !unityIsInitialized() {
print("please init unity first")
return
}
unityFramework?.showUnityWindow()
}
こちらもUnityFramework.showUnityWindow()を呼び出すだけです。
// UnityViewController.swift
func hideUnity() {
guard let delegate = UIApplication.shared.delegate as? AppDelegate else { return }
delegate.window?.makeKeyAndVisible()
}
Unity画面の非表示とはつまり元のネイティブの画面を再び呼び出すことです。上記AppDelegateのようにして、もともとのUIWindowを保持しておく必要があります。
// AppDelegate.swift(再掲)
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
private(set) var launchOptions: [UIApplication.LaunchOptionsKey: Any]?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
self.launchOptions = launchOptions
window = UIWindow(frame: UIScreen.main.bounds)
window?.rootViewController = RootViewController()
window?.makeKeyAndVisible()
return true
}
// 他のメソッドなどについては省略
}
UnityFrameworkListener
UnityFrameworkListenerはUnityのライフサイクルを監視するためのProtocolでUnityがunloadされた際、quitされた際に呼ばれます。
UnityFrameworkListenerを実際のソースで見てみると以下のようになっています。
// UnityFramework.h
// important app life-cycle events
__attribute__ ((visibility("default")))
@protocol UnityFrameworkListener<NSObject>
@optional
- (void)unityDidUnload:(NSNotification*)notification;
- (void)unityDidQuit:(NSNotification*)notification;
@end
unloadとquitしかありません。UnityViewController内でこれらを実装している部分は以下になります。
// UnityViewController.swift
// MARK: - UnityFrameworkListener
extension UnityViewController: UnityFrameworkListener {
func unityDidUnload(_ notification: Notification!) {
unityFramework?.unregisterFrameworkListener(self)
unityFramework = nil
}
// UnityFramework.h にて UnityFrameworkListenerとして宣言されているため実装するが、Quitしないので基本呼ばれない。
func unityDidQuit(_ notification: Notification!) {
unityFramework?.unregisterFrameworkListener(self)
unityFramework = nil
didQuit = true
}
}
今回の方針ではほとんどUnityがquitされることはないのですが、Protocolにより実装する必要があるため一応書いています。
それぞれUnity終了後の後処理としてUnityFramework.unregisterFrameworkListener(UnityFrameworkListener)の呼び出し、保持していたunityFrameworkを開放を行っています。
Unity・ネイティブ間の連携(NativeCallsProtocol)
NativeCallsProtocolはUnity側から呼び出される関数のProtocolです。つまり、Unity側が実行する関数の処理をネイティブ側でかけるようになります。
NativeCallsProtocolはUnity側で定義するものになります。例として今回は以下のように定義してあったとします。
// NativeCallsProtocol.h
// NativeCallsProtocol defines protocol with methods you want to be called from managed
@protocol NativeCallsProtocol
@required
- (void) finishAr;
- (void) sendMessage;
// other methods
@end
想定している例としてはUnity側からAR機能を終了したいときに、Unity側からネイティブ側のfinishAr()を呼び、ネイティブ側でAR機能終了の処理を行う、というものです。
// UnityViewController.swift
// MARK: - NativeCallsProtocol
extension UnityViewController: NativeCallsProtocol {
func finishAr() {
hideUnity()
unloadUnity()
// ここでVieModelのメソッドなどを呼び出すことでUnityとは直接のない処理を書かなくて済む
}
func sendMessage() {
unityFramework?.sendMessageToGO(withName: "LibraryHelper", functionName: "receiveMessage", message: "hello unity from nativeApp")
}
}
こうすればAR機能内でUnity側のUIをタップした際にネイティブアプリ側からAR機能を終了する、といったことができるようになります。
Unity側からだけでなくネイティブ側からUnityに通知を送ることができます。先程の例とは異なり、Unity側の関数を直接呼び出しているわけではない点に注意してください。
UnityFramework.sendMessageToGO(withName: String, functionName: String, message: String)
上記メソッドでは単純に文字列(message引数)を送ることができます。withNameでは送信したいgameObjectの名前、functionNameでは関数名、messageで送信したい文字列を指定することができます。
Unityへのネイティブライフサイクルの通知
UnityFrameworkではネイティブアプリのライフサイクルがわからないため、ネイティブ側から通知してあげる必要があります。
ネイティブのライフサイクルとは具体的にはAppDelegateでいうapplicationWillResignActive(_ application: UIApplication)やfunc applicationDidEnterBackground(_ application: UIApplication)のことです。
上記の場合ならUnityFramework.appController().applicationWillResignActive(application)やUnityFramework.appController().applicationDidEnterBackground(application)を同じタイミングで呼び出してあげればOKです。
ここで問題になるのがAppDelegate側からUnityFrameworkを参照する必要があるという点です。Unityの操作もAppDelegateに書いてしまうとAppDelegateが肥大化してしまいます。
ここでRxSwiftなどを用いてViewModelをUnityViewControllerからobserveし、AppDelegateからViewModelなどにライフサイクルを通知することによりUnityViewController内でUnityにライフサイクルを通知することができます。
最後に
コードにもTPOがあるので、必ずしも上記で紹介したようなコードが正であるとは限りません。
ですが、Unity as a Libraryを既存iOSアプリに導入するにあたっては、UnityViewControllerはUnityの操作および橋渡しのみを行うこと、AppDelegateでUnityFrameworkは保持しないこと、ViewはUIKitを無理にUnityに重ねずUnityに任せる、などの点に注意する必要はあるのかなと思いました。
少しでも皆さんの参考になれば幸いです。ここまで読んでいただき、ありがとうございました!