Action and BindingTarget in ReactiveSwift

2518
#Разработка 19 октября 2020

Меня зовут Игорь, я руковожу отделом мобайла в AGIMA. Еще не все перешли с ReactiveSwift/Rxswift на Combine? Тогда сегодня я расскажу про опыт использования таких концептов из ReactiveSwift как Action и BindingTarget и какие задачи можно решить с их помощью. Сразу отмечу, что для RxSwift эти же концепции существует в виде RxAction и Binder. В статье рассмотрим, примеры на ReactiveSwift и в конце я покажу, как все то же самое выглядит на RxSwift.

Рассчитываю на то, что вы уже представляете, что такое реактивное программирование и имели опыт с ReactiveSwift или RxSwift.

Представим, что у нас есть страница продукта и кнопка добавления в избранное. Когда мы нажимаем ее, вместо нее начинает крутиться лоадер, и по результатам кнопка становится либо залитой, либо нет. Скорее всего, у нас будет что-то подобное во ViewController (используем MVVM архитектуру).

let favoriteButton = UIButton()

let favoriteLoader = UIActivityIndicatorView()

let viewModel: ProductViewModel

func viewDidLoad() {
  ...
favoriteButton.reactive.image <~ viewModel.isFavorite.map

(mapToImage)

favoriteLoader.reactive.isAnimating <~ viewModel.isLoading

// Hide button while request is being processed

favoriteButton.reactive.isHidden <~ viewModel.isLoading

favoriteButton.reactive.controlEvents(.touchUpInside)

.take(duringLifetimeOf: self)

.observeValues { [viewModel] _ in

 viewModel.toggleFavorite()
     }
}

И во viewModel:

lazy var isFavorite = Property(_isFavorite)

private let _isFavorite: MutableProperty


lazy var isLoading = Property(_isLoading)

private let _isLoading: MutableProperty


func toggleFavorite() {

  _isLoading.value = true

	service.toggleFavorite(product).startWithResult { [weak self] result in

    self._isLoading.value = false

    switch result {

      case .success(let isFav):

         self?.isFavorite.value = isFav

      case .failure(let error):

         // do somtething with error

    }
  }
}

Все бы ничего, но немного смущает количество MutableProperty и количество «ручного» управления состоянием, что создает дополнительное пространство для ошибок. Вот тут нам и поможет Action . Благодаря ему мы можем сделать наш код более реактивным и избавиться от «лишнего» кода. Запустить Action можно 2-мя способами: запустить SignalProducer из метода apply напрямую и с помощью BindingTarget(об этом чуть позже). Рассмотрим первый вариант, теперь код по viewModel будет выглядеть так:

let isFavorite: Property

let isLoading: Property

private let toggleAction: Action



init(product: Product, service: FavoritesService = FavoriteServiceImpl()) {

    toggleAction = Action {

        service.toggleFavorite(productId: product.id)

            .map { $0.isFavorite }


     }


     isFavorite = Property(initial: product.isFavorite, then: toggleAction.values)

     isLoading = toggleAction.isExecuting

}

func toggleFavorite() {

  favoriteAction.apply().start()

}

Лучше? На мой взгляд, да. Теперь давайте разбираться, что такое Action

Action представляет собой фабрику для SignalProducerс возможностью наблюдать за всеми его событиями (для адептов RxSwift: SignalProducer — это холодный сигнал, Signal — горячий). Action принимает на вход значение, передает его в в execute блок, который возвращает SignalProducer.

экшн.png

Основной (но не весь!) функционал представлен на листинге ниже.

final class Action {

	let values: Signal

	let errors: Signal

	let isExecuting: Property

  let isEnabled: Property

  
	var bindingTarget: BindingTarget

 
	func apply(_ input: Input) -> SignalProducer {...}


  init(execute: @escaping (T, Input) -> SignalProducer)

}

Зачем все это может понадобиться? values представляет собой поток всех значений из Action errors— все ошибки. isExecuting показывает нам, выполняется ли сейчас действие (идеально подходит для лоадеров). Самое ценное тут то, что values и errors имеют тип ошибки Never то есть они никогда не завершатся «аварийно», что позволяет нам безопасно использовать их в реактивных цепочках. isEnabled- Action имеет включенные/выключенные состояния, что дает нам защиту от одновременного выполнения. Может быть полезно, когда нам надо защититься от 10 нажатий кнопки подряд. Вообще, управлять «включенностью» Action довольно гибко, но, сказать по правде, так и не пришлось этим пользоваться, поэтому этого в статье не будет :)

Важный момент 1: метод apply возвращает каждый раз новый SignalProducer однако values , errors, isExecuting от этого не зависят и получают события от всех продюсеров, созданных внутри своего Action

