Визуальный редактор логики для Unity3d. Часть 2

    Введение


    Здравствуйте уважаемые читатели, в сегодняшней статье я хотел бы осветить тему архитектуры ядра визуального редактора логики для Unity3d. Это вторая часть из серии. Предыдущую вы можете прочитать здесь. Итак, о чем пойдет речь? В основе визуального редактора лежит базовое ядро, которое позволяет запускать, загружать и хранить данные логики. В свою очередь ядро использует, о чем было сказано в предыдущей статье, ScriptableObject, как базовый класс для работы с компонентами логики. Рассмотрим подробнее все эти аспекты.

    Статьи в серии:

    Визуальный редактор логики для Unity3d. Часть 1

    Почему ScriptableObject?


    Перед тем, как приступать к разработке, я долго думал о том, вокруг чего построить систему. В самом первом виде — это были MonoBehaviour, но от этой идеи пришлось отказаться, поскольку эти скрипты должны висеть на GameObject, в качестве компонентов. Следующим шагом стала идея использовать свой класс, который не является наследником UnityEngine.Object. Но и этот вариант не прижился, хотя он и был вполне рабочим, однако тянул за собой, написание своего сериализатора, инспектора, сборщика мусора и т. п. В итоге единственным разумным выходом осталось использование ScriptableObject, цикл жизни которого схож с MonoBehaviour, если создание происходит в ходе работы приложения через ScriptableObject.CreateInstance. Помимо, этого автоматом решался вопрос с использованиeм JsonUtility (правда сейчас уже и это не является проблемой) и инспектора Unity.

    Архитектура


    Ниже представлена обобщенная схема того, из чего состоит ядро uViLEd.

    image

    Рассмотрим подробнее каждый элемент.

    Контроллер


    Контроллер — это главный элемент ядра, представляющий из себя MonoBehavior скрипт (единственный во всей системе). Класс контроллера является синглетоном и доступен всем компонентам логики. Что делает данный класс:

    1. Хранит ссылки на объекты Unity
    2. Запускает логики на старте сцены
    3. Представляет доступ к методам для запуска логики из внешних источников
    4. Обеспечивает работу Mono-методов в компонентах

    Базовый код класса контроллера
    namespace uViLEd.Core
    {
        public partial class LogicController : MonoBehaviour
        {   
            private static LogicController _instance;
            public static LogicController Instance
            {
                get
                {
                    _instance = _instance ?? FindObjectOfType<LogicController>();
                    
                    return _instance;
                }
            }
    
            [Serializable]
            public class SceneLogicData
            {
                public string Name;
                public TextAsset BinaryData => _data;                
                public string Id => _id;                                           
    
                [SerializeField] private string _id;
                [SerializeField] private TextAsset _data;                                    
    
                public SceneLogicData(string id, string name, TextAsset binaryData)
                {
                    _id = id;                    
                    _data = binaryData;                    
    
                    Name = name;
                }
            }
         
            [HideInInspector] 
            public List<SceneLogicData> SceneLogicList = new List<SceneLogicData>();                     
    	
            void Awake()
            {
                _instance = this;
    
                foreach (var sceneLogic in SceneLogicList)
                {
                    RunLogicInternal(LogicStorage.Load(sceneLogic.BinaryData));                    
                }                
            }
        }
    }
    


    Примечание: в каждой сцене присутствует свой контроллер и свой набор логик.
    Примечание: подробнее о загрузке данных логики и ее запуске будет рассказано отдельно.

    Основные элементы ядра uViLEd


    Компонент


    В первой части я уже говорил, что компонент это ScriptableObject. Все компоненты являются наследниками класса LogicComponent, который в свою очередь банально прост.

    namespace uViLEd.Core
    {
        public abstract class LogicComponent : ScriptableObject
        {
            protected MonoBehaviour coroutineHost => _logicHost;
                            
            private MonoBehaviour _logicHost;                        
    
            public virtual void Constructor() { }                    
        }
    }
    

    Здесь coroutineHost — это ссылка на контроллер логики, которая введена просто для удобства и используется, как можно понять из названия, для работы с короутинами. Применение данной абстракции необходимо для того, чтобы отделить компоненты от другого кода присутствующего в проекте Unity.

    Переменные


    Переменные, как упоминалось в прошлой статье, это специализированные компоненты для хранения данных, ниже представлен код для них.

    Код реализации переменных
    namespace uViLEd.Core
    {
        public abstract class Variable : LogicComponent { }        
        public abstract class Variable<T> : Variable
        {
            public delegate void Changed();
            public delegate void Set(T newValue);
    
            public event Changed OnChanged;
            public event Set OnSet;
                                 
            public T Value
            {
                get
                {
                    return _value;
                }set
                {
                    var changed = false;
    
                    if (_value == null && value != null || (_value != null && !_value.Equals(value)))
                    {
                        changed = true;
                    }
    
                    _value = value;
    
                    if (changed)
                    {
                        OnChanged?.Invoke();
                    }
    
                    OnSet?.Invoke(_value);
                }
            }
            
            [SerializeField] private T _value;
    
            public virtual void OnDestroy()
            {                
                if(OnSet != null)
                {
                    foreach (var eventHandler in OnSet.GetInvocationList())
                    {
                        OnSet -= (Set)eventHandler;
                    }
                }
    
                if(OnChanged != null)
                {
                    foreach (var eventHandler in OnChanged.GetInvocationList())
                    {
                        OnChanged -= (Changed)eventHandler;
                    }
                }
            }        
        }
    }
    


    Здесь Variable — это базовый абстрактный класс для всех переменных, он необходим для того, чтобы отделить их от обычных компонентов. Основной класс — это generic, который хранит собственно данные и обеспечивает работу событий для установки значения и его изменения.

    Связи


    Что такое связи, я рассказывал в прошлой статье. Коротко — это виртуальная сущность, которая позволяет компонентам использовать методы друг друга, а также ссылаться на переменные логики. Для программиста эта связь является мягкой и не видна в коде. Все связи формируются в процессе инициализации (об этом читай ниже). Рассмотрим классы, которые позволяют формировать связи.

    Входная точка
    namespace uViLEd.Core
    {
            public class INPUT_POINT<T>
            {
                public Action<T> Handler;                      
            }
            
            public class INPUT_POINT
            {
                public Action Handler;                                        
            }
    }


    Выходная точка
    
    namespace uViLEd.Core
    {
        
        public class OUTPUT_POINT<T>
        {          
            private List<Action<T>> _linkedInputPoints = new List<Action<T>>();
    
    
            public void Execute(T param)
            {
    	    foreach(var handler in _linkedInputPoints)
                {
                    handler(param);
                }
            }
        }
    
        public class OUTPUT_POINT
        {
            private List<Action> _linkedInputPoints = new List<Action>();
    
            public void Execute()
            {
                foreach (var handler in _linkedInputPoints)
                {
                    handler();
                }
            }
        }    
    }
    


    Ссылка на переменную
    
    namespace uViLEd.Core
    {
        public class VARIABLE_LINK<T>
        {          
            public T Value
            {
                get => _variable.Value;
                set => _variable.Value = value;                
            }            
    
            private Variable<T> _variableProperty
            {
                get => _variable;
                set
                {
                    _variable = value;
    
                    VariableWasSet = true;
    
                    InitializeEventHandlers();                    
                }
            }
    
            public bool VariableWasSet { get; private set; } = false;
    
            private Variable<T> _variable;
            private Variable<T>.Set _automaticSetHandler;
            private Variable<T>.Changed _automaticChangedHandler;
    
            public void AddSetEventHandler(Variable<T>.Set handler)
            {
                if (VariableWasSet)
                {
                    _variable.OnSet += handler;
                }else
                {
                    _automaticSetHandler = handler;
                }
            }
    
            public void RemoveSetEventHandler(Variable<T>.Set handler)
            {
                if (VariableWasSet)
                {
                    _variable.OnSet -= handler;
                }
            }
    
            public void AddChangedEventHandler(Variable<T>.Changed handler)
            {
                if (VariableWasSet)
                {
                    _variable.OnChanged += handler;
                }else
                {
                    _automaticChangedHandler = handler;
                }
            }
    
            public void RemoveChangedEventHandler(Variable<T>.Changed handler)
            {
                if (VariableWasSet)
                {
                    _variable.OnChanged -= handler;
                }
            }           
    
            private void InitializeEventHandlers()
            {
                if (_automaticSetHandler != null)
                {
                    _variable.OnSet += _automaticSetHandler;
                }
    
                if (_automaticChangedHandler != null)
                {
                    _variable.OnChanged += _automaticChangedHandler;
                }
            }            
        }    
    }
    


    Примечание: здесь стоит пояснить один момент, автоматические обработчики событий установки и изменения значения переменной используются только в случае, когда они задаются в методе Constructor, поскольку на тот момент ссылки на переменные еще не установлены.

    Работа с логикой


    Хранение


    В первой статье о визуальном редакторе логики, упоминалось, что логика представляет собой набор переменных, компонентов и связей между ними:

    
    namespace uViLEd.Core
    {
        [Serializable]
        public partial class LogicStorage
        {            
            public string Id = string.Empty;
            public string Name = string.Empty;
            public string SceneName = string.Empty;
    
            public ComponentsStorage Components = new ComponentsStorage();
            public LinksStorage Links = new LinksStorage();
        }    
    }
    

    Данный класс как видно является сериализуемым, однако для сериализации не используется JsonUtility от Unity . Вместо этого используется бинарный вариант, результат работы которого сохраняется в виде файла с расширением bytes. Почему так сделано? В целом, основная причина – безопасность, т. е. для варианта загрузки логики из внешнего источника возможно зашифровать данные, да и в целом десериализовать байтовый массив сложнее, чем открытый json.

    Рассмотрим подробнее класс ComponentsStrorage и LinksStorage. Для глобальной идентификации данных в системе используется GUID. Ниже приведен код класса, который является базовым для контейнеров данных.

    namespace uViLEd.Core
    {
        [Serializable]
        public abstract class Identifier
        {
            public string Id { get; }
    
            public Identifier()
            {
                if (!string.IsNullOrEmpty(Id)) return;
                
                Id = System.Guid.NewGuid().ToString();            
            }
        }
    }
    

    Рассмотрим теперь код класса ComponentsStorage, который как видно из названия хранит данные о компонентах логики:

    namespace uViLEd.Core
    {   
        public partial class LogicStorage
        {
            [Serializable]
            public class ComponentsStorage
            {                
                [Serializable]
                public class ComponentData : Identifier
                {                    
                    public string Type = string.Empty;
                    public string Assembly = string.Empty;
                    public string JsonData = string.Empty;
                    public bool IsActive = true;
                }
                
                public List<ComponentData> Items = new List<ComponentData>();
         }   
    }
    

    Класс достаточно простой. По каждому компоненту хранится следующая информация:

    1. Уникальный идентификатор (строка GUID), который находится в Identifier
    2. Имя типа
    3. Имя сборки, в которой находится тип компонента
    4. Json-строка с данными сериализации (результат работы JsonUtility.ToJson)
    5. Флаг активности (состояния) компонента

    Теперь посмотрим на класс LinksStorage. Данный класс хранит информацию о связях между компонентами, а также о ссылках на переменные.

    namespace uViLEd.Core
    {
        public partial class LogicStorage
        {
            [Serializable]
            public class LinksStorage
            {
                [Serializable]
                public class LinkData : Identifier
                {                    
                    public bool IsVariable;
                    public bool IsActive = true;
                    public string SourceComponent = string.Empty;
                    public string TargetComponent = string.Empty;
                    public string OutputPoint = string.Empty;
                    public string InputPoint = string.Empty;
                    public string VariableName = string.Empty;
                    public int CallOrder = -1;
                }
                
                public List<LinkData> Items = new List<LinkData>();
          }    
    }
    

    В принципе в данном классе тоже нет ничего сложного. Каждая связь содержит следующую информацию:

    1. Флаг показывающий, что данная связь — это ссылка на переменную
    2. Флаг активности связи
    3. Идентификатор (строка GUID) компонента с выходной точкой
    4. Идентификатор (строка GUID) компонента с входной точкой
    5. Имя выходной точки компонента источника связи
    6. Имя входной точки целевого компонента связи
    7. Имя поля класса для установки ссылки на переменную
    8. Порядок вызова связи

    Запуск из хранилища


    Прежде чем вдаваться в подробности кода, для начала хочу остановиться на описании последовательности того, как контроллер запускает логику:

    1. Инициализация стартует в методе Awake контроллера
    2. По списку логик сцены происходит загрузка и десериализация данных логики из бинарного файла (TextAsset)
    3. По каждой логике происходит:
      • Создание компонента
      • Сортировка связей по CallOrder
      • Установка связей и ссылок на переменные
      • Сортировка Mono-методов компонентов по ExecuteOrder


    Рассмотрим подробнее каждый аспект этой цепочки.

    Сериализация и десериализация в бинарном формате
    namespace uViLEd
    {
        public class Serialization
        {
            public static void Serialize(object data, string path, string fileName)
            {
                var binaryFormatter = new BinaryFormatter();
    
                if (!Directory.Exists(path))
                {
                    Directory.CreateDirectory(path);
                }
    
                using (var fs = new FileStream(Path.Combine(path, fileName), FileMode.OpenOrCreate))
                {
                    binaryFormatter.Serialize(fs, data);
                }
            }
        
    
            public static object Deserialize(TextAsset textAsset)
            {
                var binaryFormatter = new BinaryFormatter();
    
                using (var memoryStream = new MemoryStream(textAsset.bytes))
                {
                    return binaryFormatter.Deserialize(memoryStream);
                }
            }
        }
    }
    


    Загрузка данных логики из текстового ассета (бинарный файл)
    namespace uViLEd.Core
    {
        public partial class LogicStorage
        {
            public static LogicStorage Load(TextAsset textAsset) => Serialization.Deserialize(textAsset) as LogicStorage;    
        }
    }
    


    Запуск логики
    private void RunLogicInternal(LogicStorage logicStorage)
    {
        var instances = new Dictionary<string, LogicComponent>();
    
        foreach (var componentData in logicStorage.Components.Items)
        {
            CreateComponent(componentData, instances);
        }
    
        logicStorage.Links.Items.Sort(SortingLinks);
    
        foreach (var linkData in logicStorage.Links.Items)
        {
            CreateLink(linkData, instances);
        }               
    
        foreach (var monoMethods in _monoBehaviourMethods.Values)
        {
            monoMethods.Sort(SortingMonoMethods);
        }
    }
    


    Создание компонента
    
    private void CreateComponent(LogicStorage.ComponentsStorage.ComponentData componentData,
                                 IDictionary<string, LogicComponent> instances,
                                 IList<IDisposable> disposableInstance,
                                 IDictionary<string, List<MonoMethodData>> monoMethods)
    {
        if (!componentData.IsActive) return;
        
        var componentType = AssemblyHelper.GetAssemblyType(componentData.Assembly, componentData.Type);                
        var componentInstance = ScriptableObject.CreateInstance(componentType) as LogicComponent;
                    
        JsonUtility.FromJsonOverwrite(componentData.JsonData, componentInstance);                
    
        componentInstance.name = componentData.InstanceName;                
        componentType.GetFieldRecursive(_LOGIC_HOST_STR).SetValue(componentInstance, this 
    as MonoBehaviour);
                      
        componentInstance.Constructor();
        
        instances.Add(componentData.Id, componentInstance);
    
        if(componentInstance is IDisposable)
        {
            disposableInstance.Add((IDisposable)componentInstance);
        }
        
        SearchMonoBehaviourMethod(componentInstance, monoMethods);
    }
    

    Итак, что происходит в данной функции:

    1. Проверяется флаг активности компонента
    2. Происходит получение типа компонента из сборки
    3. Создается экземпляр компонента по типу
    4. Происходит десериализация параметров компонента из json
    5. Устанавливается ссылка в сoroutineHost
    6. Вызывается метод Constructor
    7. Сохраняется временная копия на экземпляр компонента
    8. Если компонент имплементирует интерфейс IDisposable, то ссылка на него сохраняется в соответствующем списке
    9. Происходит поиск Mono-методов в компоненте


    Создание связей
    private void CreateLink(LogicStorage.LinksStorage.LinkData linkData, Dictionary<string, LogicComponent> instances)
    {
        if (!linkData.IsActive) return;
    
        var sourceComponent = instances.ContainsKey(linkData.SourceComponent) ? instances[linkData.SourceComponent] : null;
    
        if (sourceComponent == null) return;
    
        var targetComponent = instances.ContainsKey(linkData.TargetComponent) ? instances[linkData.TargetComponent] : null;
    
        if (targetComponent == null) return;
    
        if (linkData.IsVariable)
        {
            var variableLinkFieldInfo = sourceComponent.GetType().GetField(linkData.variableName);
    
            if (variableLinkFieldInfo != null)
            {
                var variableLinkFieldValue = variableLinkFieldInfo.GetValue(sourceComponent);
                var variableLinkVariablePropertyInfo = variableLinkFieldInfo.FieldType.GetProperty(_VARIABLE_PROPERTY_STR, BindingFlags.NonPublic | BindingFlags.Instance);
    
                variableLinkVariablePropertyInfo.SetValue(variableLinkFieldValue, targetComponent, null);
            }
        }
        else
        {
            object handlerValue;
            MethodInfo methodListAdd;
            object linkedInputPointsFieldValue;
            Type outputPointType;
            object outputPoint;
    
            var outputPointParse = sourceComponent as IOutputPointParse;
            var inputPointParse = targetComponent as IInputPointParse;
    
            if (outputPointParse != null)
            {
                var outputPoints = outputPointParse.GetOutputPoints();
    
                if (outputPoints.ContainsKey(linkData.OutputPoint))
                {
                    outputPoint = outputPoints[linkData.OutputPoint];
    
                    if (outputPoint is FieldInfo)
                    {
                        outputPoint = sourceComponent.GetType().GetField(linkData.OutputPoint).GetValue(sourceComponent);
                    }
    
                    outputPointType = outputPoint.GetType();
    
                    var linkedInputPointsFieldInfo = outputPointType.GetField(_LINKED_INPUT_POINTS_STR, BindingFlags.NonPublic | BindingFlags.Instance);
    
                    linkedInputPointsFieldValue = linkedInputPointsFieldInfo.GetValue(outputPoint);
    
                    methodListAdd = linkedInputPointsFieldInfo.FieldType.GetMethod(_ADD_STR);
                }
            }
            else
            {
                var outputPointFieldInfo = sourceComponent.GetType().GetField(linkData.OutputPoint);
                outputPoint = outputPointFieldInfo.GetValue(sourceComponent);
    
                if (outputPoint != null)
                {
                    outputPointType = outputPoint.GetType();
    
                    var linkedInputPointsFieldInfo = outputPointFieldInfo.FieldType.GetField(_LINKED_INPUT_POINTS_STR, BindingFlags.NonPublic | BindingFlags.Instance);
    
                    linkedInputPointsFieldValue = linkedInputPointsFieldInfo.GetValue(outputPoint);
                    methodListAdd = linkedInputPointsFieldInfo.FieldType.GetMethod(_ADD_STR);
                }
            }
    
            if (inputPointParse != null)
            {
                var inputPoints = inputPointParse.GetInputPoints();
    
                if (inputPoints.ContainsKey(linkData.InputPoint))
                {
                    var inputPoint = inputPoints[linkData.InputPoint];
    
                    if (inputPoint is FieldInfo)
                    {
                        inputPoint = targetComponent.GetType().GetField(linkData.InputPoint).GetValue(targetComponent);
                    }
    
                    var inputPointType = inputPoint.GetType();
                    var inputPointHandlerFieldInfo = inputPointType.GetField(_HANDLER_STR);
    
                    handlerValue = inputPointHandlerFieldInfo.GetValue(inputPoint);
                }
            }
            else
            {
                var inputPointFieldInfo = targetComponent.GetType().GetField(linkData.InputPoint);
                var inputPointFieldValue = inputPointFieldInfo.GetValue(targetComponent);
    
                if (inputPointFieldValue != null)
                {
                    var inputPointHandlerFieldInfo = inputPointFieldInfo.FieldType.GetField(_HANDLER_STR);
    
                    handlerValue = inputPointHandlerFieldInfo.GetValue(inputPointFieldValue);
                }
            }
    
            var handlerParsedAction = GetParsedHandler(handlerValue, outputPoint);
    
            methodListAdd.Invoke(linkedInputPointsFieldValue, new object[] { handlerParsedAction });
        }
    }
    private object GetParsedHandler(object handlerValue, object outputPoint)
    {
        var inputPointType = handlerValue.GetType();
        var outputPointType = outputPoint.GetType();
    
        if (inputPointType.IsGenericType)
        {
            var paramType = inputPointType.GetGenericArguments()[0];
    
            if (paramType == typeof(object) && outputPointType.IsGenericType)
            {
                var parsingActionMethod = outputPointType.GetMethod(_PARSING_ACTION_OBJECT_STR, BindingFlags.NonPublic | BindingFlags.Instance);
    
                return parsingActionMethod.Invoke(outputPoint, new object[] { handlerValue });
            }
            else
            {
                return handlerValue;
            }
        }
        else
        {
            if (outputPointType.IsGenericType)
            {
                var parsingActionMethod = outputPointType.GetMethod(_PARSING_ACTION_EMPTY_STR, BindingFlags.NonPublic | BindingFlags.Instance);
    
                return parsingActionMethod.Invoke(outputPoint, new object[] { handlerValue });
            }
            else
            {
                return handlerValue;
            }
        }
    }

    Создание связи, один из самых по сути сложных моментов во всей системе, рассмотрим каждый этап:

    1. Проверяется флаг активности связи
    2. Во временном списке созданных компонентов ищутся, компонент источник связи и компонент цель
    3. Проверяется тип связи:
      • Если тип связи — это ссылка на переменную, то с помощью рефлексии устанавливаются нужные значения
      • Если связь обычная, то также с помощью рефлексии устанавливаются нужные значения для методов выходных и входных точек

    4. Для обычной связи, для начала проверяется наследует ли компонент интерфейсы IInputPointParse и IOutputPointParse.
    5. В зависимости от результатов предыдущего пункта происходит получение через рефлексию поля LinkedInputPoints в компоненте источнике и метода обработчика в компоненте цели.
    6. Получение метода обработчики происходит через конверсию вызова метода минуя MethodInfo.Invoke в простой вызов Action<T>. Однако получение такой ссылки через рефлексию слишком сложное, поэтому в классе OUTPUT_POINT<T> заведены специальные методы, которые позволяют это сделать. Для не generic варианта этого класса, таких действий не требуется.

      private Action<T> ParsingActionEmpty(Action action)
      {
          Action<T> parsedAction = (value) => action();
      
          return parsedAction;
      }
      
      private Action<T> ParsingActionObject(Action<object> action)
      {
          Action<T> parsedAction = (value) => action(value);
      
          return parsedAction;
      }
        }

      Первый метод используется, когда входная точка не принимает никаких параметров. Второй метод, соответственно для входной точки с параметром.

    7. Через рефлексию в поле LinkedInputPoints (которое как было показано выше, является списком) добавляется ссылка на Action – обработчик


    Работа с Mono-методами
    private void SearchMonoBehaviourMethod(LogicComponent component, IDictionary<string, List<MonoMethodData>> monoBehaviourMethods)
    {
        var type = component.GetType();
        var methods = type.GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Instance);
    
        foreach (var method in methods)
        {
            if (_monoMethods.Keys.Contains(method.Name))
            {
                var priorityAttributes = method.GetCustomAttributes(typeof(ExecuteOrderAttribute), true);
                var priority = (priorityAttributes.Length > 0) ? ((ExecuteOrderAttribute)priorityAttributes[0]).Order : int.MaxValue;
    
                monoBehaviourMethods[method.Name].Add(new MonoMethodData(method, component, priority, _monoMethods[method.Name]));
            }
        }
    }

    Каждый метод сохраняется в словаре через ссылку на специальный класс, через который и происходит вызов. Этот класс автоматически конвертирует метод в ссылку на Action. Таким образом происходит значительное ускорение вызовов Mono-методов по сравнению с обычным MethodInfo.Invoke.

    private class MonoMethodData
    {
        public int Order { get; private set; }
    
        private Action _monoMethodWrapper;
        private Action<bool> _monoMethodParamWrapper;
    
        public MonoMethodData(MethodInfo method, object target, int order, bool withParam)
        {
            if (!withParam)
            {
                _monoMethodWrapper = (Action)Delegate.CreateDelegate(typeof(Action), target, method.Name);
            }
            else
            {
                _monoMethodParamWrapper = (Action<bool>)Delegate.CreateDelegate(typeof(Action<bool>), target, method.Name);
            }
    
            Order = order;
        }
    
        public void Call() =>_monoMethodWrapper();
        public void Call(bool param) =>  _monoMethodParamWrapper(param);
    }

    Примечание: перегрузка метода Call с параметром bool используется для Mono-методов типа ApplicationPause и т.п.

    Внешний запуск логики


    Внешний запуск логики, это запуск во время работы приложения, такая логика не задается в сцене и на нее нет ссылки. Её можно загрузить из бандла, либо из ресурсов. В целом запуск извне мало чем отличается от запуска на старте сцены, за исключением работы с Mono-методами.

    Код метода для внешнего (отложенного) исполнения логики
    public void RunLogicExternal(LogicStorage logicStorage)
    {
        var instances = new Dictionary<string, LogicComponent>();
        var runMonoMethods = new Dictionary<string, List<MonoMethodData>>();
    
        foreach (var monoMethodName in _monoMethods.Keys)
        {
            runMonoMethods.Add(monoMethodName, new List<MonoMethodData>());
        }
    
        foreach (var componentData in logicStorage.Components.Items)
        {
            CreateComponent(componentData, instances, _disposableInstances, runMonoMethods);
        }
    
        logicStorage.Links.Items.Sort(SortingLinks);
    
        foreach (var linkData in logicStorage.Links.Items)
        {
            CreateLink(linkData, instances);
        }
    
        foreach (var monoMethods in runMonoMethods.Values)
        {
            monoMethods.Sort(SortingMonoMethods);
        }
    
        if (runMonoMethods.ContainsKey(_START_STR))
        {
            CallMonoBehaviourMethod(_START_STR, runMonoMethods, true);
        }
    
        foreach (var monoMethodName in runMonoMethods.Keys)
        {
            _monoBehaviourMethods[monoMethodName].AddRange(runMonoMethods[monoMethodName]);
        }
    
        foreach (var monoMethods in _monoBehaviourMethods.Values)
        {
            monoMethods.Sort(SortingMonoMethods);
        }
    }


    Как видно, здесь происходит сохранение всех ссылок на Mono-методы в локальном словаре, после чего происходит запуск методов Start и затем все остальные методы добавляются в общий словарь.

    Запуск логики как экземпляра


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

    Для запуска логики как экземпляра, происходит сохранение ссылок на все экземпляры компонентов и т.п данные, которые после завершения работы, можно удалить, тем самым очистив память.

    Код класса хранилища данных экземпляра логики:

    private class InstanceLogicData
    {
        public readonly IList<LogicComponent> ComponentInstances = new List<LogicComponent>();
        public readonly IDictionary<string, List<MonoMethodData>> MonoBehaviourMethods = new Dictionary<string, List<MonoMethodData>>();
        public readonly IList<IDisposable> DisposableInstances = new List<IDisposable>();    
    }

    Сам запуск логики похож на внешний запуск описанный ранее.
    public string RunLogicInstance(LogicStorage logicStorage, object data)
    {
        var id = Guid.NewGuid().ToString();
        var logicInstanceData = new InstanceLogicData();
        var instances = new Dictionary<string, LogicComponent>();
    
        _logicInstances.Add(id, logicInstanceData);
    
        foreach (var componentData in logicStorage.Components.Items)
        {
            CreateComponent(componentData, instances, logicInstanceData.DisposableInstances, logicInstanceData.MonoBehaviourMethods);
        }
    
        logicStorage.Links.Items.Sort(SortingLinks);
    
        foreach (var linkData in logicStorage.Links.Items)
        {
            CreateLink(linkData, instances);
        }
    
        foreach (var monoMethods in logicInstanceData.MonoBehaviourMethods.Values)
        {
            monoMethods.Sort(SortingMonoMethods);
        }
        return id;
    }


    Из кода видна, что после создания логики, данные о ней сохраняются в экземпляре специализированного класса, а после запуска возвращается уникальный идентификатор экземпляра логики.

    Данный идентификатор нужен, чтобы вызвать функцию остановки и очистки памяти.
    public void StopLogicInstance(string instanceId)
    {
        if (!_logicInstances.ContainsKey(instanceId)) return;
    
        var logicInstance = _logicInstances[instanceId];
    
        foreach (var disposableInstance in logicInstance.DisposableInstances)
        {
            disposableInstance.Dispose();
        }
    
        foreach (var componentInstance in logicInstance.ComponentInstances)
        {
            Destroy(componentInstance);
        }
    
        logicInstance.ComponentInstances.Clear();
        logicInstance.DisposableInstances.Clear();
        logicInstance.MonoBehaviourMethods.Clear();
    
        _logicInstances.Remove(instanceId);
    }


    Завершение работы сцены и очистка памяти


    При создании ScriptableObject в работающей сцене через ScriptableObject.CreateInstance, экземпляр ведет себя также, как и MonoBehaviour, т. е. по выгрузке из сцены, для каждого будет вызван OnDestroy и он будет удален из памяти. Однако как было сказано в прошлой статье, компонент может наследовать IDisposable, поэтому очистку я произвожу в методе OnDisable:

    void OnDisable()
    {
        foreach (var disposable in _disposableInstances)
        {
            disposable.Dispose();
        }
    
        _disposableInstances.Clear();
        _monoBehaviourMethods.Clear();
    
        foreach (var logicInstance in _logicInstances.Values)
        {
            foreach (var disposableInstance in logicInstance.DisposableInstances)
            {
                disposableInstance.Dispose();
            }
    
            logicInstance.DisposableInstances.Clear();
            logicInstance.ComponentInstances.Clear();
            logicInstance.MonoBehaviourMethods.Clear();
        }
    
        _logicInstances.Clear();
    
        _instance = null;
    }

    Примечание: как видно, в случае, если экземпляры логики существуют на момент выгрузки сцены, то происходит очистка и в них.

    Проблема и решение с объектами Unity


    В первой части статьи о визуальном редакторе, я упоминал о проблеме, связанной с сохранением ссылок на объекты сцены, которая связана с невозможностью восстановить сериализованные данные. Это связано с тем, что уникальный идентификатор объектов сцены при каждом запуске сцены разный. Решением данный проблемы является единственный вариант — это перевести хранение ссылок в сцену. С этой целью в контроллере логики было добавлено специализированное хранилище и класс-обертка, через которую компоненты логики получают ссылки на объекты Unity, в том числе ресурсы, префабы и другие ассеты.

    Код для хранилища
    [Serializable]
    private class ObjectLinkData
    {
        public string Id => _id;
        public UnityEngine.Object Obj => _obj;
    
        [SerializeField] private string _id = string.Empty;
        [SerializeField] private UnityEngine.Object _obj;
    
        public ObjectLinkData(string id, UnityEngine.Object obj)
        {
            _id = id;
            _obj = obj;
        }
    }
    
    [SerializeField] [HideInInspector] private List<ObjectLinkData> _objectLinks = new List<ObjectLinkData>();
    
    public UnityEngine.Object GetObject(string id)
    {
        var linkData = _objectLinks.Find(link =>
        {
            return string.Compare(link.Id, id, StringComparison.Ordinal) == 0;
        });
    
        return linkData?.Obj;
    }
    

    Здесь:

    1. Id – уникальный идентификатор ассета или объекта сцены
    2. Obj – ссылка на ассет или объекты сцены


    Как видно по коду, ничего сверхсложного.

    Теперь рассмотрим класс-обертку для объектов Unity
    
    [Serializable]
    public class VLObject
    {
        public string Id => _id;
        public UnityEngine.Object Obj
        {
            get
            {
                if (_obj == null && !_objNotFound)
                {
                    _obj = Core.LogicController.Instance.GetObject(Id);
    
                    if (_obj == null)
                    {
                        _objNotFound = true;
                    }
                }
                return _obj;
            }
        }
    
        private UnityEngine.Object _obj;
    
        [SerializeField] private string _id;
    
        private bool _objNotFound;
    
        public VLObject() { }
        public VLObject(UnityEngine.Object obj)
        {
            _obj = obj;
        }
    
        public T Get<T>() where T : UnityEngine.Object
        {
            return Obj as T;
        }
    }
    

    Примечание: флаг _objNotFound необходим, чтобы не производить поиск в хранилище каждый раз, если объекта в нем нет.

    Производительность


    Теперь, я думаю, стоит остановиться на таком вопросе как производительность. Если внимательно посмотреть на весь код выше, то можно понять, что в уже запущенном приложении система никак не влияет на скорость, т.е. все будет работать как обычно приложение Unity. Я сравнивал Update обычного MonoBehaviour и через uViLEd и не смог достичь хоть какой-либо разницы, которую можно было здесь указать, все цифры паритетны вплоть до 1000 вызовов за кадр. Единственное узкое место — это скорость загрузки сцены, но и тут значимых цифр у меня не удалось получить, хотя между платформами (я проверял Android и iOS) разница большая. В логике на 70 компонентов и порядка 150 связей (включая ссылки на переменные) цифры получились следующие:

    1. Android (очень посредственный MediaTek 8-ядерный)
      — На старте приложения — ~750мс
      — Запуск сцены в запущенном приложении ~250мс
    2. iOS (iPhone 5s)
      — На старте приложения — ~100мс
      — Запуск сцены в запущенном приложении ~50мс

    Заключение


    Архитектура ядра системы uViLEd довольно-таки проста и незамысловата, использовать ее опосредовано от визуального редактора не целесообразно, хотя не исключаю, что какие-то решения для кого-то окажутся полезными. Тем не менее данная статья важна для общего понимания принципа работы системы, последней важной частью которой остался сам редактор. И этому будет посвящена последняя заключительная часть серии, которая будет самой насыщенной.

    PS: написание статьи сильно затянулось, о чем прошу прощения. Изначально я планировал выпустить сразу все оставшиеся части, однако выход новых версий Unity 3d, а также жизненные (приятные и не очень) потрясения изменили планы.

    Визуальный редактор логики для Unity3d. Часть 1
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 0

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое