Как стать автором
Обновить
0
Pixonic
Developing and publishing games since 2009

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

Время на прочтение15 мин
Количество просмотров3.5K
Любая система, которая часто используется в проекте, со временем обречена на эволюцию. Так случилось и с нашей системой реактивного связывания 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.

На текущий момент только одна большая фича в проекте сделана полностью на основе описанного метода и еще несколько старых функциональностей переведено на новые рельсы. Данный инструмент новый и точно еще подвергнется дополнениям и изменениям.
Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
+21
Комментарии2

Публикации

Информация

Сайт
pixonic.com
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Кипр

Истории