Action и BindingTarget в ReactiveSwift
Привет, Хабр!
Меня зовут Игорь, я руковожу отделом мобайла в 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()
}
}
}
Ссылочки: