こんにちは、ZOZO NEXTで新規プロダクトの開発を担当している木下です。先日、3Dバーチャル試着に関する実証実験の取り組みが発表されました。3Dバーチャル試着ではユーザーが入力した体型データを基に3Dアバターが作成され、好みのアイテムを選んで着丈やサイズ感を確認できます。
この実証実験のために開発したアプリは、 Unity as a Library (UaaL)という技術を利用して実装されています。今回はUaaLをiOSアプリに組み込むにあたって工夫した点を、UX観点も交えながらご紹介します。
Unity as a Libraryとは Unity as a Library (UaaL)はUnityのARや3D/2Dのリアルタイムレンダリングといった機能をネイティブアプリに組み込むことができる技術です。Unityの2019.3.0a2から導入されたもので、これによってUnityをネイティブアプリの一部として公式に組み込めるようになりました。
画像のキューブや背景と青枠内のボタンがUnityによるもの、赤枠内のボタンがネイティブアプリによるものです( サンプルプロジェクト より)。
背景 3Dシミュレーション技術は、パートナー企業からUnityのSDKとして提供されました。Unityを用いたiOSアプリの開発に当たっては、今回のような(1)UaaLを用いる方法と(2)Unityのみを用いる方法の2つがあります。今回はUXを担保するためにAppleの Human Interface Guidelines に則るという方針のもと、(1)の手法を採用しました。
UXを考慮すると、シームレスにUnityを組み込むことが重要になります。今回のバーチャル試着では、お客様ひとりひとりの体型を反映したアバターに、リアルタイムシミュレーションで服を着装します。これはモバイルアプリとしては比較的重い処理であり、負荷によってはUXに大きく関わります。これらの課題に対して、以下のような工夫をしました。
Unityのロードに若干時間がかかる→ AppDelegateでUnity呼び出す Unityとネイティブの画面切り替えが不自然→ UnityのWindowからViewだけを利用する Unityの負荷によってネイティブのアニメーションが不安定になる→ Unityを一時停止する Unityとネイティブでのデータのやりとりが複雑→ Unityとのやりとりを一方向にする UnityのBuild後の設定が複数あって手間になる→ Build後の設定を自動化する UaaLをSwiftで利用するに当たって UaaLを使うに当たって、Swiftで実装したい方が多いかと思います。しかしながら、公式のサンプルプロジェクト Unity-Technologies/uaal-example はObjective-Cで書かれています。幸い先人のおかげで様々な日本語記事が充実しています。私もこれらの記事を大いに参考にさせていただきました。
Unityクラスの実装 工夫を1つ1つ説明する前に、UaaLをネイティブアプリのプロジェクトから利用する方法について説明します。UaaLはUnityFrameworkというObjective-CのClassから 操作することができます 。そのクラスを呼び出しやすくするため、以下のようにUnity.swiftというクラスをシングルトンオブジェクトとして実装します。
class Unity: NSObject, UnityFrameworkListener {
static let shared = Unity()
private let unityFramework: UnityFramework
override init() {
let bundlePath = Bundle.main.bundlePath
let frameworkPath = bundlePath + "/Frameworks/UnityFramework.framework"
let bundle = Bundle(path: frameworkPath)!
if !bundle.isLoaded {
bundle.load()
}
// It needs disable swiftlint rule due to needs for unwrapping before calling super.init()
// swiftlint:disable:next force_cast
let frameworkClass = bundle.principalClass as! UnityFramework.Type
let framework = frameworkClass.getInstance()!
if framework.appController() == nil {
var header = _mh_execute_header
framework.setExecuteHeader(&header)
}
unityFramework = framework
super.init()
}
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) {
unityFramework.register(self)
unityFramework.setDataBundleId("com.unity3d.framework")
unityFramework.runEmbedded(withArgc: CommandLine.argc,
argv: CommandLine.unsafeArgv, appLaunchOpts: launchOptions)
}
// UnityのWindowからViewだけを返す
var view: UIView {
unityFramework.appController()!.rootView!
}
// ネイティブ側からUnityのメソッドを呼び出す
func sendMessageToUnity(objectName: String, functionName: String, argument: String) {
unityFramework.sendMessageToGO(withName: objectName, functionName: functionName, message: argument)
}
func applicationWillResignActive(_ application: UIApplication) {
unityFramework.appController()?.applicationWillResignActive(application)
}
func applicationDidEnterBackground(_ application: UIApplication) {
unityFramework.appController()?.applicationDidEnterBackground(application)
}
func applicationWillEnterForeground(_ application: UIApplication) {
unityFramework.appController()?.applicationWillEnterForeground(application)
}
func applicationDidBecomeActive(_ application: UIApplication) {
unityFramework.appController()?.applicationDidBecomeActive(application)
}
func applicationWillTerminate(_ application: UIApplication) {
unityFramework.appController()?.applicationWillTerminate(application)
}
}
AppDelegateでUnityを呼び出す 簡易に計測したところ、Unity起動時のロードには0.2-0.3秒かかります。これを任意のタイミングで呼び出すと、ロードしている間は真っ暗な画面が表示されます。軽微であるとは言え、UXに関わる部分です。そこで、AppDelegateの application(_:didFinishLaunchingWithOptions:) の中で呼び出すこととしました。こうすることで、ネイティブアプリのスプラッシュ画面が表示されているタイミングでUnityをロードでき、不要な画面遷移を減らすことができます。
import Firebase
import UIKit
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Unityを呼び出す
Unity.shared.application(application, didFinishLaunchingWithOptions: launchOptions)
// 最初に表示する画面を呼び出す
let singInViewController = SignInViewController(nibName: nil, bundle: nil)
let navigationController = UINavigationController(rootViewController: singInViewController)
let model = SignInModel()
let presenter = SignInPresenter(view: singInViewController, model: model)
singInViewController.inject(presenter: presenter)
window = UIWindow(frame: UIScreen.main.bounds)
window?.rootViewController = navigationController
window?.makeKeyAndVisible()
return true
}
}
この方法で実装すると、結局ローディングの時間をそのまま待つ必要があります。それを解決するべく、並列処理によってバックグラウンドでのUnityのロードを検討しました。しかしその方法では、スプラッシュ画面が表示されたあと、Unityをロードする真っ暗な画面が表示されました。
結果的に、起動時間そのものは変わらないものの、不要な画面遷移を減らしスプラッシュ画面1つにまとめるという方法に落ち着きました。
UnityのWindowからViewだけを利用する UaaLの仕組みとしては、ネイティブ(ホスト)側のiOSアプリのUIWindowとは別に、Unity側でUIWindowを生成しています。ホスト側からUnity側のWindowに切り替える際には、前述したUnityFrameworkの showUnityWindow という関数を呼び出す必要があります。この関数はアニメーションもなく、単にUnityのUIWindowをアプリの最前面に表示する仕様となっています。
一方で今回のアプリでは、NavigationControllerによるプッシュ遷移に組み込む必要がありました。そのため、 Unity側のWindowからViewだけを呼び出し、アプリの画面を表示しているViewControllerにaddSubViewする という方法を取りました。
UnityのWindowのViewにアクセスできるようプロパティを実装しました。先ほどの、 Unity.swift から抜粋しています。
var view: UIView {
unityFramework.appController()!.rootView!
}
ホスト側ViewController(HostViewController)へのaddSubViewと、そのsubViewを背面へ移動します。
import UIKit
class HostViewController: UIViewController {
// UnityのViewの読み込み
private let unityView = Unity.shared.view
private var presenter: HostPresenterInput!
func inject(presenter: HostPresenterInput) {
self.presenter = presenter
}
override func viewDidLoad() {
super.viewDidLoad()
// addSubView
view.addSubview(unityView)
// 追加したsubViewのサイズをViewControllerのViewのサイズに合わせる
unityView.frame = view.bounds
// 追加したsubViewを背面へ(addSubViewは最前面に追加するため、ViewControllerのViewの後ろに設定する必要がある)
view.sendSubviewToBack(unityView)
}
...
}
実際の画面は画像のようになり、アバターと背景からなるUnityの画面の前に、ネイティブ側で実装したボタンやリストなど(赤枠で囲った部分)を配置しています。
続きは こちら