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

Reactive programming

Как безболезненно мигрировать с RxJava на Kotlin CoroutinesFlow

11.01.2021 10:24:00 | Автор: admin
Для выполнения асинхронных операций в Android-приложениях, где нужна загрузка и обработка любых данных, долгое время использовали RxJava и о том, как перейти на RxJava 3, мы уже писали в нашем блоге. Сейчас на смену фреймворку постепенно приходят инструменты Kotlin Coroutines+Flow. Актуальность этой связки подтверждается тем, что Google сделал Kotlin приоритетным языком для Android-разработки.

Корутины позволяют тратить меньше системных ресурсов, чем RxJava. Кроме того, поскольку они являются частью Kotlin, Android предоставляет удобные инструменты для работы с ними например, viewModelScope и lifecycleScope. В этой статье мы рассмотрим use cases, распространенные в Rx Java, и то, какие возможности вы получите при переходе на Flow.



Переключение потоков и создание


Для начала сравним, как происходит переключение потоков в RxJava и Flow.

RxJava


Observable.create<Int> { emitter ->emitter.onNext(1)emitter.onNext(2)emitter.onNext(3)emitter.onComplete()}.observeOn(Schedulers.io()).map {printThread(map1 value = $it)it + it}.doOnNext { printThread(after map1 -> $it) }.observeOn(Schedulers.computation()).map {printThread(map2 value = $it)it * it}.doOnNext { printThread(after map2 -> $it) }.observeOn(Schedulers.single()).subscribe ({printThread(On Next $it)},{printThread(On Error)},{printThread(On Complete)})


При этом сложение выполняется в IO шедулере, умножение в computation шедулере, а подписка в single.

Flow


Повторим этот же пример для Flow:

launch {flow {emit(1)emit(2)emit(3)}.map {printThread(map1 value = $it)it + it}.onEach { printThread(after map1 -> $it) }.flowOn(Dispatchers.IO).map {printThread(map2 value = $it)it * it}.onEach { printThread(after map2 -> $it) }.flowOn(Dispatchers.Default).onCompletion { printThread(onCompletion) }.collect { printThread(received value $it) }}


В результате можно отметить следующее:

1) observeOn переключает поток, в котором будут выполняться последующие операторы, а flowOn определяет диспетчер выполнения для предыдущих операторов.

2) Метод collect() будет выполняться в том же диспетчере, что и launch, а emit данных будет происходить в Dispatchers.IO. Метод subscribe() будет выполняться в Schedulers.single(), потому что идет после него.

3) Flow также имеет стандартные методы создания flow:

  • flowOf(): в примере можно было бы использовать Observable.fromArray(1, 2, 3) и flowOf(1, 2, 3)
  • extenstion function asFlow(), который превращает Iterable, Sequence, массивы во flow
  • билдер flow { }

4) В данном примере Flow, как и RxJava, представляет собой cold stream данных: до вызова методов collect() и subscribe() никакой обработки происходить не будет.

5) В RxJava нужно явно вызывать emitter.onComplete(). В Flow метод onCompletion() будет автоматически вызываться после окончания блока flow { }.

6) При попытке сделать эмит данных из другого диспетчера, с помощью withContext, например, приведет к ошибке.

Exception in thread main java.lang.IllegalStateException: Flow invariant is violated:
Flow was collected in [BlockingCoroutine{Active}@5df83c81, BlockingEventLoop@3383bcd],
but emission happened in [DispatchedCoroutine{Active}@7fbc37eb, Dispatchers.IO].
Please refer to 'flow' documentation or use 'flowOn' instead

Подписка и отписка на источник данных


В RxJava метод Observable.subscribe() возвращает объект Disposable. Он служит для отписки от источника данных, когда новые порции данных от текущего источника уже не нужны. Важно иметь доступ к этому объекту, чтобы вовремя отписываться и избегать утечек.

Для Flow ситуация схожа: так как метод collect() suspend метод, он может быть запущен только внутри корутины. Следовательно, отписка от flow происходит в момент отмены Job корутины:

val job = scope.launch {flow.collect { }}job.cancel() // тут произойдет отписка от flow

В случае же использования viewModelScope об этом заботиться не нужно: все корутины, запущенные в рамках этого scope, будут отменены, когда ViewModel будет очищена, т.е. вызовется метод ViewModel.onCleared(). Для lifecycleScope ситуация аналогична: запущенные в его рамках корутины будут отменены, когда соответствующий Lifecycle будет уничтожен.

Обработка ошибок


В RxJava есть метод onError(), который будет вызван в случае возникновения какой-либо ошибки и на вход получит данные о ней. В Flow тоже есть такой метод, он называется catch(). Рассмотрим следующий пример.

RxJava


Observable.fromArray(1, 2, 3).map {val divider = Random.Default.nextInt(0, 1)it / divider}.subscribe({ value ->println(value)},{ e ->println(e)})


При возникновении ArithmeticException будет срабатывать onError(), и информация об ошибке будет напечатана в консоль.

Flow


flowOf(1, 2, 3).map {val divider = Random.Default.nextInt(0, 1)it / divider}.catch { e -> println(e) }.collect { println(it) }


Этот же пример, переписанный на flow, можно представить с помощью catch { }, который под капотом имеет вид привычной конструкции try/catch.

Операторы RxJava onErrorResumeNext и onErrorReturn можно представить в виде:

catch { emit(defaultValue) } // onErrorReturn

catch { emitAll(fallbackFlow) } // onErrorResumeNext

В Flow, как и в RxJava, есть операторы retry и retryWhen, позволяющие повторить операции в случае возникновения ошибки.

Операторы


Рассмотрим наиболее распространенные операторы RxJava и найдем их аналоги из Flow.



Подробнее с операторами Flow можно познакомиться здесь.

Некоторые операторы Flow (например, merge) помечены как экспериментальные или отсутствующие. Их api может измениться (как, например, для flatMapMerge), или их могут задепрекейтить, то есть они станут недоступны. Это важно помнить при работе с Flow. При этом отсутствие некоторых операторов компенсируется тем, что flow всегда можно собрать в список и работать уже с ним. В стандартной библиотеке Kotlin есть множество функций для работы со списками.

Также у Flow отсутствуют отдельные операторы троттлинга и другие операторы, которые работают с временными промежутками. Это можно объяснить молодостью библиотеки, а также тем, что, согласно словам разработчика Kotlin Романа Елизарова, команда Jetbrains не планирует раздувать библиотеку множеством операторов, оставляя разработчикам возможность компоновать нужные операторы самостоятельно, предоставляя им удобные блоки для сборки.

Backpressure


Backpressure это ситуация, когда производитель данных выдает элементы подписчику быстрее, чем тот их может обработать. Готовые данные, в ожидании того, как подписчик сможет их обработать, складываются в буфер Observable. Проблема такого подхода в том, что буфер может переполниться, вызвав OutOfMemoryError.