Важный момент 2: Action выполняется последовательно. Мы не можем запустить Action несколько раз подряд, не дождавшись выполнения предыдущего действия. В этом случае мы получим ошибку, говорящую о том, что Action недоступен (справедливо и для RxSwift).

Теперь не обязательно обрабатывать результаты SignalProducer, поскольку их мы получаем в сигнале favoriteAction.values Если нужно обрабатывать ошибки, для этого можно использовать сигнал favoriteAction.errors

Теперь рассмотрим 2-й способ запуска Action с помощью BindingTarget Во viewModel нам теперь не нужен метод toggleFavorite он трансформируется таким образом в такое:

let toggleFavorite: BindingTarget = favoriteAction.bindingTarget

Код во вьюконтроллере станет таким

viewModel.toggleFavorite <~ button.reactive.controlEvents(.touchUpInside)

Выглядит до боли знакомо. Это наш любимый оператор биндинга. Левая его часть и есть BindingTarget.

Eсть, правда, один нюанс: иногда нам бы хотелось отменить выполнение SignalProducer, например, мы скачиваем какой-то файл и нажали на кнопку отмены. Обычно, запустив SignalProducer либо подписавшись на Signal мы бы сохранили Disposable и вызвали у него метод dispose(). Если мы поставляем input значения через оператор биндинга, то SignalProducer запускается внутри Action и доступа к disposable у нас нет.

Что же такое BindingTarget? BindingTarget представляет собой структуру, содержащую блок, который будет вызываться при получении нового значения и так называемый Lifetime(объект, отражающий время жизни объекта). Кстати, Observerи MutablePropertyтоже можно использовать как BindingTarget.

Получатся довольно элегантно. Вообще, BindingTarget— это очень полезная штука для того, чтобы «учить» объекты обрабатывать потоки данных внутри себя и не писать в очередной раз:

isLoadingSignal

    .take(duringLifetimeOf: self)

    .observe { [weak self] isLoading in 

        isLoading ? self?.showLoadingView() : self?.hideLoadingView()
    }

а вместо этого писать:

self.reactive.isLoading <~ isLoadingSignal

Хорошая новость — завершение подписки берет на себя фреймворк, и нам можно об этом не беспокоиться.

Объявление isLoading будет выглядеть следующим образом (все существующие биндинги выглядят точно также):

extension Reactive where Base: ViewController {

    var isLoading: BindingTarget {

        makeBindingTarget { (vc, isLoading) in

            isLoading ? vc.showLoadingView() : vc.hideLoadingView()
        }
    }
}

Отмечу, что в методе makeBindingTarget можно указывать, на каком потоке будет вызываться биндинг. Есть еще вариант с использованиями KeyPath (только на главном потоке):

var isLoading = false


...


reactive[\.isLoading] <~ isLoadingSignal

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

Action выступает отличным помощником для выстраивания «вечных» реактивных цепочек и отлично себя чувствует на ViewModel слое. BindingTarget в свою очередь, позволяет инкапсулировать код, отвечающий за биндинг и вместе эти концепции делают код более элегантным, читаемым и надежным, чего все мы пытаемся достичь :)

И обещанный перевод на RxSwift

ViewController:

viewModel.isFavorite

    .map(mapToImage)

    .drive(favoriteButton.rx.image())

    .disposed(by: disposeBag)


viewModel.isLoading

    .drive(favoriteLoader.rx.isAnimating)

    .disposed(by: disposeBag)


viewModel.isLoading

   .drive(favoriteButton.rx.isHidden)

   .disposed(by: disposeBag)



favoriteButton.rx.tap

   .bind(to: viewModel.toggleFavorite)

   .disposed(by: disposeBag)

ViewModel

let isFavorite: Driver

let isLoading: Driver

let toggleFavorite: AnyObserver

private let toggleAction = Action

    
init(product: Product, service: FavoritesService = FavoriteServiceImpl()) {

    toggleAction = Action {

         service.toggleFavorite(productId: product.id)

            .map { $0.isFavorite }
    }
        
    isFavorite = toggleAction.elements.asDriver(onErrorJustReturn: false)

    isLoading = toggleAction.executing.asDriver(onErrorJustReturn: false)

    toggleFavorite = toggleAction.inputs
}

Binder

extension Reactive where Base: UIViewController {

    var isLoading: Binder {

        Binder(self.base) { vc, value in

            value ? vc.showLoadingView() : vc.hideLoadingView()
        }
    }
}

Контент-хаб

0 / 0
+7 495 981-01-85 + Стать клиентом
Услуги Кейсы Контент-хаб