Русский
Русский
English
Статистика
Реклама

Избавляемся от мистических строк в системе реактивного связывания на 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.

На текущий момент только одна большая фича в проекте сделана полностью на основе описанного метода и еще несколько старых функциональностей переведено на новые рельсы. Данный инструмент новый и точно еще подвергнется дополнениям и изменениям.
Источник: habr.com
К списку статей
Опубликовано: 17.12.2020 10:10:32
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Блог компании pixonic

Программирование

Разработка игр

Unity

Реактивное программирование

Reactive programming

Реактивность

Валидация

Оптимизация

Gamedev

Unity3d

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru