Action and BindingTarget in ReactiveSwift
Action и BindingTarget помогают описать пользовательское действие как управляемую операцию: принять входные данные, выполнить запрос, показать загрузку и передать результат интерфейсу. Подход уменьшает ручное управление состояниями во ViewController и подходит для MVVM.
Коротко
| Инструмент | Ответственность |
|---|---|
| Action | Принимает вход, выполняет операцию и публикует результат, ошибку и состояние выполнения |
| BindingTarget | Безопасно передаёт значения в конкретное свойство интерфейса |
| RxAction и Binder | Реализуют тот же подход в 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 Action представляет собой фабрику для SignalProducerс возможностью наблюдать за всеми его событиями (для адептов RxSwift: SignalProducer — это холодный сигнал, Signal — горячий). Action принимает на вход значение, передает его в в execute блок, который возвращает SignalProducer.
Основной (но не весь!) функционал представлен на листинге ниже.
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).
BindingTarget: передача событий интерфейсу
Теперь не обязательно обрабатывать результаты 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
И обещанный перевод на 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()
}
}
}

