[Unity] Editor 拡張を書いてワンクリックでビルドをしよう

[Unity] Editor 拡張を書いてワンクリックでビルドをしよう

Unity Editor 拡張を書いてワンクリックでビルドをしよう

「あれ、アプリに書き出したらうまく動かない…」
そんな開発者の悲鳴が今日も世界のどこかで聞こえてきます。

非常に残念なことに Unity Editor では再現しない問題というのが、この世には数多存在します。

  • 今世紀最大の謎!シェーダーバリアントの自動除去
  • 恐怖!アセットバンドル
  • 全米が泣いた!コードストリッピング

正直、ホラーですね!

ご紹介が遅れました、株式会社 Aiming エンジニアの土井と申します。
最近は「マインクラフトダンジョンズ」にハマっています。ブログを書くのは久しぶりです。よろしくおねがいします。

私は、普段からビルドのたびにお祈りしているのですが、世知辛くも祈りはあまり届いていないようです。祈ってばかりいても仕方がないので、手間の多い「ビルドしたアプリのデバッグ」を、少しだけ楽にすることからはじめてみましょう。

ビルドしたアプリの問題に直面した場合、まず手元の PC で動くアプリケーションをビルドしてデバッグするのが解決への近道です。そのためには、正しい手順のビルドが簡単にできるようになっていることが理想です。しかし、開発が進むにつれ、ビルドの手順も複雑化していきます。いざ、デバッグのためにビルドをしようとしても「ありゃ!?どうやってビルドするんだったっけ?」ということになりかねません。

そんなときはアプリケーションのビルドと実行をワンクリックで行う Editor 拡張を Unity Editor に追加してみましょう。

エディタウィンドウ

エディタウィンドウ

エディタウィンドウはこんな感じです。実際に現場で使っているやつはもっとカッコイイのですが、説明のために最小構成にしました。

最上段には、標準の Build Settings ウィンドウにあるオプションの中でもよく使うものを抜粋して配置してあります。また、Addressable Assets を一緒にビルドするか指定できるようにしました。

二段目には、ビルドだけ行う Build ボタンと、すでにビルド済みのアプリケーションを起動するための Run ボタン、最後にビルドが終わったらすぐにアプリケーションを起動する Build and Run ボタンを置きました。

最後の段には、Run ボタンを押したときに起動するアプリケーションの数を設定できるようにしました。Aiming はオンラインゲームを作る会社ですので、複数のクライアントを同時に立ち上げてテストしなければならないことが多いです。地味に便利な機能です。

こうやって、開発作業中によく使うビルドの項目をシンプルにまとめてあります。どの項目も、別に拡張を作るまでもなくマウスをポチポチやっていればできることではありますが、メリットはたくさんあります。

  • ビルドの手順をスクリプト化できる
  • スクリプト化したビルド手順は、そのまま CI の自動ビルドに活かせる
  • あたらしくプロジェクトに参加したメンバーでもすぐにビルドできる
  • ビルド後の動作確認をするのが面倒じゃなくなる
  • マルチプレイで動くアプリの確認も面倒じゃなくなる
  • エンジニアに限らず、チームメンバー全員が簡単に使える

単純なことでも、一年間何百回も行うような微妙に複雑な操作をワンクリック化すれば、トータルでは数日分の節約になるはずです。この節約効果は、チームのメンバーにとっても同様なので、チームメンバーが多ければ多いほど効果は高いと言えます。

この中でも スクリプト化したビルド手順は、そのまま CI の自動ビルドに活かせる は、大事なことだと考えています。実際、プロジェクトでは、紹介したビルドのワンクリック化だけではなく、データの検証や不正なデータの自動修正などもワンクリックでできるようにしています。ユニットテストと同様に、ちょっとした操作や検証作業をコードにしておけば、CI に組み込むことが簡単になります。

いろいろメリットを上げましたが、やはり一番のメリットは、単純にストレスが減ること! ですね。

実装

「自分もやってみたい!」と思った方がいらっしゃいましたら、スクリプトを置いておくので参考にしていただけると幸いです。

  • Windows、MacOS 版 Unity Editor に対応
  • Unity Editor version 2020.3.17f で動作確認

クリックしてコードを見る

using System;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using UnityEditor;
using UnityEditor.AddressableAssets;
using UnityEditor.AddressableAssets.Settings;
using UnityEngine;
using UnityEngine.SceneManagement;
using Debug = UnityEngine.Debug;

public class SampleLauncher : EditorWindow
{
    private const string AppName = "Sample";
    private const string AddressablesProfileName = "Default";

    private bool allowDebugging  ;
    private bool buildAddressableAssets = true;
    private bool connectWithProfiler = true;
    private int processCount = 1;

    private string ExecutablePath
    {
        get
        {
#if UNITY_EDITOR_WIN
            return TargetPath;
#elif UNITY_EDITOR_OSX
            return TargetPath + "/Contents/MacOS/!!!you product name!!!";
#else
            throw new InvalidOperationException("対応していないプラットフォームです");
#endif
        }
    }

    private BuildTarget BuildTarget
    {
        get
        {
#if UNITY_EDITOR_WIN
            return BuildTarget.StandaloneWindows;
#elif UNITY_EDITOR_OSX
            return BuildTarget.StandaloneOSX;
#else
            return BuildTarget.NoTarget;
#endif
        }
    }

    private string ProjectRootPath => Path.GetDirectoryName(Application.dataPath);

    private string TargetPath => BuildPath(BuildTarget);

