Удобная работа с консольными утилитами в Unity

от автора

Всем привет! Меня зовут Григорий Дядиченко, я занимаюсь продюсированием digital проектов. Сегодня хотелось бы поговорить про возможности расширения редактора Unity, и как вы можете упростить себе работу на примере включения-выключения nginx из Unity. Мы пройдёмся по теме сборки AssetBundles и работы с процессами в C#.

Я довольно много работаю с виртуальной и дополненной реальностью. Да и в целом с тестированием чего-то на устройствах, где не всё можно сделать в редакторе Unity. И поэтому я задался вопросом: «Как бы упростить себе процесс работы?». Основная машина у меня на Windows, и для сборки билдов на IOS приходится перегонять через shared folder по Wifi сборку на макбук. Можно было бы настроить CI&CD, но с точки зрения итераций это медленнее такого пути. Но помимо существует такая задача, как тестирование контента, где в пересборке билда мало художественного смысла. И можно просто заливать на устройство контент в локальной сети через механизм Asset Bundles. Чтож, ну, а тут как не потратить пол дня на такую задачку?

Но чтобы бандлы доставлять нужен веб-сервер. Сначала я попробовал сделать это с помощью сервера из этой статьи, но он слишком топорный, и там будет тяжело поддержать gzip. Поэтому в голову сразу пришёл nginx. Но так как хочется сделать и себе, и людям, то надо бы упростить запуск nginx для Unity разработчиков, и ещё чтобы он выключался с выключением редактора и в целом из редактора им управлять (ну в рамках нашей задачи). Чтож, решение придумано — пора делать.

Интеграция NGINX в Unity проект

В целом навык запускать из редактора всякие консольные утилиты или exe файлы — штука довольно полезная. Таким образом можно быстро без сложной разработки расширять возможности редактора, но помимо редактора, если проект под десктопную платформу навык работать с классом Process, позволяет вам так же обращаться к нужным утилитам уже в билде. Мы же разберём случай с редактором. Чтож, напишем класс для запуска нашего nginx.

Для начала напишем просто запуск процесса и разберём, что он делает:

Код запуска процесса
private void ExecuteCommand (string pathToExe, string args) {   Process process = new Process();   ProcessStartInfo startInfo = new ProcessStartInfo();   startInfo.WindowStyle = ProcessWindowStyle.Hidden;   startInfo.FileName = pathToExe;   startInfo.Arguments = args;   startInfo.UseShellExecute = false;   var path = Path.GetDirectoryName(NginxPath) ?? string.Empty;    if (!string.IsNullOrEmpty(path))   {   startInfo.WorkingDirectory = path;   }   startInfo.CreateNoWindow = true;   process.StartInfo = startInfo;   process.Start();   Debug.Log($"Success {pathToExe} {args}"); }

В данном блоке кода мы:
1. Создаём новый процесс
2. Задаём параметры запуска. Из интересного:
startInfo.WindowStyle = ProcessWindowStyle.Hidden; — чтобы у нас не появлялась консоль.
startInfo.WorkingDirectory = path; некоторые процессы зависят от рабочей директории.
startInfo.CreateNoWindow = true; — нужно ли запускать процесс в новом окне.
3. Запускаем процесс

Запуск процесса написан, теперь нам нужен путь до исполняемого файла. Можно конечно его захардкодить, но в Unity есть инструмент в разы удобнее. Дело в том, что UnityEngine.Object задать в качестве поля в инспекторе, и при этом все файлы и папки проекта являются наследником UnityEngine.Object. Дальше через AssetDatabase.GetAssetPath можно получить полный путь до объекта. И использовать его для наших целей. Вот полный код нашего объекта настройки nginx:

