Избавляемся от «мистических» строк в системе реактивного связывания на 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 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 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
{
[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 IMutablePropertyIsConnectionLost;
[Bind("mech/slots-count")]
public IMutableProperty 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 на префабе.
Резолверы имеют простую структуру и хорошо подходят для кодогенерации:
// ------------------------------------------------------------------------------
//
// This code was generated by ViewModelBindingsGenerator
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
//
// ------------------------------------------------------------------------------
using PS.ReactiveBindings;
using Test;
namespace BindViewModel
{
public partial struct BindViewModelResolver
{
private static ConnectionStatusViewModel ResolveConnectionStatusViewModel(IViewModel viewModel)
=> new ConnectionStatusViewModel
{
IsConnectionLost = LookupProperty>(
"ConnectionStatusViewModel",
viewModel,
PropertyType.String,
"connection/is-lost",
true),
SomeCollection = LookupProperty(
"ConnectionStatusViewModel",
viewModel,
PropertyType.Collection,
"mech/tempCollection",
true),
};
}
}
Темплейт для генерации:
<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ parameter name ="m_GenerationInfo" type="WarRobots.RBViewModelWrapperGenerator.BindViewModel.GenerationInfo"#>
// ------------------------------------------------------------------------------
//
// This code was generated by ViewModelBindingsGenerator
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
//
// ------------------------------------------------------------------------------
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 _resolvers;
static partial void InitResolvers();
public static T Resolve(IViewModel viewModel) where T : struct, IBindViewModel
{
InitResolvers();
if (_resolvers != null && _resolvers.ContainsKey(typeof(T)))
{
var resolver = (Resolver) _resolvers[typeof(T)];
return resolver.Resolve(viewModel);
}
return ResolveWithReflection(viewModel);
}
private class CanNotResolvePropertyException : System.Exception
{
public CanNotResolvePropertyException(string message) : base(message)
{
}
}
private interface IResolver
{
}
private struct Resolver : IResolver
where T : struct, IBindViewModel
{
public delegate T ResolveDelegate(IViewModel viewModel);
public ResolveDelegate Resolve;
}
private static Resolver FromDelegate(Resolver.ResolveDelegate resolveDelegate)
where T : struct, IBindViewModel
=> new Resolver {Resolve = resolveDelegate};
private static T LookupProperty(
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(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();
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
{
{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"#>
<#@ import namespace="System.Collections.Generic" #>
<#@ import namespace="BindViewModel" #>
// ------------------------------------------------------------------------------
//
// This code was generated by ViewModelBindingsGenerator
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
//
// ------------------------------------------------------------------------------
using System.Collections.Generic;
<#
List namespaces = new List();
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
{
<#
foreach (var generationInfo in m_GenerationInfos)
{
#>
{typeof(<#=generationInfo.ClassName #>), FromDelegate(Resolve<#=generationInfo.ClassName #>)},
<#
}
#>
};
}
}
}
Итак, теперь у нас есть инструмент создания резолверов. Осталось создать инструмент для его вызова. А это задача генератора.
Генератор проходится по assemblies и выискивает контракты-наследники IBindViewModel. Найдя контракт, он проходит по нему и заполняет информацию для генерации. Текущая информация состоит из имени переменной, типа, пути для связывания и прочего. Затем подготовленная информация передается непосредственно в T4-генератор.
Код для сбора информации:
List generationInfos = new List();
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();
var fields = type.GetFields();
foreach (var field in fields)
{
var bindAttribute = field.GetCustomAttribute();
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 {["_m_GenerationInfoField"] = gInfo}
};
viewModelBindingsTemplateGenerator.Initialize();
var generationData = viewModelBindingsTemplateGenerator.TransformText();
File.WriteAllText(fullOutputPath + gInfo.ClassName + ".cs", generationData);
}
var viewModelResolverTemplateGenerator = new ViewModelResolverTemplate()
{
Session = new Dictionary {["_m_GenerationInfosField"] = generationInfos}
};
viewModelResolverTemplateGenerator.Initialize();
var generationResult = viewModelResolverTemplateGenerator.TransformText();
File.WriteAllText(fullOutputPath + "BindViewModelResolverGenerated.cs", generationResult);
Как результат — теперь мы можем инициализировать контракт следующим образом:
Var DroneInfoViewModel = BindViewModelResolver.Resolve(_droneInfoModel);
Пример сгенеренного резолвера для DroneInfoViewModel:
public partial struct BindViewModelResolver
{
private static DroneInfoViewModel ResolveDroneInfoViewModel(IViewModel viewModel)
=> new DroneInfoViewModel
{
OnTopInfoClick = LookupProperty("DroneInfoViewModel",viewModel, PropertyType.Event, "current-drone-info/on-top-info-click", true),
OnBottomInfoClick = LookupProperty("DroneInfoViewModel",viewModel, PropertyType.Event, "current-drone-info/on-bottom-info-click", true),
DroneName = LookupProperty>("DroneInfoViewModel",viewModel, PropertyType.String, "current-drone-info/drone-name", true),
DroneTier = LookupProperty>("DroneInfoViewModel",viewModel, PropertyType.String, "current-drone-info/drone-tier", true),
VoltageCurrent = LookupProperty>("DroneInfoViewModel",viewModel, PropertyType.Integer, "current-drone-info/voltage-current", true),
VoltageMax = LookupProperty>("DroneInfoViewModel",viewModel, PropertyType.Integer, "current-drone-info/voltage-max", true),
VoltageRange = LookupProperty>("DroneInfoViewModel",viewModel, PropertyType.String, "current-drone-info/voltage-range", true),
SpawnChargeCost = LookupProperty>("DroneInfoViewModel",viewModel, PropertyType.Integer, "current-drone-info/spawn-charge-cost", true),
SpawnHardCost = LookupProperty>("DroneInfoViewModel",viewModel, PropertyType.Integer, "current-drone-info/spawn-hard-cost", true),
BuyCurrency = LookupProperty>("DroneInfoViewModel",viewModel, PropertyType.String, "current-drone-info/buy-currency", true),
BuyPriceValue = LookupProperty>("DroneInfoViewModel",viewModel, PropertyType.Integer, "current-drone-info/buy-price-value", true),
SchemeSlotsInfo = LookupProperty("DroneInfoViewModel",viewModel, PropertyType.Collection, "current-drone-info/scheme-slots-info", true),
DroneId = LookupProperty>("DroneInfoViewModel",viewModel, PropertyType.String, "current-drone-info/drone-id", true),
InLoadingState = LookupProperty>("DroneInfoViewModel",viewModel, PropertyType.Boolean, "drone-info/in-loading-state", true),
DroneExist = LookupProperty>("DroneInfoViewModel",viewModel, PropertyType.Boolean, "drone-info/drone-exist", true),
DroneNoSlot = LookupProperty>("DroneInfoViewModel",viewModel, PropertyType.Boolean, "drone-info/drone-no-slot", true),
DroneNoDrone = LookupProperty>("DroneInfoViewModel",viewModel, PropertyType.Boolean, "drone-info/drone-no-drone", true),
IsDroneBlueprint = LookupProperty>("DroneInfoViewModel",viewModel, PropertyType.Boolean, "drone-info/drone-blueprint", true),
};
}
Напоследок — в паре слов о валидаторах.
Чтобы включить валидацию для модели, нужно всего лишь прописать атрибут ExpectReactiveContract:
[ExpectReactiveContract(typeof(DroneInfoViewModel))]
private ViewModel _droneInfoModel;
При наличии ошибок в редакторе будет выведено предупреждение вида:
Валидатор работает на основе рефлексии, пробегая по Bind-полям и проверяя их наличие в модели.
Наличие валидации принесло нам ряд преимуществ:
- уменьшилось время ручного тестирования;
- поиск ошибок стал проще;
- упростилась дальнейшая поддержка/переработка UI;
- стало стабильнее и легче переиспользование классов, описывающих работу с UI.
На текущий момент только одна большая фича в проекте сделана полностью на основе описанного метода и еще несколько старых функциональностей переведено на новые рельсы. Данный инструмент новый и точно еще подвергнется дополнениям и изменениям.