    private string[] ScenePaths
    {
        get
        {
            var sceneCount = SceneManager.sceneCountInBuildSettings;
            var scenes = new string[sceneCount];
            for (var i = 0; i < sceneCount; i++)
                scenes[i] = SceneUtility.GetScenePathByBuildIndex(i);
            return scenes;
        }
    }

    private BuildOptions BuildOptions
    {
        get
        {
            var options = BuildOptions.Development;
            if (allowDebugging) options |= BuildOptions.AllowDebugging;
            if (connectWithProfiler) options |= BuildOptions.ConnectWithProfiler;
            return options;
        }
    }

    private BuildPlayerOptions BuildPlayerOptions => new BuildPlayerOptions
    {
        targetGroup = BuildTargetGroupFromBuildTarget(BuildTarget),
        target = BuildTarget,
        locationPathName = TargetPath,
        scenes = ScenePaths,
        options = BuildOptions
    };

    private bool Build()
    {
        // ビルドターゲットを UnityEditorが実行されているプラットフォームへ変更します
        if (!SwitchBuildTarget(BuildTarget))
            return false;

        // AddressableAssets をビルドします
        if (buildAddressableAssets)
            BuildAddressable(AddressablesProfileName);


        return BuildPipeline.BuildPlayer(BuildPlayerOptions);
    }

    private void Run()
    {
        try
        {
            for (var i = 0; i < processCount; i++)
            {
                var proc = new Process
                {
                    StartInfo = new ProcessStartInfo
                    {
                        FileName = ExecutablePath,
                        UseShellExecute = false
                    }
                };
                proc.Start();
            }
        }
        catch (Win32Exception e)
        {
            Debug.LogError($"No executable exists. Please 'Build' first\n{e.Message}");
        }
    }

    private bool SwitchBuildTarget(BuildTarget buildTarget)
    {
        var buildResult = EditorUserBuildSettings.SwitchActiveBuildTarget(
            BuildTargetGroupFromBuildTarget(buildTarget),
            buildTarget);

        if (!buildResult)
            Debug.LogError($"Failed to switch target to {buildTarget}");

        return buildResult;
    }

    private void BuildAddressable(string profileName)
    {
        AddressableAssetSettingsDefaultObject.Settings.activeProfileId =
            AddressableAssetSettingsDefaultObject.Settings.profileSettings.GetProfileId(profileName);
        AddressableAssetSettings.BuildPlayerContent();
    }


    #region EditorWindow methods

    [MenuItem("My/Launcher")] // Unity Editor のメニューに登録する場所
    private static void Init()
    {
        // エディタウィンドウの作成
        var window = GetWindow<SampleLauncher>("My Launcher");
        window.Show();
    }

    private void OnGUI()
    {
        using (new GUILayout.HorizontalScope())
        {
            allowDebugging = GUILayout.Toggle(allowDebugging, "Allow debugging");
            connectWithProfiler = GUILayout.Toggle(connectWithProfiler, "Connect with profiler");
            buildAddressableAssets = GUILayout.Toggle(buildAddressableAssets, "Build addressable");
        }

        using (new GUILayout.HorizontalScope())
        {
            if (GUILayout.Button("Build")) Build();
            if (GUILayout.Button("Run")) Run();
            if (GUILayout.Button("Build and Run") && Build()) Run();
        }

        using (new GUILayout.HorizontalScope())
        {
            GUILayout.Label($"Client Count: {processCount}", EditorStyles.boldLabel, GUILayout.Width(100));
            processCount = (int)GUILayout.HorizontalSlider(processCount, 1, 3, GUILayout.Height(24), GUILayout.MaxWidth(120));
        }
    }

    #endregion


    #region Build Path and Target

    private string BuildPath(BuildTarget buildTarget)
    {
        switch (buildTarget)
        {
            case BuildTarget.StandaloneOSX: return ProjectRootPath + $"/Build/StandaloneOSX/{AppName}.app";
            case BuildTarget.StandaloneWindows: return ProjectRootPath + $"/Build/StandaloneWindows/{AppName}.exe";
            case BuildTarget.StandaloneWindows64: return ProjectRootPath + $"/Build/StandaloneWindows64/{AppName}.exe";
            default: throw new ArgumentOutOfRangeException(nameof(buildTarget), buildTarget, null);
        }
    }

    private BuildTargetGroup BuildTargetGroupFromBuildTarget(BuildTarget buildTarget)
    {
        switch (buildTarget)
        {
            case BuildTarget.StandaloneOSX: return BuildTargetGroup.Standalone;
            case BuildTarget.StandaloneWindows: return BuildTargetGroup.Standalone;
            case BuildTarget.iOS: return BuildTargetGroup.iOS;
            case BuildTarget.Android: return BuildTargetGroup.Android;
            case BuildTarget.StandaloneWindows64: return BuildTargetGroup.Standalone;
            default: throw new ArgumentOutOfRangeException(nameof(buildTarget), buildTarget, null);
        }
    }

    #endregion
}


最後に

株式会社 Aiming では一緒にオンラインゲームを作り上げていく仲間を積極採用中です!
2021年12月1日には、LiTMUS 株式会社(UUUM 子会社)との共同事業契約締結が発表され、やりたいことはますます増えていく状況です!
ゲーム開発にすでに従事されている方も、ゲームクリエイターを目指している方も、ぜひ一度採用ページをご覧になっていただけるとうれしいです!

採用ページはこちらです!

それでは、今回はこれにて失礼します!