Код NGINXSettings
using System; using System.Diagnostics; using System.IO; using UnityEditor; using UnityEngine; using Debug = UnityEngine.Debug; using Object = UnityEngine.Object;  [CreateAssetMenu(fileName = "NGINXSettings", menuName = "NGINX/Settings")] public class NGINXSettings : ScriptableObject {     public const int ServerPort = 10020;     public const string LogPath = "logs";     public const string PidFileName = "nginx.pid";     public Object Nginx;     public string NginxPath => Path.GetFullPath(AssetDatabase.GetAssetPath(Nginx));          public void StartNginx()     {         var dir = Path.GetDirectoryName(NginxPath) ?? string.Empty;         if (!File.Exists(Path.Combine(dir, LogPath, PidFileName)))         {             ExecuteCommand(NginxPath , "");         }         else         {             Debug.Log("Nginx already started!");         }     }      public void StopNginx()     {         ExecuteCommand(NginxPath, "-s quit");     }     private void ExecuteCommand (string pathToExe, string args)     {         Process process = new Process();         ProcessStartInfo startInfo = new ProcessStartInfo();         startInfo.WindowStyle = ProcessWindowStyle.Hidden;         startInfo.FileName = pathToExe;         startInfo.Arguments = args;         startInfo.UseShellExecute = false;         var path = Path.GetDirectoryName(NginxPath) ?? string.Empty;          if (!string.IsNullOrEmpty(path))         {             startInfo.WorkingDirectory = path;         }         startInfo.CreateNoWindow = true;         process.EnableRaisingEvents = true;         process.StartInfo = startInfo;         process.Start();         Debug.Log($"Success {pathToExe} {args}");     }      } 

В данном случае для удобства работы в редакторе мы сделаем Scriptable Object с кастомным инспектором. Более подробно SO и кастомные инспекторы я разбирал тут. Правда тогда я делал чуть иначе, поэтому тут стоит сказать, что делает аттрибут CreateAssetMenu. Он позволяет вам создавать SO по правой кнопке мыши в редакторе.

А вот кастомные инспекторы там разобраны неплохо, поэтому следущий код примем за данность, чтобы у нас появились кнопки. Тем более он элементарный:

Код NGINXSettingsCustomEditor
using UnityEditor; using UnityEngine;  [CustomEditor(typeof(NGINXSettings))] public class NGINXSettingsCustomEditor : Editor {     public override void OnInspectorGUI()     {         var nginxSettings = target as NGINXSettings;         base.OnInspectorGUI();         if (GUILayout.Button("Start Nginx"))         {             nginxSettings.StartNginx();         }         if (GUILayout.Button("Stop Nginx"))         {             nginxSettings.StopNginx();         }     } } 

Всё, теперь мы можем создать объект, положить в проект nginx и назначить nginx.exe в качестве поля в инспекторе.

Если вы собираетесь это паковать в билд, я бы делал это через StreamingAssets и расширение функциональности определения пути, но работать это всё равно будет только на десктопных платформах.

Стоит сказать, что сейчас у вас nginx работать не будет под Windows за пределами вашей машины, так как Unity редактор заблокирован в Windows Firewall на входящие соединения. Как это настроить правильно можно прочитать тут.

Едем дальше, теперь нам хочется удобно собирать бандлы, чтобы они сразу «заливались на сервер». Пусть и локальный.

Сборка AssetBundles для доступа по сети

Собирать ассет бандлы довольно просто. Для этого в Юнити есть метод:

BuildPipeline.BuildAssetBundles(string, BuildAssetBundleOptions, BuildTarget);

Сделаем это чуть удобнее, задав некоторый набор методов. Он будет позволять нам создавать определённые бандлы или все, назначать платформы которые мы собираем и т.п.