В ситуациях, когда возможен backpressure, для Observable нужно применять различные механизмы для предотвращения ошибки MissingBackpressureException.

После появления в RxJava 2 Flowable произошло разделение на источники данных с поддержкой backpressure (Flowable) и Observable, которые теперь не поддерживают backpressure. При работе с RxJava требуется правильно выбрать тип источника данных для корректной работы с ним.

У Flow backpressure заложена в Kotlin suspending functions. Если сборщик flow не может принимать новые данные в настоящий момент, он приостанавливает источник. Возобновление происходит позднее, когда сборщик flow снова сможет получать данные. Таким образом, в Kotlin нет необходимости выбирать тип источника данных, в отличие от RxJava.

Hot streams


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

Горячие источники данных полезны, например, при подписке на события от View: при этом нужно получать только новые события, нет смысла обрабатывать заново все пользовательские действия. Также мы не можем запретить пользователю нажимать на экран до тех пор, пока мы не будем готовы обрабатывать его действия. Для обработки событий от View в реактивном виде существует библиотека RxBinding, которая имеет поддержку RxJava3.

В Kotlin Flow есть свои возможности для работы с горячим flow, который производит данные вне зависимости от наличия подписчиков и выдает новые данные одновременно всем имеющимся подписчикам. Для этого можно использовать Channel, SharedFlow, чтобы отправлять новые порции данных одновременно всем подписанным сборщикам.

Кстати, для Flow тоже есть отличная библиотека для обработки событий от View Corbind. В ней есть поддержка большинства Android-виджетов.

RxJava Subjects


Subject в RxJava это специальный элемент, который одновременно является источником данных и подписчиком. Он может подписаться на один или несколько источников данных, получать от них порции данных и отдавать их своим подписчикам.

Аналог Subject в Flow это Channel, в частности, BroadcastChannel. Существуют различные варианты их реализации: с буферизацией данных (ArrayBroadcastChannel), с хранением только последнего элемента (ConflatedBroadcastChannel). Но важно помнить, что, так как библиотека Kotlin Flow молода и постоянно развивается, ее части могут меняться. Так получилось и в случае с BroadcastChannel: в своей статье Роман Елизаров сообщил, что, начиная с версии 1.4 будет предложено лучшее решение shared flows, а BroadcastChannel ждет deprecation в ближайшем будущем.

Заключение


В данной статье мы сравнили RxJava и Kotlin Flow, рассмотрели их схожие моменты и аналоги частей RxJava в Flow. При этом Flow хорошо подойдет в качестве инструмента для обработки событий в реактивном стиле в проектах на Kotlin, использующих паттерн MVVM: благодаря viewModelScope и lifecycleScope запускать корутины можно быстро и удобно, не боясь утечек. В связи с тем, что популярность Kotlin и его инструментов растет, а также этот язык является приоритетным для разработки Android-приложений, в ближайшие годы связка Coroutines+Flow может заменить RxJava скорее всего, новые проекты будут написаны именно с помощью нее. На первый взгляд, миграция с RxJava на Flow не представляется болезненной, потому что в обоих случаях есть похожие операторы и разделение общей концепции Reactive streams. Кроме того, Kotlin имеет достаточно большое комьюнити, которое постоянно развивается и помогает разработчикам в изучении новых возможностей.

А вы готовы мигрировать на корутины? Приглашаем поделиться мнениями!
Подробнее..

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

17.12.2020 10:10:32 | Автор: admin
Любая система, которая часто используется в проекте, со временем обречена на эволюцию. Так случилось и с нашей системой реактивного связывания 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.

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

Перевод Шпаргалка по Spring Boot WebClient

09.02.2021 02:22:17 | Автор: admin

В преддверии старта курса Разработчик на Spring Framework подготовили традиционный перевод полезного материала.

Также абсолютно бесплатно предлагаем посмотреть запись демо-урока на тему
Введение в облака, создание кластера в Mongo DB Atlas.


WebClient это неблокирующий, реактивный клиент для выполнения HTTP-запросов.

Время RestTemplate подошло к концу

Возможно, вы слышали, что время RestTemplate на исходе. Теперь это указано и в официальной документации:

NOTE: As of 5.0 this class is in maintenance mode, with only minor requests for changes and bugs to be accepted going forward. Please, consider using the org.springframework.web.reactive.client.WebClient which has a more modern API and supports sync, async, and streaming scenarios.

ПРИМЕЧАНИЕ: Начиная с версии 5.0, этот класс законсервирован и в дальнейшем будут приниматься только минорные запросы на изменения и на исправления багов. Пожалуйста, подумайте об использовании org.springframework.web.reactive.client.WebClient, который имеет более современный API и поддерживает синхронную, асинхронную и потоковую передачи.

Конечно, мы понимаем, что RestTemplate не исчезнет мгновенно, однако новой функциональности в нем уже не будет. По этой причине в Интернете можно видеть десятки вопросов о том, что такое WebClient и как его использовать. В этой статье вы найдете советы по использованию новой библиотеки.

Отличия между WebClient и RestTemplate

Если в двух словах, то основное различие между этими технологиями заключается в том, что RestTemplate работает синхронно (блокируя), а WebClient работает асинхронно (не блокируя).

RestTemplate это синхронный клиент для выполнения HTTP-запросов, он предоставляет простой API с шаблонным методом поверх базовых HTTP-библиотек, таких как HttpURLConnection (JDK), HttpComponents (Apache) и другими.

Spring WebClient это асинхронный, реактивный клиент для выполнения HTTP-запросов, часть Spring WebFlux.

Вам, вероятно, интересно, как можно заменить синхронного клиента на асинхронный. У WebClient есть решение этой задачи. Мы рассмотрим несколько примеров использования WebClient.

А сейчас настало время попрощаться с RestTemplate , сказать ему спасибо и продолжить изучение WebClient.

Начало работы с WebClient

Предварительные условия

Подготовка проекта

Давайте создадим базовый проект с зависимостями, используя Spring Initializr.

Теперь взглянем на зависимости нашего проекта. Самая важная для нас зависимость spring-boot-starter-webflux.

<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webflux</artifactId> </dependency>

Spring WebFlux является частью Spring 5 и обеспечивает поддержку реактивного программирования для веб-приложений.

Пришло время настроить WebClient.

Настройка WebClient

Есть несколько способов настройки WebClient. Первый и самый простой создать его с настройками по умолчанию.

WebClient client = WebClient.create();

Можно также указать базовый URL:

WebClient client = WebClient.create("http://base-url.com");

Третий и самый продвинутый вариант создать WebClient с помощью билдера. Мы будем использовать конфигурацию, которая включает базовый URL и таймауты.

