Что это за система? Она позволяет нам связывать данные на префабе с данными в коде. У нас есть 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.
На текущий момент только одна большая фича в проекте сделана полностью на основе описанного метода и еще несколько старых функциональностей переведено на новые рельсы. Данный инструмент новый и точно еще подвергнется дополнениям и изменениям.