Код AssetBundlesBuilder
using System.Collections.Generic; using System.IO; using System.Linq; using UnityEditor; using UnityEngine;  public class AssetBundlesBuilder  {     public static void BuildAllAssetBundles(AssetBundlesBuildSettings settings)     {         BuildCustomAssetBundles(             settings.AssetBundleDirectory,             null,             settings.Platforms);     }          public static void BuildSpecifiedAssetBundles(AssetBundlesBuildSettings settings)     {         BuildCustomAssetBundles(             settings.AssetBundleDirectory,             settings.AssetBundleNamesToBuild,             settings.Platforms);     }     private static void BuildCustomAssetBundles(         string path,         string[] assetBundleNames,         BuildTarget[] platforms)     {         if(platforms == null)         {             Debug.LogError("Set at least one platform!");             return;         };                  if (!Directory.Exists(path))         {             Directory.CreateDirectory(path);         }          var builds = new List<AssetBundleBuild>();         if (assetBundleNames != null && assetBundleNames.Length != 0)         {             assetBundleNames = assetBundleNames.Distinct().ToArray();              foreach (var assetBundle in assetBundleNames)             {                 var assetPaths = AssetDatabase.GetAssetPathsFromAssetBundle(assetBundle);                 var build = new AssetBundleBuild                 {                     assetBundleName = assetBundle,                     assetNames = assetPaths                 };                 builds.Add(build);                 Debug.Log($"[Asset Bundles] Build bundle: {build.assetBundleName}");             }         }         for (int i = 0; i < platforms.Length; i++)         {             var platform = platforms[i];             BuildAssetBundlesForTarget(path, platform, GetPlatformDirectory(platform),builds.ToArray());         }     }     private static void BuildAssetBundlesForTarget(string path, BuildTarget target, string targetPath, AssetBundleBuild[] bundles = null)     {         var directory = Path.Combine(path, targetPath);         if (!Directory.Exists(directory))         {             Directory.CreateDirectory(directory);         }          if (bundles == null || bundles.Length == 0)         {             BuildPipeline.BuildAssetBundles(directory, BuildAssetBundleOptions.None, target);         }         else         {             BuildPipeline.BuildAssetBundles(directory, bundles.ToArray(), BuildAssetBundleOptions.None, target);         }        }     public static string GetPlatformDirectory(BuildTarget target)     {         switch (@target)         {             default:                 return "standalone";             case BuildTarget.Android:                 return "android";             case BuildTarget.iOS:                 return "ios";             case BuildTarget.StandaloneWindows:                 return "standalone";             case BuildTarget.StandaloneWindows64:                 return "standalone64";         }     } } 

Все эти методы нам пригодятся для нашего второго объекта настроек:

Код AssetBundlesBuildSettings
using UnityEditor; using UnityEngine;  [CreateAssetMenu(fileName = "AssetBundleBuildSettings", menuName = "Asset Bundles/AssetBundleBuildSettings")] public class AssetBundlesBuildSettings : ScriptableObject {     public Object AssetBundleDirectoryObject;     public string AssetBundleDirectory => AssetDatabase.GetAssetPath(AssetBundleDirectoryObject);     public BuildTarget[] Platforms;     public string[] AssetBundleNamesToBuild; } 

Код AssetBundlesBuildSettingsCustomEditor
using UnityEditor; using UnityEngine; [CustomEditor(typeof(AssetBundlesBuildSettings))] public class AssetBundlesBuildSettingsCustomEditor : Editor {     public override void OnInspectorGUI()     {         base.OnInspectorGUI();         GUILayout.Space(40);         if (GUILayout.Button("Build All"))         {             var settings = target as AssetBundlesBuildSettings;             AssetBundlesBuilder.BuildAllAssetBundles(settings);         }         GUILayout.Space(40);         if (GUILayout.Button("Build From Names"))         {             var settings = target as AssetBundlesBuildSettings;             AssetBundlesBuilder.BuildSpecifiedAssetBundles(settings);         }     } } 

В этом примере мы собственно будем передавать не объект, а папку в качестве UnityEngine.Object, что так же удобно, чтобы не хардкодить пути. Очень советую использовать такой механизм в проектах, так как это в разы удобнее, гибче и не требует времени на рекомпиляцию проекта.

Теперь осталось назначить папку для сборки bundles в качестве html папки nginx (как на картинке выше), и бандлы можно спокойно качать по локальной сети.

Спасибо за внимание! Полный код репозитория можно найти тут. Там есть и код клиентского приложения. Результат решения бандлы можно быстро протестировать на разных платформах.


ссылка на оригинал статьи https://habr.com/ru/post/661363/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *