Избавляемся от «мистических» строк в системе реактивного связывания на Unity

от автора

Любая система, которая часто используется в проекте, со временем обречена на эволюцию. Так случилось и с нашей системой реактивного связывания reactive bindings.

Что это за система? Она позволяет нам связывать данные на префабе с данными в коде. У нас есть ViewModel, лежащая на префабе. В ней есть некие ключи с разными типами. Соответственно, вся остальная логика, которая у нас привязана к UI, привязана к этим ключам и их изменениям. То есть, если у нас есть некая логическая переменная, меняя ее в коде, мы можем менять любые состояния UI автоматически.

Использование reactive bindings принесло нам как множество новых возможностей, так и ряд зависимостей. Для связи переменных кода и ViewModel, лежащей на префабе, нам необходимо было соответствие строковых имен. Это приводило к тому, что в результате неосторожной правки префаба или ошибки мерджа могли быть утеряны какие-то из этих связей, а ошибка замечалась уже на этапе поздних тестов в виде отвалившегося UI-функционала.

Росла частота использования системы — росло число подобных сложностей.

Два основных неудобства, с которыми мы столкнулись:

  • Строковые ключи в коде;
  • Нет проверки соответствия ключей в коде и ключей в модели.

Эта статья — о том, как мы дополнили систему и тем самым закрыли эти потребности.

Но обо всем по порядку.

В наших reactive bindings доступ к полям происходит по связке «тип поля-строковый путь» во ViewModel. Отсюда повсеместно мы имели подобный код:

Посмотреть код

public static class AbilitiesPresenter {   private static readonly PropertyName MechAbilities = "mech/abilities";   private static readonly PropertyName MechAbilitiesIcon = "mech/abilities/icon";   private static readonly PropertyName MechAbilitiesName = "mech/abilities/name";   private static readonly PropertyName MechAbilitiesDescription = "mech/abilities/description";    public static void Present(IViewModel viewModel, List<AbilityInfo> data)   {      var collection = viewModel.GetMutableCollection(MechAbilities);      collection.Fill(data, SetupAbilityItem);   }    private static void SetupAbilityItem(AbilityInfo data, IViewModel model)   {      model.GetString(MechAbilitiesIcon).Value = data.Icon;      model.GetString(MechAbilitiesName).Value = data.Name;      model.GetString(MechAbilitiesDescription).Value = data.Desc;   } }  

То есть, посредством GetString/GetInteger/GetBoolean и т. д. мы получаем ссылку на поле в модели и пишем/читаем значения.

В чем проблема этой системы? А в том, что чем больше полей в модели — тем больше «строк» в коде. Читать и поддерживать подобный стиль становится весьма проблематично.

Контролировать соответствие типов/путей в коде и в реальной ViewModel — та еще боль. Если c UI-префабом работает больше одного человека, может возникнуть неявный мердж, в результате которого какие-то ключи могут «потеряться». Об этом мы узнаем только на этапе поздних тестов, когда UI перестает работать корректно.

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

Второй подзадачей являлось получение инструмента, который позволит валидировать эти значения, чтобы мы могли быть на 100% уверены, что текущая ViewModel на префабе соответствует текущему контракту и содержит все необходимые поля.

Желаемый формат работы выглядел примерно так:

  • Для работы с ViewModel создается некий «контракт», в котором описаны все поля и строковые связи;
  • Далее нам нужно вызвать некий механизм инициализации этого контракта;
  • В редакторе во ViewModel мы должны иметь явные сообщения об ошибках при отсутствии каких-то полей в модели или во вложенной коллекции.

В проекте есть отличный механизм для валидирования и вывода информации о возможных ошибках в редакторе — модуль Validate. И есть возможность использовать кодогенерацию (T4). Все это мы задействовали, чтобы решить поставленную задачу.

Теперь ближе к коду.

Раньше описание работы с элементами у нас было в следующем стиле:

Посмотреть код

public static class AbilitiesPresenter {   private static readonly PropertyName MechAbilities = "mech/abilities";   private static readonly PropertyName MechAbilitiesIcon = "mech/abilities/icon";   private static readonly PropertyName MechAbilitiesName = "mech/abilities/name";   private static readonly PropertyName MechAbilitiesDescription = "mech/abilities/description";    public static void Present(IViewModel viewModel, List<AbilityInfo> data)   {      var collection = viewModel.GetMutableCollection(MechAbilities);      collection.Fill(data, SetupAbilityItem);   }    private static void SetupAbilityItem(AbilityInfo data, IViewModel model)   {      model.GetString(MechAbilitiesIcon).Value = data.Icon;      model.GetString(MechAbilitiesName).Value = data.Name;      model.GetString(MechAbilitiesDescription).Value = data.Desc;   } } 

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

Стало же все выглядеть так:

Посмотреть код

namespace DroneDetails {   public class DroneDetailScreenView : UIScreenViewWith3D<DroneViewUI3DScreen>   {      [ExpectReactiveContract(typeof(DroneInfoViewModel))] [ExpectNotNull] [SerializeField]      private ViewModel _droneInfoModel;       [ExpectReactiveContract(typeof(DroneScreenMainEventsModel))] [ExpectNotNull] [SerializeField]      private ViewModel _droneScreenMainEventsModel;       [ExpectReactiveContract(typeof(DroneScreenInfoModel))] [ExpectNotNull] [SerializeField]      private ViewModel _droneScreenInfoModel;       [ExpectReactiveContract(typeof(DroneSpawnInfoViewModel))] [ExpectNotNull] [SerializeField]      private ViewModel _droneSpawnInfoViewModel;       [ExpectReactiveContract(typeof(ScrollListViewModel))] [ExpectNotNull] [SerializeField]      private ViewModel _scrollListViewModel; //….    } } 

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

public struct ConnectionStatusViewModel : IBindViewModel { //пример описания полей   [Bind("connection/is-lost")]   public IMutableProperty<string>IsConnectionLost;   [Bind("mech/slots-count")]    public IMutableProperty<int> SlotsCount;  //задание контракта для элементов вложенной коллекции   [Bind("current-drone-info/scheme-slots-info")]      [SchemaContract(typeof(SchemeSlotInfoViewModel))]   public IMutableCollection SchemeSlotsInfo; } 

В этом варианте есть явное типизированное поле. Сверху атрибутом Bind описывается строка, которая связывает это поле с ViewModel.

private void OnPreviewDrone(int index) {   _droneDetailModel.DroneScrollStateModel.SaveState(index);   var droneId = _dronesListModel.GetDroneIdByIndex(index);   _view.DroneInfoViewModel.DroneId.Value = droneId;   //... } 

Способ использования теперь стал каноничным: мы берем структуру (контракт) и устанавливаем новое значение одному из полей (в примере это DroneId).

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

Для описания контракта используются два основных атрибута: Bind и SchemaContract. Bind отвечает за связывание поля структуры с полем во ViewModel. Атрибут получает ключ и опциональное поле IsRequired, говорящее о том, действительно ли во ViewModel необходимо иметь конкретный ключ или ничего не произойдет, если его упустить.

При помощи Bind мы передаем строковые ключи и используем этот атрибут для передачи информации в кодогенератор:

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field |                AttributeTargets.GenericParameter)] public class BindAttribute : Attribute {   public string ViewModelPath { get; }   public bool IsRequired { get; }    public BindAttribute(string value, bool isRequired = true)   {      ViewModelPath = value;      IsRequired = isRequired;   } } 

Атрибут SchemaContract служит с целью указания контракта для элементов коллекции:

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field |   AttributeTargets.GenericParameter)] public class SchemaContractAttribute : Attribute {   public System.Type[] BindViewModelContracts;    public SchemaContractAttribute(params System.Type[]contracts)   {      BindViewModelContracts = contracts;   } } 

Итак, теперь у нас есть контракт. Дело осталось за малым: написать механизм, который позволит его полноценно использовать. Необходимо как-то заполнить значения всех полей. Для этого мы реализуем специализированный класс — резолвер.

Резолвер — класс, который может проинициализировать поля структуры (контракта). Он и выполняет роль связывания между контрактом и ViewModel на префабе.

Резолверы имеют простую структуру и хорошо подходят для кодогенерации:

Посмотреть код

// ------------------------------------------------------------------------------ // <auto-generated> //     This code was generated by ViewModelBindingsGenerator //     Changes to this file may cause incorrect behavior and will be lost if //     the code is regenerated. // </auto-generated> // ------------------------------------------------------------------------------  using PS.ReactiveBindings; using Test;  namespace BindViewModel {    public partial struct BindViewModelResolver    {        private static ConnectionStatusViewModel ResolveConnectionStatusViewModel(IViewModel viewModel)            => new ConnectionStatusViewModel            {                IsConnectionLost = LookupProperty<IMutableProperty<string>>(                "ConnectionStatusViewModel",                viewModel,                 PropertyType.String,                 "connection/is-lost",                 true),                 SomeCollection = LookupProperty<IMutableCollection>(                "ConnectionStatusViewModel",        viewModel,                 PropertyType.Collection,                 "mech/tempCollection",                 true),            };    } } 

Темплейт для генерации:

Посмотреть код

<#@ template debug="false" hostspecific="false" language="C#" #> <#@ parameter name ="m_GenerationInfo" type="WarRobots.RBViewModelWrapperGenerator.BindViewModel.GenerationInfo"#>  // ------------------------------------------------------------------------------ // <auto-generated> //     This code was generated by ViewModelBindingsGenerator //     Changes to this file may cause incorrect behavior and will be lost if //     the code is regenerated. // </auto-generated> // ------------------------------------------------------------------------------  using PS.ReactiveBindings; using <#=m_GenerationInfo.Namespace #>;  namespace BindViewModel {    public partial struct BindViewModelResolver    {        private static <#=m_GenerationInfo.ClassName #> Resolve<#=m_GenerationInfo.ClassName #>(IViewModel viewModel)            => new <#=m_GenerationInfo.ClassName #>            { <#   foreach (var property in m_GenerationInfo.PropertiesInfo)   {      var requiredString = property.Required.ToString().ToLower(); #>                <#=property.Name #> = LookupProperty<<#=property.PropertyTypeName #>>("<#=m_GenerationInfo.ClassName #>",viewModel, <#=property.ReactivePropertyTypeName #>, "<#=property.ViewModelPath #>", <#=requiredString #>), <#   } #>            };    } }

Класс BindViewModelResolver — partial и имеет генерируемую часть. Задача метода resolve — найти нужный резолвер для контракта и с его помощью выполнить связывание между логической и префабной частью.

Также есть метод ResolveWithReflection (fallback), который выполняет данное связывание через рефлексию. Это сделано на случай, если у нас отсутствует сгенерированный резолвер. Рефлексия работает медленнее, поэтому мы стараемся ее избегать.

Посмотреть код

