Unityを組み込んだiOSアプリにおける、UXも考慮した開発

OGP

こんにちは、ZOZO NEXTで新規プロダクトの開発を担当している木下です。先日、3Dバーチャル試着に関する実証実験の取り組みが発表されました。3Dバーチャル試着ではユーザーが入力した体型データを基に3Dアバターが作成され、好みのアイテムを選んで着丈やサイズ感を確認できます。

zozonext.com

この実証実験のために開発したアプリは、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 as a Libraryのサンプル画面

画像のキューブや背景と青枠内のボタンがUnityによるもの、赤枠内のボタンがネイティブアプリによるものです(サンプルプロジェクトより)。

背景

3Dシミュレーション技術は、パートナー企業からUnityのSDKとして提供されました。Unityを用いたiOSアプリの開発に当たっては、今回のような(1)UaaLを用いる方法と(2)Unityのみを用いる方法の2つがあります。今回はUXを担保するためにAppleのHuman Interface Guidelinesに則るという方針のもと、(1)の手法を採用しました。

UXを考慮すると、シームレスにUnityを組み込むことが重要になります。今回のバーチャル試着では、お客様ひとりひとりの体型を反映したアバターに、リアルタイムシミュレーションで服を着装します。これはモバイルアプリとしては比較的重い処理であり、負荷によってはUXに大きく関わります。これらの課題に対して、以下のような工夫をしました。

  1. Unityのロードに若干時間がかかる→AppDelegateでUnity呼び出す
  2. Unityとネイティブの画面切り替えが不自然→UnityのWindowからViewだけを利用する
  3. Unityの負荷によってネイティブのアニメーションが不安定になる→Unityを一時停止する
  4. Unityとネイティブでのデータのやりとりが複雑→Unityとのやりとりを一方向にする
  5. UnityのBuild後の設定が複数あって手間になる→Build後の設定を自動化する

UaaLをSwiftで利用するに当たって

UaaLを使うに当たって、Swiftで実装したい方が多いかと思います。しかしながら、公式のサンプルプロジェクトUnity-Technologies/uaal-exampleはObjective-Cで書かれています。幸い先人のおかげで様々な日本語記事が充実しています。私もこれらの記事を大いに参考にさせていただきました。

qiita.com note.com

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の画面の前に、ネイティブ側で実装したボタンやリストなど(赤枠で囲った部分)を配置しています。

ネイティブアプリとUnityの画面の関係

Unityを一時停止する

背景でも説明した通り、今回のバーチャル試着アプリはリアルタイムシミュレーションで、モバイルアプリとしては比較的重い処理をUnityで実行しています。そのためUnityの負荷で、ネイティブアプリのアニメーションやWebViewのスクロールがカクつく事象が発生していました。少しでもUnityからの影響を抑えられるよう、ネイティブアプリのアニメーションがある場合には、それが終わるまでUnityをpauseしました。またUnityが表示されない、試着画面以外の画面に遷移する際にもUnityのpauseを実行しました。

CollectionViewCellのselect時のアニメーション

アイテムリストの画像

今回のアプリには、選択されたアイテムのセルの幅が大きくなり、画面中央部に移動するアニメーションがありました。performBatchUpdates(_:completion:)を利用して、アニメーションが発生する前にUnityをpause、アニメーションが完了後Unityのpauseを解除しました。

Unity.shared.pause(true)
collectionView.performBatchUpdates {
    collectionView.collectionViewLayout.invalidateLayout()
    collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)
} completion: { _ in
    Unity.shared.pause(false)
}

ドロワーメニューからWebViewを開いた場合

基本的にはバーチャル試着の画面以外に遷移する際にはUnityをpauseし、試着画面のViewControllerのviewWillAppearでUnityのpauseを解除するように実装していました。しかし、ここで問題となったのは以下のドロワーメニューです。

ドロワーメニュー

画像のようにUIModalPresentationStyleが.fullScreenではない場合、そのモーダルを閉じても試着画面のviewWillAppearは呼ばれません。そのため、モーダルを開く際にUnityのpauseを、そしてモーダルのviewDidDisappearでUnityのpauseの解除を実装しなければなりません。

このドロワーメニューからは、利用規約やプライバシーポリシーなどをWebViewで開くことができます。利用規約などの項目をタップしてWebViewを開く際には、ドロワーメニューの上ではなく、ドロワーメニューを一旦閉じた後に試着画面の上にモーダルとして表示します。これは、Gmailなどのメジャーなアプリの挙動を参考にさせていただきました。

この場合、ドロワーメニューを閉じて試着画面を表示した時点でUnityのpauseが解除されてしまいます。そうすると、WebViewのスクロールがカクついてしまいます。そのため、ドロワーメニューのdismissのコールバックで再度Unityをpauseしました。さらにカスタム実装したWebViewのViewControllerが閉じた時点でUnityのpauseを解除できるようにしました。

ドロワーメニューからWebViewへの切り替えを行う部分は次の通りです。

guard let presentingViewController = presentingViewController,
      let url = URL(string: termsUrl)
else {
    return
}

dismiss(animated: true) {
    // 試着画面で解除されるUnityを再度pauseする
    Unity.shared.pause(true)
}

// このpresentWebViewのメソッドの詳細は割愛
presentWebView(navigationBarTitle: "利用規約", URL: url, presentingOn: presentingViewController) {
    // WebViewを閉じる際に呼ばれる処理を渡す
    Unity.shared.pause(false)
}

カスタムWebViewController内でのコールバックの部分は次の通りです。

...
class WebViewController: UIViewController, WKNavigationDelegate {
    // MARK: - Properties
    // このプロパティに先程の処理が渡される
    private var onCloseHandler: (() -> Void)?
    ...
    
    // WebViewが閉じられた際にUnityのpauseを解除する
    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        onCloseHandler?()
    }
    ...
}

試着画面から画面遷移するたびにUnityをpauseし、試着画面に戻ってきた際にUnityのpauseを毎回解除する必要があり、少し手間にはなります。しかしUnityの負荷が高い時にはとても効果的です。

Unityとのやりとりを一方向にする

UaaLではネイティブとUnityがそれぞれやりとりを行えるプラグインがあります。最初に挙げたサンプルプロジェクトでは、それぞれネイティブ→Unity、Unity→ネイティブのプラグインを利用することでデータのやりとりを行なっています。具体的には色情報のやりとりを行なっています。

双方向のやりとりを行うことはできますが、一方向に絞った方がデータの流れは理解しやすくなります。今回は可能な限り一方向に絞ることを目指して工夫しました。ネイティブ側から呼び出すメソッドとして、MethodFirstMethodSecondという2つのメソッドがあるとします。またMethodFirstが完了した後に、MethodSecondを実行する必要があります。これをネイティブからUnityという一方向で実現するには、仮に2つのメソッドが同時に呼び出されても、Unity内でMethodFirstからMethodSecondという順に処理させる必要があります。そのためUnity内でEventとCoroutineを活用し、これを実現しました。ただし、Unity内のデータをネイティブ側に伝えたい場合や、Unityの描画が完了したタイミングなどのイベントをネイティブ側に渡したい場合は一方向での実装はできません。

SwiftによるネイティブからUnityの呼び出しは、簡易なUnitySendMessageを用いて実装しました。複雑なデータであっても、Unity側でStringをJSONに変換することでデータのやりとりが可能です。

func sendData(data: [String]) {
    // 引数はStringなので、それに合わせる
    let dataText =
        """
        {\"data0\": \(data[0]), \"data1\": \(data[1]), \"data2\": \(data[2]),
        \"data3\": \(data[3]), \"data4\": \(data[4]), \"data5\": \(data[5])}
        """
    // UnityFrameworkのメソッドを呼び出し、
    // Unityのシーン内のオブジェクトの名前、実行したい関数の名前、引数を渡す
    Unity.shared
        .sendMessageToUnity(objectName: "ObjectNameInUnity", functionName: "MethodFirst", argument: dataText)
}

データを受け取って、Unity内でJSONに変換します。また、EventとCoroutineでMethodFirstMethodSecondを順に処理することを実現しました。

using UnityEngine;

private Data data;
private bool hasSet = false;

void Awake()
{
  data.SettingComplete += OnSettingComplete;
}

void OnSettingComplete()
{
  hasSet = true;
}

public void MethodFirst(strig inputDataString)
{
  // ネイティブ側のstringをJSONに変換
  var inputData = JsonUtility.FromJson<Data>(dataString);

  hasSet = false;
  data.Set(inputData); // ここでSetされると、OnSettingCompleteが実行される
  ...
}

public IEnumerator MethodSecond()
{
  while (!hasSet)
  {
    yield return new WaitForSeconds(.1f);
  }

  // MethodFirstが完了するとwhileを抜けて処理を続行
  ...
}

Build後の設定を自動化する

公式のサンプルプロジェクトでも説明されていますが、Unityをネイティブアプリから読み込めるよう、Build後のフォルダのターゲットにUnityFrameworkを指定する必要があります。開発において何度も発生する作業は自動化すると効率が上がるので、以下のようにスクリプトを作成し、ビルド後に処理が走るように設定しました。このファイルは、Unityのプロジェクト内のAssets/Editorに配置します。

using UnityEditor;
using UnityEditor.Build;
using UnityEditor.Build.Reporting;
using UnityEngine;
using UnityEditor.iOS.Xcode;

class PostBuildProcess : IPostprocessBuildWithReport
{
    public int callbackOrder { get { return 0; } }
    public void OnPostprocessBuild(BuildReport report)
    {
        var outputPath = report.summary.outputPath;
        EditProject(outputPath);
    }

    static void EditProject(string outputPath)
    {
        var projectPath = PBXProject.GetPBXProjectPath( outputPath );

        var pbx = new PBXProject();
        pbx.ReadFromFile(projectPath);

        // UnityFrameworkを取得
        var guidTarget = pbx.GetUnityFrameworkTargetGuid();

        // DataフォルダのターゲットにUnityFrameworkを追加
        var guidData = pbx.FindFileGuidByProjectPath("Data");
        var guidResPhase = pbx.GetResourcesBuildPhaseByTarget(guidTarget);
        pbx.AddFileToBuildSection(guidTarget, guidResPhase, guidData);

        pbx.WriteToFile(projectPath);
    }
}

まとめ

本記事では、Unity as a Libraryを用いたiOSアプリ開発における、実用的な工夫についてご紹介いたしました。Unityを組み込むに当たって、ネイティブアプリとシームレスに馴染むように、そしてUnityの負荷がネイティブアプリに与える影響を最小限にすることを目指しました。皆様の参考になれば幸いです。

ZOZO NEXTでは、先端技術を取り入れ、デザイナーと一緒になってUXを最大化しながらプロダクト開発を行なっております。世界からヤバいと言われるプロダクトづくりを目指して、絶賛仲間を募集中です!

https://hrmos.co/pages/zozo/jobs/0000143hrmos.co hrmos.co

カテゴリー