@Configurationpublic class WebClientConfiguration {    private static final String BASE_URL = "https://jsonplaceholder.typicode.com";    public static final int TIMEOUT = 1000;    @Bean    public WebClient webClientWithTimeout() {        final var tcpClient = TcpClient                .create()                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, TIMEOUT)                .doOnConnected(connection -> {                    connection.addHandlerLast(new ReadTimeoutHandler(TIMEOUT, TimeUnit.MILLISECONDS));                    connection.addHandlerLast(new WriteTimeoutHandler(TIMEOUT, TimeUnit.MILLISECONDS));                });        return WebClient.builder()                .baseUrl(BASE_URL)                .clientConnector(new ReactorClientHttpConnector(HttpClient.from(tcpClient)))                .build();    }}

Параметры, поддерживаемые WebClient.Builder можно посмотреть здесь.

Подготовка запроса с помощью Spring WebClient

WebClient поддерживает методы: get(), post(), put(), patch(), delete(), options() и head().

Также можно указать следующие параметры:

  • Переменные пути (path variables) и параметры запроса с помощью метода uri().

  • Заголовки запроса с помощью метода headers().

  • Куки с помощью метода cookies().

После указания параметров можно выполнить запрос с помощью retrieve() или exchange(). Далее мы преобразуем результат в Mono с помощью bodyToMono() или во Flux с помощью bodyToFlux().

Асинхронный запрос

Давайте создадим сервис, который использует бин WebClient и создает асинхронный запрос.

@Service@AllArgsConstructorpublic class UserService {    private final WebClient webClient;    public Mono<User> getUserByIdAsync(final String id) {        return webClient                .get()                .uri(String.join("", "/users/", id))                .retrieve()                .bodyToMono(User.class);    }}

Как вы видите, мы не сразу получаем модель User. Вместо User мы получаем Mono-обертку, с которой выполняем различные действия. Давайте подпишемся неблокирующим способом, используя subscribe().

userService  .getUserByIdAsync("1")  .subscribe(user -> log.info("Get user async: {}", user));

Выполнение продолжается сразу без блокировки на методе subscribe(), даже если для получения значения будет требоваться некоторое время.

Синхронный запрос

Если вам нужен старый добрый синхронный вызов, то это легко сделать с помощью метода block().

public User getUserByIdSync(final String id) {    return webClient            .get()            .uri(String.join("", "/users/", id))            .retrieve()            .bodyToMono(User.class)            .block();}

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

Повторные попытки

Мы все знаем, что сетевой вызов не всегда может быть успешным. Но мы можем перестраховаться и в некоторых случаях выполнить его повторно. Для этого используется метод retryWhen(), который принимает в качестве аргумента класс response.util.retry.Retry.

public User getUserWithRetry(final String id) {    return webClient            .get()            .uri(String.join("", "/users/", id))            .retrieve()            .bodyToMono(User.class)            .retryWhen(Retry.fixedDelay(3, Duration.ofMillis(100)))            .block();}

С помощью билдера можно настроить параметры и различные стратегии повтора (например, экспоненциальную). Если вам нужно повторить успешную попытку, то используйте repeatWhen() или repeatWhenEmpty() вместо retryWhen().

Обработка ошибок

В случае ошибки, когда повторная попытка не помогает, мы все еще можем контролировать ситуацию с помощью резервного варианта. Доступны следующие методы:

  • doOnError() срабатывает, когда Mono завершается с ошибкой.

  • onErrorResume() при возникновении ошибки подписывается на резервного издателя, используя функцию для выбора действия в зависимости от ошибки.

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

public User getUserWithFallback(final String id) {    return webClient            .get()            .uri(String.join("", "/broken-url/", id))            .retrieve()            .bodyToMono(User.class)            .doOnError(error -> log.error("An error has occurred {}", error.getMessage()))            .onErrorResume(error -> Mono.just(new User()))            .block();}

В некоторых ситуациях может быть полезно отреагировать на конкретный код ошибки. Для этого можно использовать метод onStatus().

public User getUserWithErrorHandling(final String id) {  return webClient          .get()          .uri(String.join("", "/broken-url/", id))          .retrieve()              .onStatus(HttpStatus::is4xxClientError,                      error -> Mono.error(new RuntimeException("API not found")))              .onStatus(HttpStatus::is5xxServerError,                      error -> Mono.error(new RuntimeException("Server is not responding")))          .bodyToMono(User.class)          .block();}

Клиентские фильтры

Для перехвата и изменения запроса можно настроить фильтры через билдер WebClient .

WebClient.builder()  .baseUrl(BASE_URL)  .filter((request, next) -> next          .exchange(ClientRequest.from(request)                  .header("foo", "bar")                  .build()))  .clientConnector(new ReactorClientHttpConnector(HttpClient.from(tcpClient)))  .build();

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

WebClient.builder()  .baseUrl(BASE_URL)  .filter(basicAuthentication("user", "password")) // org.springframework.web.reactive.function.client.basicAuthentication()  .clientConnector(new ReactorClientHttpConnector(HttpClient.from(tcpClient)))  .build();

Заключение

В этой статье мы узнали, как настроить WebClient и выполнять синхронные и асинхронные HTTP-запросы. Все фрагменты кода, упомянутые в статье, можно найти в GitHub-репозитории. Документацию по Spring WebClient вы можете найти здесь.

Подводя итог, мы видим, что WebClient прост в использовании и включает в себя все необходимые функции, необходимые в современном программировании.

Удачи с новым Spring WebClient!


Узнать подробнее о курсе Разработчик на Spring Framework.

Посмотреть запись демо-урока на тему Введение в облака, создание кластера в Mongo DB Atlas.

Подробнее..

Как подружить RxJava с VIPER в Android, подходы применения и о структуре планировщиков

23.07.2020 18:11:16 | Автор: admin
image

Привет, Хабровчане. Сегодня мы с вами поговорим о RxJava. Я знаю, что о ней написано материала вагон и маленькая тележка, но, как мне кажется, у меня есть пара интересных моментов, которыми стоит поделиться. Сначала расскажу, как мы используем RxJava вместе с архитектурой VIPER для Android приложений, заодно посмотрим на классический способ применения. После этого пробежимся по главным особенностям RxJava и остановимся подробнее на том, как устроены планировщики. Если вы уже запаслись вкусняшками, то добро пожаловать под кат.

Архитектура, которая подойдет всем


RxJava это реализация концепции ReactiveX, а создала эту реализацию компания Netflix. В их блоге есть цикл статей о том, зачем они это сделали и какие проблемы они решили. Ссылки (1, 2) вы найдете в конце статьи. Netflix использовали RxJava на стороне сервера (backend), чтобы распараллелить обработку одного большого запроса. Хотя они предложили способ применения RxJava на backend, такая архитектура подойдет для написания разных типов приложений (мобильные, desktop, backend и многих других). Разработчики Netflix использовали RxJava в сервисном слое таким образом, чтобы каждый метод сервисного слоя возвращал Observable.

Дело в том, что элементы в Observable могу доставляться синхронно и асинхронно. Это дает возможность методу самому решать, возвращать значение сразу синхронно, (например, если оно доступно в кэш) или сначала получить эти значения (например, из базы данных или удаленного сервиса) и вернуть их асинхронно. В любом случае, управление будет возвращаться сразу после вызова метода (либо с данными, либо без).

/** * Метод, который сразу возвращает значение, если оно * доступно, или использует  другой поток исполнения, * чтобы получить значение и передать его через callback `onNext()` */public Observable<T> getProduct(String name) {    if (productInCache(name)) {        // Если данные доступны, возвращаем их сразу        return Observable.create(observer -> {           observer.onNext(getProductFromCache(name));           observer.onComplete();        });    } else {        // Иначе задействуем другой поток исполнения        return Observable.<T>create(observer -> {            try {                // Выполняем работу в отдельном потоке                T product = getProductFromRemoteService(name);                // вовращаем значение                observer.onNext(product);                observer.onComplete();            } catch (Exception e) {                observer.onError(e);            }        })        // Говорим Observable использовать планировщик IO        // для создания/получения данных        .subscribeOn(Schedulers.io());    }}

При таком подходе мы получаем один неизменный API для клиента (в нашем случае контроллер) и разные реализации. Клиент всегда взаимодействует с Observable одинаково. Ему абсолютно не важно, получены ли значения синхронно или нет. При этом реализации API могут меняться от синхронных к асинхронным, никак не затрагивая взаимодействие с клиентом. С помощью такого подхода можно совершенно не задумываться о том, как организовать многопоточность, и сосредоточиться на реализации бизнес задач.

Подход применим не только в сервисном слое на backend, но и в архитектурах MVC, MVP, MVVM и др. Например, для MVP мы можем сделать класс Interactor, который будет ответственным за получение и сохранение данных в различные источники, и сделать так, чтобы все его методы возвращали Observable. Они будут являться контрактом взаимодействия с Model. Это также даст возможность использовать в Presenter всю мощь операторов, имеющихся в RxJava.

image

Мы можем пойти дальше и сделать реактивным API уже у Presenter, но для этого нам понадобится правильно реализовать механизм отписки, позволяющий всем View одновременно отписаться от Presenter.

Дальше посмотрим на пример применения такого подхода для архитектуры VIPER, которая является усовершенствованным MVP. Также стоит помнить, что нельзя делать Observable singleton объектами, потому что подписки к таким Observable будут порождать утечки памяти.

Опыт применения в Android и VIPER


В большинстве текущих и новых Android проектов мы используем архитектуру VIPER. Я познакомился с ней, когда присоединился к одному из проектов, в котором она уже использовалась. Помню, как удивился, когда у меня спросили, не смотрел ли я в сторону iOS. iOS в Android проекте?, подумал я. А между тем, VIPER пришел к нам из мира iOS и по сути является более структурированной и модульной версией MVP. О VIPER очень хорошо написано в этой статье (3).

Поначалу все казалось хорошо: правильно разделенные, не перегруженные слои, у каждого слоя своя область ответственности, понятная логика. Но через какое-то время стал проявляться один недостаток, а по мере роста и изменения проекта он стал очень даже мешать.

Дело в том, что мы использовали Interactor так же, как и коллеги в своей статье. Interactor реализует небольшой use case, например, скачать продукты из сети или взять продукт из БД по id, и выполняет действия в рабочем потоке. Внутри себя Interactor совершает операции, используя Observable. Чтобы запустить Interactor и получить результат, пользователь реализует интерфейс ObserverEntity вместе с его методами onNext, onError и onComplete и передает его вместе с параметрами в метод execute(params, ObserverEntity).

Вы, наверное, уже заметили проблему структура интерфейса. На практике нам редко нужны все три метода, часто используются один или два из них. Из-за этого в коде могут встречаться пустые методы. Конечно, мы можем пометить все методы интерфейса default, но такие методы скорее нужны для добавления новой функциональности в интерфейсы. К тому же, странно иметь интерфейс, все методы которого опциональны. Мы также можем, например, создать абстрактный класс, который наследует интерфейс, и переопределять нужные нам методы. Или, наконец, создать перегруженные версии метода execute(params, ObserverEntity), которые принимают от одного до трех функциональных интерфейсов. Эта проблема плохо сказывается на читаемости кода, но, к счастью, довольно просто решается. Однако, она не единственная.

saveProductInteractor.execute(product, new ObserverEntity<Void>() {    @Override    public void onNext(Void aVoid) {        // Сейчас этот метод нам не нужен,        // но мы обязана его реализовать    }    @Override    public void onError(Throwable throwable) {        // Сейчас этот метод используется        // Какой-то код    }    @Override    public void onComplete() {        // И этот метод тоже используется        // Какой-то код    }});

Кроме пустых методов, есть и более неприятная проблема. Мы используем Interactor, чтобы выполнить какое-то действие, но почти всегда это действие не является единственным. Например, мы можем взять продукт из базы данных, затем получить о нем отзывы и картинку, затем сохранить все это в другое место и в конце перейти на другой экран. Тут у нас каждое действие зависит от предыдущего и при использовании Interactor-ов мы получаем огромную цепочку из колбэков, прослеживать которую бывает очень утомительно.

private void checkProduct(int id, Locale locale) {    getProductByIdInteractor.execute(new TypesUtil.Pair<>(id, locale), new ObserverEntity<Product>() {        @Override        public void onNext(Product product) {            getProductInfo(product);        }        @Override        public void onError(Throwable throwable) {            // Какой-то код        }        @Override        public void onComplete() {        }    });}private void getProductInfo(Product product) {    getReviewsByProductIdInteractor.execute(product.getId(), new ObserverEntity<List<Review>>() {        @Override        public void onNext(List<Review> reviews) {            product.setReviews(reviews);            saveProduct(productInfo);        }        @Override        public void onError(Throwable throwable) {            // Какой-то код        }        @Override        public void onComplete() {            // Какой-то код        }    });    getImageForProductInteractor.execute(product.getId(), new ObserverEntity<Image>() {        @Override        public void onNext(Image image) {            product.setImage(image);            saveProduct(product);        }        @Override        public void onError(Throwable throwable) {            // Какой-то код        }        @Override        public void onComplete() {        }    });}private void saveProduct(Product product) {    saveProductInteractor.execute(product, new ObserverEntity<Void>() {        @Override        public void onNext(Void aVoid) {        }        @Override        public void onError(Throwable throwable) {            // Какой-то код        }        @Override        public void onComplete() {            goToSomeScreen();        }    });}

Ну, как вам такая макаронина? При этом у нас простая бизнес логика и одинарная вложенность, а представьте, что было бы с более сложным кодом. Это также затрудняет повторное использование метода и применение разных планировщиков для Interactor.

Решение на удивление простое. Вы чувствуете, что этот подход пытается повторить поведение Observable, но делает это неправильно и сам создает непонятные ограничения? Как я уже рассказывал раньше, этот код достался нам из уже существующего проекта. При исправлении этого legacy-кода будем использовать подход, который завещали нам ребята из Netflix. Вместо того, чтобы каждый раз реализовывать ObserverEntity, заставим Interactor просто возвращать Observable.

private Observable<Product> getProductById(int id, Locale locale) {    return getProductByIdInteractor.execute(new TypesUtil.Pair<>(id, locale));}private Observable<Product> getProductInfo(Product product) {    return getReviewsByProductIdInteractor.execute(product.getId())    .map(reviews -> {        product.set(reviews);        return product;    })    .flatMap(product -> {        getImageForProductInteractor.execute(product.getId())        .map(image -> {            product.set(image);            return product;        })    });}private Observable<Product> saveProduct(Product product) {    return saveProductInteractor.execute(product);}private doAll(int id, Locale locale) {    // Берем продукт из хранилища    getProductById (id, locale)    // Добавляем информацию    .flatMap(product -> getProductInfo(product))    // Сохраняем все в другое хранилище    .flatMap(product -> saveProduct(product))    // После сохранения продукты в потоке больше не нужны    .ignoreElements()    // Устанавливаем планировщики    .subscribeOn(Schedulers.io())    .observeOn(AndroidSchedulers.mainThread())    // Переходим на другой экран    .subscribe(() -> goToSomeScreen(), throwable -> handleError());}

Вуаля! Так мы не только избавились от того громоздкого и неповоротливого ужаса, но и привнесли мощь RxJava в Presenter.

Концепции в основе


Я довольно часто встречал, как с помощью функционального реактивного программирования (далее ФРП) пытались объяснить концепцию RxJava. На самом деле, оно никак не связано с этой библиотекой. ФРП больше о непрерывных динамически изменяемых значениях (поведениях), непрерывном времени и денотационной семантике. В конце статьи вы сможете найти пару интересных ссылок (4, 5, 6, 7).

В качестве основных концепций RxJava использует реактивное программирование и функциональное программирование. Реактивное программирование можно описать как последовательную передачу информации от наблюдаемого объекта объекту-наблюдателю таким образом, что объект-наблюдатель получает ее автоматически (асинхронно) по мере возникновения этой информации.

Функциональное программирование использует понятие чистых функций, то есть тех, которые не используют и не изменяют внешнее состояние; они полностью зависят от своих входных данных для получения своих выходных данных. Отсутствие у чистых функций побочных эффектов дает возможность использовать результаты одной функции в качестве входных параметров другой. Это дает возможность составлять неограниченную цепочку функций.

Соединение этих двух концепций вместе наряду с использованием GoF шаблонов Observer и Iterator позволяет создавать асинхронные потоки данных и обрабатывать их с помощью огромного арсенала очень удобных функций. Это также дает возможность очень просто, а главное безопасно, использовать многопоточность, не думая о таких ее проблемах как синхронизация, неконсистентность памяти, наложение потоков и т.д.

image

Три кита RxJava


Основные три компонента, на которых строится RxJava Observable, операторы и планировщики.
Observable в RxJava отвечает за реализацию реактивной парадигмы. Observable часто называют потоками, так как они реализуют как концепцию потоков данных, так и распространение изменений. Observable это тип, который достигает реализации реактивной парадигмы за счет объединения в себе двух шаблонов из книги Gang of Four: Observer и Iterator. Observable добавляет в Observer две отсутствующие семантики, которые есть в Iterable:

  • Возможность для производителя сигнализировать потребителю о том, что больше нет доступных данных (цикл foreach в Iterable завершается и просто возвращается; Observable в этом случае вызывает метод onCompleate).
  • Возможность для производителя сообщать потребителю, что произошла ошибка и Observable больше не может испускать элементы (Iterable бросает исключение, если во время итерации возникает ошибка; Observable вызывает метод onError у своего наблюдателя и завершается).

Если Iterable использует pull подход, то есть потребитель запрашивает значение у производителя, и поток исполнения блокируется до тех пор, пока это значение не прибудет, то Observable является его push эквивалентом. Это значит, что производитель отправляет значения потребителю, только когда они становятся доступны.

Observable это только начало RxJava. Он позволяет асинхронно получать значения, но настоящая мощь приходит с реактивными расширениями (отсюда ReactiveX) операторами, которые позволяют преобразовывать, комбинировать и создавать последовательности элементов, испускаемых Observable. Именно тут и выходит на передний план функциональная парадигма со своими чистыми функциями. Операторы используют эту концепцию в полной мере. Они позволяют безопасно работать с последовательностями элементов, которые испускает Observable, не боясь побочных эффектов, если, конечно, не создать их самостоятельно. Операторы позволяют применять многопоточность, не заботясь о таких проблемах как потокобезопасность, низкоуровневое управление потоками, синхронизация, ошибки некосистентности памяти, наложение потоков и т.д. Имея большой арсенал функций, можно легко оперировать различными данными. Это дает нам очень мощный инструмент. Главное помнить, что операторы модифицируют элементы, испускаемые Observable, а не сами Observable. Observable никогда не изменяются с момента их создания. Размышляя о потоках и операторах, лучше всего думать диаграммами. Если вы не знаете, как решить задачу, то подумайте, посмотрите на весь список доступных операторов и подумайте еще.

Хотя сама по себе концепция реактивного программирования является асинхронной (не путайте с многопоточностью), по умолчанию все элементы в Observable доставляются подписчику синхронно, в том же потоке, в котором был вызван метод subscribe(). Чтобы привнести ту самую асинхронность, нужно либо самостоятельно вызывать методы onNext(T), onError(Throwable), onComplete() в другом потоке исполнения, либо использовать планировщики. Обычно все разбирают их поведение, так что давайте посмотрим на их устройство.

Планировщики абстрагируют пользователя от источника параллелизма за собственным API. Они гарантируют, что будут предоставлять определенные свойства, независимо от лежащего в основе механизма параллельности (реализации), например, Threads, event loop или Executor. Планировщики используют daemon потоки. Это означает, что программа завершится вместе с завершением основного потока исполнения, даже если происходят какие-то вычисления внутри оператора Observable.

В RxJava есть несколько стандартных планировщиков, которые подходят для определенных целей. Все они расширяют абстрактный класс Scheduler и реализуют собственную логику управлением workers (рабочими). Например, планировщик ComputationScheduler во время своего создания формирует пул рабочих, количество которых равно количеству процессорных потоков. После этого ComputationScheduler использует рабочих для выполнения Runnable задач. Вы можете передать Runnable планировщику с помощью методов scheduleDirect() и schedulePeriodicallyDirect(). Для обоих методов планировщик берет очередного рабочего из пула и передает ему Runnable.

Рабочий находится внутри планировщика и представляет собой сущность, которая выполняет Runnable объекты (задачи), используя одну из нескольких схем параллельности. Другими словами, планировщик получает Runnable и передает ее рабочему для выполнения. Также можно самостоятельно получить рабочего у планировщика и передать ему один или несколько Runnable, независимо от других рабочих и самого планировщика. Когда рабочий получает задачу, он помещает ее в очередь. Рабочий гарантирует последовательное выполнение задач в том порядке, в котором они были переданы, однако порядок может нарушаться отложенными задачами.

Например, в планировщике ComputationScheduler рабочий реализован с помощью ScheduledExecutorService размером в один поток.

image

Таким образом, мы имеем абстрактных рабочих, которые могут реализовывать любую схему параллельности. Такой подход дает много плюсов: модульность, гибкость, один API, различные реализации. Похожий подход мы видели в ExecutorService. К тому же, мы можем использовать планировщики отдельно от Observable.

Заключение


RxJava очень мощная библиотека, которую можно использовать самыми разными способами во множестве архитектур. Способы ее применения не ограничиваются уже существующими, поэтому всегда пробуйте адаптировать ее под себя. Однако помните о SOLID, DRY и других принципах разработки, а также не забывайте делиться с коллегами своим опытом применения. Надеюсь, что вы смогли почерпнуть из статьи что-то новое и интересное, до встречи!

  1. Причины, почему Netflix начали использовать ReactiveX
  2. Презентация RxJava интернет-сообществу
  3. Объяснение архитектуры VIPER и пример применения
  4. Объяснение ФРП от его создателя
  5. Разница между ФРП и реактивным программированием
  6. Рассуждение о ФРП
  7. Блог Conal Elliot о ФРП
Подробнее..

Перевод Отслеживание состояния компонентов в Angular c помощью ng-set-state

19.04.2021 18:14:05 | Автор: admin

В предыдущей статье (Angular Components with Extracted Immutable State) я показал, почему изменение полей компонентов без каких-либо ограничений - это не всегда хорошо, а также представил библиотеку, которая позволяет упорядочить изменения состояния компонентов.

С тех пор я немного изменил её концепцию и упростил использование. На этот раз я сосредоточусь на простом (на первый взгляд) примере того, как eё можно использовать в сценариях, где обычно потребовался бы rxJS.

Основная Идея

Есть неизменяемый объект, который представляет все значения полей некоторого компонента:

Каждый раз, когда какое-либо поле (или несколько значений полей) компонента изменяется, то создается новый неизменяемый объект, содержащий комбинацию старых и новых значений:

Сравнивая эти два объекта, можно определить какие именно поля изменились, и если есть логические зависимости от этих полей, то тогда выполнится соответствующее вычисление значений этих зависимостей. После того как это вычисление завершено, будет создан 3-й объект, содержащий как исходные значения 2-го объекта, так и вновь вычисленные:

Теперь у нас есть новый объект, который также можно сравнить с предыдущим, и при необходимости можно будет вычислить новые зависимости. Этот сценарий может быть повторен много раз до тех пор, пока мы не получим полностью согласованный объект, после чего Angular отобразит эти данные:

Простая Форма Приветствия

Давайте создадим простую форму приветствия (исходный код на stackblitz):

simple-greeting-form.component.ts

@Component({  selector: 'app-simple-greeting-form',  templateUrl: './simple-greeting-form.component.html'})export class SimpleGreetingFormComponent {  userName: string;  greeting:  string;}

simple-greeting-form.component.html

<div class="form-root">    <h1>Greeting Form</h1>  <label for="ni">Name</label><br />  <input [(ngModel)]="userName" id="ni" />  <h1>{{greeting}}</h1></div>

Очевидно, что поле greeting зависит от поля userName, и существует несколько способов выразить эту зависимость:

  1. Преобразовать greeting в свойство с геттером, но в этом случае его значение будет вычисляться в каждом цикле обнаружения изменений (change detection);

  2. Преобразовать userName в свойство с сеттером, который обновит значение поле greeting;

  3. Создать обработчик событийя ngModelChange, но это избыточно усложнит код;

Эти способы будут работать, но если какое-то другое поле зависит от приветствия (greeting, greeting counter) или greeting зависит от нескольких полей (например, greeting = f (userName, template)), то ни один из этих методов не поможет, поэтому предлагается другой подход:

@Component(...)@StateTracking()export class SimpleGreetingFormComponent {  userName: string;  greeting:  string;  @With("userName")  public static greet(state: ComponentState<SimpleGreetingFormComponent>)    : ComponentStateDiff<SimpleGreetingFormComponent>  {    const userName = state.userName === ""       ? "'Anonymous'"       : state.userName;    return {      greeting: `Hello, ${userName}!`    }  }}

Для начала компонент должен быть отмечен декоратором @StateTracking или же в конструкторе должна быть вызвана функция initializeStateTracking (декораторы компонентов иногда работают некорректно в некоторых старых версиях Angular):

@Component(...)export class SimpleGreetingFormComponent {  userName: string;  greeting:  string;    constructor(){    initializeStateTracking(this);  }}

Декоратор @StateTracking (или функция initializeStateTracking) находит все те поля компонента, от которых могут зависеть другие поля, и заменяет их свойствами с геттерами и сеттерами, так чтобы библиотека могла отслеживать изменения.

Далее определяем функцию перехода в новое состояние:

  ...  @With("userName")  public static greet(state: ComponentState<SimpleGreetingFormComponent>)    : ComponentStateDiff<SimpleGreetingFormComponent>  {      ...  }  ...

Каждая функция перехода получает в виде аргумента объект, который представляет текущее состояние компонента, и эта функция должна вернуть объект, который будет содержать только обновленные поля. Это объект будет объединен с копией объекта текущего состояния, и эта новая модифицированная копия станет новым состоянием компонента.

При желании вы можете добавить второй аргумент, который получит объект предыдущего состояния.

Если же вы определите третий параметр, то он получит объект разницы между текущим и предыдущим состояниями:

@With("userName")public static greet(  state: ComponentState<SimpleGreetingFormComponent>,  previous: ComponentState<SimpleGreetingFormComponent>,  diff: ComponentStateDiff<SimpleGreetingFormComponent>): ComponentStateDiff<SimpleGreetingFormComponent>{  ...}

ComponentState и ComponentStateDiff это прокси типы (Typescript mapped types), которые отфильтровывают методы и источники событий (event emitters). Также ComponentState отмечает все поля как только для чтения (ведь состояние неизменяемо (immutable)), а ComponentStateDiff отмечает все поля как необязательные, поскольку функция перехода может возвращать любое подмножество исходного состояния.

Для простоты определим алиасы этих типов:

type State = ComponentState<SimpleGreetingFormComponent>;type NewState = ComponentStateDiff<SimpleGreetingFormComponent>;...  @With("userName")  public static greet(state: State): NewState  {    ...  }

Декоратор @With получает список имен полей, изменение значений которых вызовет соответствующий декорированный статический (!) метод класса компонента. Typescript проверит, что класс на самом деле содержит объявленные поля и что метод является статическим (функции переходов должны быть чистыми (pure)).

Логирование изменений

Теперь форма отображает соответствующее приветствие при любом изменении имени. Посмотрим, как меняется состояние компонента:

@Component(...)@StateTracking<SimpleGreetingFormComponent>({  onStateApplied: (c,s,p)=> c.onStateApplied(s,p)})export class SimpleGreetingFormComponent {  userName: string;  greeting:  string;  private onStateApplied(current: State, previous: State){    console.log("Transition:")    console.log(`${JSON.stringify(previous)} =>`)    console.log(`${JSON.stringify(current)}`)  }  @With("userName")  public static greet(state: State): NewState  {      ...  }  }

onStateApplied это функция-перехватчик (hook), которая вызывается каждый раз, когда состояние компонента становится согласованным - это означает, что все функции перехода были вызваны и больше никаких изменений не обнаружено:

Transition:{} =>{"userName":"B","greeting":"Hello, B!"}Transition:{"userName":"B","greeting":"Hello, B!"} =>{"userName":"Bo","greeting":"Hello, Bo!"}Transition:{"userName":"Bo","greeting":"Hello, Bo!"} =>{"userName":"Bob","greeting":"Hello, Bob!"}

Как мы видим, компонент переходит в новое состояние каждый раз, когда пользователь вводит следующий символ имени, и поле приветствия при этом немедленно обновляется. Если необходимо предотвратить обновление приветствия при каждом изменении имени, то это можно легко сделать, добавив расширение Debounce к декоратору @With:

@With("userName").Debounce(3000/*ms*/)public static greet(state: State): NewState{    ...}...

Теперь библиотека ждет 3 секунды после последнего изменения в имени и только затем выполняет переход:

Transition:{} =>{"userName":"B"}Transition:{"userName":"B"} =>{"userName":"Bo"}Transition:{"userName":"Bo"} =>{"userName":"Bob"}Transition:{"userName":"Bob"} =>{"userName":"Bob","greeting":"Hello, Bob!"}

Добавим индикацию того, что форма находится в режиме ожидания:

...export class SimpleGreetingFormComponent {  userName: string;  greeting:  string;  isThinking:  boolean = false;  ...  @With("userName")  public static onNameChanged(state: State): NewState{    return{      isThinking: true    }  }  @With("userName").Debounce(3000/*ms*/)  public static greet(state: State): NewState  {    const userName = state.userName === ""       ? "'Anonymous'"       : state.userName;    return {      greeting: `Hello, ${userName}!`,      isThinking: false    }  }}
...<h1 *ngIf="!isThinking">{{greeting}}</h1><h1 *ngIf="isThinking">Thinking...</h1>...

Кажется, что это работает, но есть проблема - если пользователь начал печатать, а затем решил вернуть исходное имя в течение заданных 3 секунд, то с точки зрения библиотеки поле greeting не изменилось, и функция перехода не будет вызвана, а форма будет показывать Thinking до тех пор, пока вы не набираете другое имя. Это можно решить, добавив декоратор @Emitter() для поля userName:

@Emitter()userName: string;

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

Однако есть и другое решение - когда форма перестает "думать", она может установить для userName значение null, и тогда пользователю придется начать вводить новое имя:

...@With("userName")public static onNameChanged(state: State): NewState{  if(state.userName == null){    return null;  }  return{    isThinking: true  }}@With("userName").Debounce(3000/*ms*/)public static greet(state: State): NewState{  if(state.userName == null){    return null;  }    const userName = state.userName === ""     ? "'Anonymous'"     : state.userName;  return {    greeting: `Hello, ${userName}!`,    isThinking: false,    userName: null  }}...

А теперь давайте подумаем о ситуации, когда пользователь нетерпелив и хочет сразу получить результат. Что ж, позволим ему нажать [Enter] ((keydown.enter) = "onEnter ()"), чтобы немедленно получить приветствие:

...userName: string | null;immediateUserName: string | null;onEnter(){  this.immediateUserName = this.userName;}...@With("userName")public static onNameChanged(state: State): NewState{  ...}@With("userName").Debounce(3000/*ms*/)public static greet(state: State): NewState {  ...}@With("immediateUserName")public static onImmediateUserName(state: State): NewState{  if(state.immediateUserName == null){    return null;  }  const userName = state.immediateUserName === ""     ? "'Anonymous'"     : state.immediateUserName;  return {    greeting: `Hello, ${userName}!!!`,    isThinking: false,    userName: null,    immediateUserName: null  }}...

Ещё было бы неплохо узнать, сколько времени ждать, если пользователь не нажимает [Enter] - какой-нибудь счетчик обратного отсчёта был бы очень полезен:

<h1 *ngIf="isThinking">Thinking ({{countdown}} sec)...</h1>
...countdown: number = 0;...@With("userName")public static onNameChanged(state: State): NewState{  if(state.userName == null){    return null;  }  return{    isThinking: true,    countdown: 3  }}...@With("countdown").Debounce(1000/*ms*/)public static countdownTick(state: State): NewState{  if(state.countdown <= 0) {    return null  }  return {countdown: state.countdown-1};}

и вот как это выглядит:

Обратный отсчет также следует сбрасывать каждый раз, когда готово новое приветствие. Это предотвращает ситуацию, когда пользователь сразу нажимает [Enter], а обратный отсчет в этот момент остается 3 - после этого он перестает работать, так как его значение больше никогда не изменится. Для простоты сбросим все поля, зависящие от флага isThinking:

...@With("isThinking")static reset(state: State): NewState{  if(!state.isThinking){    return{      userName: null,      immediateUserName: null,      countdown: 0    };  }  return null;}...

Обнаружение Изменений (Change Detection)

Очевидно, что обратный отсчет работает асинхронно, и этот факт не вызывает никаких проблем с обнаружением изменений в Angular, пока стратегия обнаружения изменений - Default. Однако, если стратегия - OnPush, то ничто не может сообщить компоненту, что его состояние меняется во время обратного отсчета.

К счастью, мы уже определили функцию обратного вызова, которая вызывается каждый раз, когда состояние компонента только что изменилось, поэтому единственное, что требуется, - это добавить туда явное обнаружение изменений:

...constructor(readonly changeDetector: ChangeDetectorRef){}...private onStateApplied(current: State, previous: State){  this.changeDetector.detectChanges();  ...

Теперь он работает должным образом даже с OnPush стратегией обнаружения (Change Detection Strategy).

Исходящие Параметры (Output Properties)

Библиотека обнаруживает все источники событий (Event emitters) компонентов и вызывает их, когда поменялись значения привязанных к этим источникам полей. По умолчанию привязка выполняется с использованием суффикса Change в названиях источников событий:

greeting:  string;@Output()greetingChange = new EventEmitter<string>();

Распределенное Состояние

Обычно, когда компонент уничтожается (например, скрывается с помощью *ngIf), все ещё не завершенные асинхронные операции, инициированные этим компонентом, заканчиваются без каких либо значимых изменений. Однако библиотека позволяет выделить состояние компонента со всеми его переходами в отдельный объект, который может существовать независимо от компонента. Более того, такой объект может использоваться несколькими компонентами одновременно!

Давайте превратим компонент формы приветствия в сервис:

greeting-service.ts

@StateTracking({includeAllPredefinedFields:true})export class GreetingService implements IGreetingServiceForm {  userName: string | null = null;  immediateUserName: string | null = null;  greeting:  string = null;  isThinking:  boolean = false;  countdown: number = 0;  @With("userName")  static onNameChanged(state: State): NewState{    ...  }  @With("userName").Debounce(3000/*ms*/)  static greet(state: State): NewState  {    ...  }  @With("immediateUserName")  static onImmediateUserName(state: State): NewState{    ...  }  @With("countdown").Debounce(1000/*ms*/)  static countdownTick(state: State): NewState{    ...  }  @With("isThinking")  static reset(state: State): NewState{    ...  }}

и добавим его в провайдеры главного модуля.

includeAllPredefinedFields означает, что все поля с некоторым начальным значением (даже если оно null) будут автоматически включены в объекты состояния.

Чтобы использовать подобный сервис внутри компонента, нужно выполнить следующие действия:

  1. Внедрить экземпляр службы в компонент через dependency injection;

  2. Передать экземпляр сервиса в инициализатор трекера состояния;

  3. Отметить поля компонента, которые будут привязаны к соответствующим полям сервиса;

  4. Подписаться на изменения состояния сервиса - это необходимо, когда требуется явное обнаружение изменений или стратегия обнаружения изменений - OnPush.

После выполнения этих шагов компонент будет выглядеть так:

@Component({...  changeDetection: ChangeDetectionStrategy.OnPush})export class ComplexGreetingFormComponent   implements OnDestroy, IGreetingServiceForm {  private _subscription: ISharedStateChangeSubscription;  @BindToShared()  userName: string | null;  @BindToShared()  immediateUserName: string | null;  @BindToShared()  greeting:  string;  @BindToShared()  isThinking:  boolean = false;  @BindToShared()  countdown: number = 0;  constructor(greetingService: GreetingService, cd: ChangeDetectorRef) {    const handler = initializeStateTracking<ComplexGreetingFormComponent>(this,{      sharedStateTracker: greetingService,      onStateApplied: ()=>cd.detectChanges()    });    this._subscription = handler.subscribeSharedStateChange();  }  ngOnDestroy(){    this._subscription.unsubscribe();  }  public onEnter(){    this.immediateUserName = this.userName;  }}

Экземпляр службы передается в функцию initializeStateTracking (также возможно использование декоратора @StateTracking(), но это потребует больше усилий), которая возвращает объект с помощью методов которого можно управлять работой библиотеки в указанном компоненте.

Подписка (_subscription: ISharedStateChangeSubscription) требуется для вызова функции обратного вызова onStateApplied при изменении состояния сервиса или для вызова локальной функции перехода, которая зависит от поля (полей) сервиса. Если компонент использует стратегию обнаружения изменений Default и отсутствуют локальные функции перехода, то подписка не требуется.

Не забудьте отказаться от подписки при уничтожении компонента, дабы избежать утечек памяти. В качестве альтернативы вы можете вызвать функции handler.release() или releaseStateTracking(this), чтобы отказаться от подписок на компоненты, но эти методы также отменяют все незавершенные асинхронные операции, что не всегда желательно.

Составное Распределенное Состояние

Библиотека позволяет использовать общие состояния не только в компонентах, но и в других сервисах.

Давайте создадим сервис, который будет регистрировать все приветствия и имитировать их отправку на сервер:

export type LogItem = {  id: number | null  greeting: string,  status: LogItemState,}@Injectable()export class GreetingLogService implements IGreetingServiceLog, IGreetingServiceOutput {  @BindToShared()  greeting:  string;  log: LogItem[] = [];  logVersion: number = 0;  identity: number = 0;  pendingCount: number = 0;  savingCount: number = 0;  ...  constructor(greetingService: GreetingService){    const handler = initializeStateTracking(this,{      sharedStateTracker: greetingService,       includeAllPredefinedFields: true});          handler.subscribeSharedStateChange();      }  ...}

При каждом изменении значения поля greeting, это значение будет добавлено в массив журнала log. logVersion будет увеличиваться каждый раз при изменении массива, чтобы обеспечить обнаружение изменений, не делая при этом массив журнала неизменяемым:

...@With("greeting")static onNewGreeting(state: State): NewState{    state.log.push({id: null, greeting: state.greeting, status: "pending"});    return {logVersion: state.logVersion+1};}...

Сервис не сразу отправляет новые приветствия "на сервер", он будет ждать некоторое время, чтобы накопить сразу несколько изменений:

@With("logVersion")static checkStatus(state: State): NewState{  let pendingCount = state.pendingCount;  for(const item of state.log){    if(item.status === "pending"){      pendingCount++;    }    else if(item.status === "saving"){      savingCount++;    }  }  return {pendingCount, savingCount};}@With("pendingCount").Debounce(2000/*ms*/)static initSave(state: State): NewState{  if(state.pendingCount< 1){    return null;  }  for(const item of state.log){    if(item.status === "pending"){      item.status = "saving";    }  }  return {logVersion: state.logVersion+1};}

И наконец, собственно, сама отправка на сервер:

...  @WithAsync("savingCount").OnConcurrentLaunchPutAfter()  static async save(getState: ()=>State): Promise<NewState>{      const initialState = getState();      if(initialState.savingCount < 1){        return null;      }      const savingBatch = initialState.log.filter(i=>i.status === "saving");      await delayMs(2000);//Simulates sending data to server       const stateAfterSave = getState();      let identity = stateAfterSave.identity;      savingBatch.forEach(l=>{        l.status = "saved",        l.id = ++identity      });      return {        logVersion: stateAfterSave.logVersion+1,        identity: identity      };        }...

Эта функция перехода отличается от предыдущих тем, что она асинхронная:

  1. Она отмечена декоратором WithAsync вместо With;

  2. Декоратор имеет спецификацию поведения параллельного запуска (в данном случае OnConcurrentLaunchPutAfter);

  3. Вместо объекта текущего состояния он получает функцию, которая возвращает текущее состояние в момент вызова.

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


Мы только что рассмотрели пример пользовательского интерфейса с относительно сложным асинхронным поведением. Однако оказывается, что реализовать такое поведение не так уж и сложно, используя концепцию серии неизменяемых состояний. По крайней мере, это можно рассмотреть как альтернативу RxJs.


  1. Код статьи на stackblitz: https://stackblitz.com/edit/set-state-greet;

  2. Ссылка на пердыдущую статью: Angular Components with Extracted Immutable State;

  3. Ссылка не исходный код ng-set-state: https://github.com/0x1000000/ngSetState

Подробнее..

Категории

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

  • Имя: Макс
    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