Привет, Хабр!

Меня зовут Игорь, я руковожу отделом мобайла в 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
  // Скрыть кнопку во время выполнения запрос
  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<Bool>

lazy var isLoading = Property(_isLoading)
private let _isLoading: MutableProperty<Bool>

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<Bool>
let isLoading: Property<Bool>
private let toggleAction: Action<Void, Bool, Error>

init(product: Product, service: FavoritesService = FavoriteServiceImpl()) {
    toggleAction = Action<Void, Bool, Error> {
        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.

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

final class Action<Input, Output, Error> {
	let values: Signal<Output, Never>
	let errors: Signal<Error, Never>
	let isExecuting: Property<Bool>
  let isEnabled: Property<Bool>
  
	var bindingTarget: BindingTarget<Input>
 
	func apply(_ input: Input) -> SignalProducer<Output, Error> {...}

  init(execute: @escaping (T, Input) -> SignalProducer<Output, Error>)
}

Зачем все это может понадобиться? 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<Void> = 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<Bool> {
        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<Bool>
let isLoading: Driver<Bool>
let toggleFavorite: AnyObserver<Void>
private let toggleAction = Action<Void, Bool>
    
init(product: Product, service: FavoritesService = FavoriteServiceImpl()) {
    toggleAction = Action<Void, Bool> {
         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<Bool> {
        Binder(self.base) { vc, value in
            value ? vc.showLoadingView() : vc.hideLoadingView()
        }
    }
}

Ссылочки:

Action

RxSwiftCommunity/Action