public partial struct BindViewModelResolver {   private static Dictionary<System.Type, IResolver> _resolvers;   static partial void InitResolvers();    public static T Resolve<T>(IViewModel viewModel) where T : struct, IBindViewModel   {      InitResolvers();      if (_resolvers != null && _resolvers.ContainsKey(typeof(T)))      {         var resolver = (Resolver<T>) _resolvers[typeof(T)];         return resolver.Resolve(viewModel);      }       return ResolveWithReflection<T>(viewModel);   }    private class CanNotResolvePropertyException : System.Exception   {      public CanNotResolvePropertyException(string message) : base(message)      {      }   }    private interface IResolver   {   }    private struct Resolver<T> : IResolver      where T : struct, IBindViewModel   {      public delegate T ResolveDelegate(IViewModel viewModel);       public ResolveDelegate Resolve;   }    private static Resolver<T> FromDelegate<T>(Resolver<T>.ResolveDelegate resolveDelegate)      where T : struct, IBindViewModel      => new Resolver<T> {Resolve = resolveDelegate};    private static T LookupProperty<T>(      string holderName,      IViewModel viewModel,      PropertyType type,      PropertyName id,      bool required)      where T : class, IReactive   {      T obj = viewModel.LookupProperty(id, type) as T;      if (obj == null)      {         if (required)         {            throw new CanNotResolvePropertyException(               $"{holderName} -> Can't resolve {id} path => \n PropertyType.{type} \n {id}"            );         }          Debug.LogWarning($"{holderName} -> Can't resolve {id} path => \n PropertyType.{type} \n {id}");      }       return obj;   }    private static T ResolveWithReflection<T>(IViewModel viewModel)   {      var type = typeof(T);      var fields = type.GetFields();      var resolvedStruct = System.Activator.CreateInstance(type);      foreach (var field in fields)      {         var bindAttribute = field.GetCustomAttribute<BindAttribute>();         if (bindAttribute != null)         {            var viewModelPath = bindAttribute.ViewModelPath;            var result = ResolveFieldValue(type.Name, field.FieldType, viewModelPath, viewModel, bindAttribute.IsRequired);            field.SetValue(resolvedStruct, result);         }      }       return (T) resolvedStruct;   } 

Сами резолверы лежат в словаре по типам. Этот список резолверов и описан в сгенерированной части. А сама она выглядит так:

Посмотреть код

public partial struct BindViewModelResolver {    static partial void InitResolvers()    {         if (_resolvers != null) return;        _resolvers = new Dictionary<System.Type, IResolver>        {            {typeof(DroneInfoViewModel), FromDelegate(ResolveDroneInfoViewModel)},            {typeof(DroneSchemeMetaphorViewModel), FromDelegate(ResolveDroneSchemeMetaphorViewModel)},            {typeof(DroneScreenInfoModel), FromDelegate(ResolveDroneScreenInfoModel)},            {typeof(DroneScreenMainEventsModel), FromDelegate(ResolveDroneScreenMainEventsModel)},            {typeof(DroneSpawnInfoViewModel), FromDelegate(ResolveDroneSpawnInfoViewModel)},            {typeof(DroneStoreItemViewModel), FromDelegate(ResolveDroneStoreItemViewModel)},            {typeof(HangarSlotViewModel), FromDelegate(ResolveHangarSlotViewModel)},            {typeof(SchemeSlotInfoViewModel), FromDelegate(ResolveSchemeSlotInfoViewModel)},            {typeof(ScrollListViewModel), FromDelegate(ResolveScrollListViewModel)},            {typeof(StateItemViewModel), FromDelegate(ResolveStateItemViewModel)},            {typeof(ConnectionStatusViewModel), FromDelegate(ResolveConnectionStatusViewModel)},            {typeof(TitanStateViewModel), FromDelegate(ResolveTitanStateViewModel)},            {typeof(MechStateViewModel), FromDelegate(ResolveMechStateViewModel)},            {typeof(ChipOfferItemViewModel), FromDelegate(ResolveChipOfferItemViewModel)},            {typeof(DroneOfferItemViewModel), FromDelegate(ResolveDroneOfferItemViewModel)},        };    } } 

Темплейт для генерируемой части:

Посмотреть код

<#@ template debug="false" hostspecific="false" language="C#" #> <#@ parameter name ="m_GenerationInfos" type="System.Collections.Generic.List<WarRobots.RBViewModelWrapperGenerator.BindViewModel.GenerationInfo>"#> <#@ import namespace="System.Collections.Generic" #> <#@ import namespace="BindViewModel" #>  // ------------------------------------------------------------------------------ // <auto-generated> //     This code was generated by ViewModelBindingsGenerator //     Changes to this file may cause incorrect behavior and will be lost if //     the code is regenerated. // </auto-generated> // ------------------------------------------------------------------------------  using System.Collections.Generic; <# List<string> namespaces = new List<string>();   foreach (var generationInfo in m_GenerationInfos)   {      if (!namespaces.Contains(generationInfo.Namespace))      { #> using <#=generationInfo.Namespace #>; <#         namespaces.Add(generationInfo.Namespace);      }   } #>  namespace BindViewModel {    public partial struct BindViewModelResolver    {        static partial void InitResolvers()        {             if (_resolvers != null) return;            _resolvers = new Dictionary<System.Type, IResolver>            { <#   foreach (var generationInfo in m_GenerationInfos)   { #>                {typeof(<#=generationInfo.ClassName #>), FromDelegate(Resolve<#=generationInfo.ClassName #>)}, <#   } #>            };        }    } } 

Итак, теперь у нас есть инструмент создания резолверов. Осталось создать инструмент для его вызова. А это задача генератора.

Генератор проходится по assemblies и выискивает контракты-наследники IBindViewModel. Найдя контракт, он проходит по нему и заполняет информацию для генерации. Текущая информация состоит из имени переменной, типа, пути для связывания и прочего. Затем подготовленная информация передается непосредственно в T4-генератор.

Код для сбора информации:

Посмотреть код

List<GenerationInfo> generationInfos = new List<GenerationInfo>(); Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies(); foreach (var assembly in assemblies) {    var types = assembly.GetTypes();    var iBindViewModelType = typeof(IBindViewModel);   foreach (Type type in types)   {      if (type.IsValueType && iBindViewModelType.IsAssignableFrom(type))      {         GenerationInfo generationInfo = new GenerationInfo {ClassName = type.Name, Namespace = type.Namespace};         var props = new List<PropertyInfo>();         var fields = type.GetFields();         foreach (var field in fields)         {            var bindAttribute = field.GetCustomAttribute<BindAttribute>();            if (bindAttribute != null)            {               var propertyInfo = new PropertyInfo();               propertyInfo.Name = field.Name;               propertyInfo.ViewModelPath = bindAttribute.ViewModelPath;               var propertyTypeNames = GetPropertyTypeName(field.FieldType);               propertyInfo.ReactivePropertyTypeName = propertyTypeNames.ReactivePropertyTypeName;               propertyInfo.PropertyTypeName = propertyTypeNames.PropertyTypeName;               propertyInfo.ValueTypeName = propertyTypeNames.ValueTypeName;               propertyInfo.Required = bindAttribute.IsRequired;               props.Add(propertyInfo);            }         }          generationInfo.PropertiesInfo = props;         generationInfos.Add(generationInfo);      }   } } 

Передача информации и запуск T4-генератора:

Посмотреть код

foreach (var gInfo in generationInfos) {   var viewModelBindingsTemplateGenerator = new ViewModelBindingsTemplate   {      Session = new Dictionary<string, object> {["_m_GenerationInfoField"] = gInfo}   };   viewModelBindingsTemplateGenerator.Initialize();   var generationData = viewModelBindingsTemplateGenerator.TransformText();   File.WriteAllText(fullOutputPath + gInfo.ClassName + ".cs", generationData); }  var viewModelResolverTemplateGenerator = new ViewModelResolverTemplate() {   Session = new Dictionary<string, object> {["_m_GenerationInfosField"] = generationInfos} }; viewModelResolverTemplateGenerator.Initialize(); var generationResult = viewModelResolverTemplateGenerator.TransformText(); File.WriteAllText(fullOutputPath + "BindViewModelResolverGenerated.cs", generationResult); 

Как результат — теперь мы можем инициализировать контракт следующим образом:

Var DroneInfoViewModel = BindViewModelResolver.Resolve<DroneInfoViewModel>(_droneInfoModel);

Пример сгенеренного резолвера для DroneInfoViewModel:

Посмотреть код

public partial struct BindViewModelResolver {    private static DroneInfoViewModel ResolveDroneInfoViewModel(IViewModel viewModel)        => new DroneInfoViewModel        {            OnTopInfoClick = LookupProperty<IEvent>("DroneInfoViewModel",viewModel, PropertyType.Event, "current-drone-info/on-top-info-click", true),            OnBottomInfoClick = LookupProperty<IEvent>("DroneInfoViewModel",viewModel, PropertyType.Event, "current-drone-info/on-bottom-info-click", true),            DroneName = LookupProperty<IMutableProperty<string>>("DroneInfoViewModel",viewModel, PropertyType.String, "current-drone-info/drone-name", true),            DroneTier = LookupProperty<IMutableProperty<string>>("DroneInfoViewModel",viewModel, PropertyType.String, "current-drone-info/drone-tier", true),            VoltageCurrent = LookupProperty<IMutableProperty<int>>("DroneInfoViewModel",viewModel, PropertyType.Integer, "current-drone-info/voltage-current", true),            VoltageMax = LookupProperty<IMutableProperty<int>>("DroneInfoViewModel",viewModel, PropertyType.Integer, "current-drone-info/voltage-max", true),            VoltageRange = LookupProperty<IMutableProperty<string>>("DroneInfoViewModel",viewModel, PropertyType.String, "current-drone-info/voltage-range", true),            SpawnChargeCost = LookupProperty<IMutableProperty<int>>("DroneInfoViewModel",viewModel, PropertyType.Integer, "current-drone-info/spawn-charge-cost", true),            SpawnHardCost = LookupProperty<IMutableProperty<int>>("DroneInfoViewModel",viewModel, PropertyType.Integer, "current-drone-info/spawn-hard-cost", true),            BuyCurrency = LookupProperty<IMutableProperty<string>>("DroneInfoViewModel",viewModel, PropertyType.String, "current-drone-info/buy-currency", true),            BuyPriceValue = LookupProperty<IMutableProperty<int>>("DroneInfoViewModel",viewModel, PropertyType.Integer, "current-drone-info/buy-price-value", true),            SchemeSlotsInfo = LookupProperty<IMutableCollection>("DroneInfoViewModel",viewModel, PropertyType.Collection, "current-drone-info/scheme-slots-info", true),            DroneId = LookupProperty<IMutableProperty<string>>("DroneInfoViewModel",viewModel, PropertyType.String, "current-drone-info/drone-id", true),            InLoadingState = LookupProperty<IMutableProperty<bool>>("DroneInfoViewModel",viewModel, PropertyType.Boolean, "drone-info/in-loading-state", true),            DroneExist = LookupProperty<IMutableProperty<bool>>("DroneInfoViewModel",viewModel, PropertyType.Boolean, "drone-info/drone-exist", true),            DroneNoSlot = LookupProperty<IMutableProperty<bool>>("DroneInfoViewModel",viewModel, PropertyType.Boolean, "drone-info/drone-no-slot", true),            DroneNoDrone = LookupProperty<IMutableProperty<bool>>("DroneInfoViewModel",viewModel, PropertyType.Boolean, "drone-info/drone-no-drone", true),            IsDroneBlueprint = LookupProperty<IMutableProperty<bool>>("DroneInfoViewModel",viewModel, PropertyType.Boolean, "drone-info/drone-blueprint", true),        }; } 

Напоследок — в паре слов о валидаторах.

Чтобы включить валидацию для модели, нужно всего лишь прописать атрибут ExpectReactiveContract:

[ExpectReactiveContract(typeof(DroneInfoViewModel))] private ViewModel _droneInfoModel; 

При наличии ошибок в редакторе будет выведено предупреждение вида:

Валидатор работает на основе рефлексии, пробегая по Bind-полям и проверяя их наличие в модели.

Наличие валидации принесло нам ряд преимуществ:

  • уменьшилось время ручного тестирования;
  • поиск ошибок стал проще;
  • упростилась дальнейшая поддержка/переработка UI;
  • стало стабильнее и легче переиспользование классов, описывающих работу с UI.

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

ссылка на оригинал статьи https://habr.com/ru/company/pixonic/blog/533448/


Комментарии

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

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