RxSwift: работа с GUI

49aa66dfc7e34d5ca6292d7dc9c5e387.png

Моя первая статья по RxSwift покрывала практически все базовые операторы, без знаний которых соваться в разработку не имело особого смысла. Но это всего лишь алфавит функционального программирования. Для того чтобы писать полноценные программы необходимо понять основные принципы при работе с GUI.

В основном для проработки материала используются стандартные примеры из RxExample, но для прояснения отдельных моментов была создана песочница UIExplanation и дополнительный пример в RxExample
Весь код можно найти здесь github.com/sparklone/RxSwift

При работе с UI элементами в Rx есть основные потребности:
1) понять какие подводные камни нас ожидают в принципе и зачем нужен Driver
2) научиться делать привязку UI к Observable, чтобы элементы Observable меняли состояние свойства/свойств UI элемента. Это решается с помощью UIBindingObserver
3) научиться переводить паттерн target-action на рельсы Rx. Это делается с помощью ControlEvent
4) сделать двустороннюю привязку к свойствам UI элемента. Это делается с помощью ControlProperty
5) т.к. зачастую у UI элементов delegate/dataSource предполагаются в единственном числе, — ввели класс DelegateProxy, который позволяет одновременно использовать как обычный делегат, так и Rx последовательности.

Рассмотрим каждую потребность отдельно

Driver

1) Существует несколько проблем при работе с Observable. Чтобы их понять, рассмотрим небольшой пример в песочнице

import Cocoa
import RxSwift
import RxCocoa
import XCPlayground

XCPlaygroundPage.currentPage.needsIndefiniteExecution = true

example("without shareReplay duplicate call problem") {
    let source = NSTextField()
    let status = NSTextField()
    
    let URL = NSURL(string: "https://github.com/")!
    let request = NSURLRequest(URL: URL)
    let observable =  NSURLSession.sharedSession().rx_response(request).debug("http")
    
    let sourceObservable = observable.map { (maybeData, response) in
        return String(data: maybeData, encoding: NSUTF8StringEncoding)!
        }.observeOn(MainScheduler.instance)
    let statusObservable = observable.map { (maybeData, response) in
        return response.statusCode.description
        }.observeOn(MainScheduler.instance)
    
    sourceObservable.subscribe(source.rx_text)
    statusObservable.subscribe(status.rx_text)
}

а) Если есть Observable и к нему подписать несколько Observer’ов — для каждого Observer’а будет создан отдельный Observable. В нашем случае Observable обращается в сеть и скачивает страницу, код страницы и статус ответа сервера помещаем в разные textView.

Если посмотрим в консоль то увидим 2 subscribed, 2 disposed:

--- without shareReplay duplicate call problem example ---
2016-05-01 04:17:17.225: http -> subscribed
2016-05-01 04:17:17.229: http -> subscribed
curl -X GET 
"https://github.com/" -i -v
Success (1098ms): Status 200
2016-05-01 04:17:18.326: http -> Event Next(( Event Completed
2016-05-01 04:17:18.339: http -> disposed
curl -X GET 
"https://github.com/" -i -v
Success (1326ms): Status 200
2016-05-01 04:17:18.556: http -> Event Next(( Event Completed
2016-05-01 04:17:18.557: http -> disposed

Чтобы этого избежать, нужно добавить shareReplayLatestWhileConnected для изначального observable

let observable =  NSURLSession.sharedSession().rx_response(request).debug("http").shareReplayLatestWhileConnected()

В итоге в консоли видно, что теперь к серверу идет лишь одно обращение

--- with shareReplay no duplicate call problem example ---
2016-05-01 04:18:27.845: http -> subscribed
curl -X GET 
"https://github.com/" -i -v
Success (960ms): Status 200
2016-05-01 04:18:28.807: http -> Event Next(( Event Completed
2016-05-01 04:18:28.821: http -> disposed

Замечу еще, что используется shareReplayLatestWhileConnected, а не shareReplay (1), т.к. он очищает буфер при отписке всех Observer’ов и при завершении последовательности корректно или с ошибкой. Когда я писал первую статью по операторам RxSwift, то обнаружил это странное поведение shareReplay (отсутствие очистки даже после того как последовательность была завершена) самостоятельно в песочнице и сначала решил, что я что то делаю не так, оказалось — by design.

б) все что связано с GUI мы обязаны обрабатывать на MainScheduler. Если нужно освежить память по поводу разных Scheduler’ов. Можно обратиться к официальной документации и пройти по ссылкам, где я описывал subscribeOn и observeOn в предыдущей статье.
Если убрать .observeOn (MainScheduler.instance) из кода, — мы получим

fatalError "fatal error: Executing on backgound thread. Please use `MainScheduler.instance.schedule` to schedule work on main thread."


Меня кстати несколько озадачила эта ошибка, я ведь знал, что в каком потоке ты создаешь Observable, в таком и будет выполняться код внутри него. Но я ошибочно полагал, что в каком потоке идет вызов subscribe, в таком и будет происходить выполнение кода observer’а.

По первому моменту, код Observable действительно выполняется в том же потоке, где он и был создан. Но, в случае rx_response внутри создается Observable, внутри которого идет обращение к методу dataTaskWithRequest от NSURLSession, и возврат значений идет из замыкания этого метода, а это замыкание уже выполняется в совершенно другом потоке. Поэтому то на выходе из NSURLSession.sharedSession ().rx_response (request) нас ждет другой поток.

А по второму моменту — прочитав официальную документация я ошибочно посчитал, что из какого потока вызываешь subscribe — в этом потоке и будет выполняться тело Observer’а», оказалось что это не так. Поток сохраняется тот — в котором находится исполняемый код Observable.
Чтобы это проверить я написал еще два примера

example("from main thread") {
    print("init thread: \(NSThread.currentThread())")
    let source = NSTextField()
    let status = NSTextField()
    
    let URL = NSURL(string: "https://github.com/")!
    let request = NSURLRequest(URL: URL)
    let observable =  NSURLSession.sharedSession().rx_response(request).shareReplayLatestWhileConnected()
    
    let sourceObservable = observable.map { (maybeData, response) in
        return String(data: maybeData, encoding: NSUTF8StringEncoding)!
    }
    
    sourceObservable.subscribe() { e in
        print("observer thread: \(NSThread.currentThread())")
    }
}

example("from another queue") {
    print("init thread: \(NSThread.currentThread())")
    let source = NSTextField()
    let status = NSTextField()
    
    let URL = NSURL(string: "https://github.com/")!
    let request = NSURLRequest(URL: URL)
    let observable =  NSURLSession.sharedSession().rx_response(request).shareReplayLatestWhileConnected()
    
    let sourceObservable = observable.map { (maybeData, response) in
        return String(data: maybeData, encoding: NSUTF8StringEncoding)!
    }
    
    let queue1 = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)
    dispatch_async(queue1,{
        print("queue1 thread: \(NSThread.currentThread())")
        sourceObservable.subscribe() { e in
            print("observer thread: \(NSThread.currentThread())")
        }
    })
}

Вывод в консоли:

--- from main thread example ---
init thread: {number = 1, name = main}
curl -X GET 
"https://github.com/" -i -v
Success (944ms): Status 200
observer thread: {number = 3, name = (null)}
observer thread: {number = 3, name = (null)}

--- from another queue example ---
init thread: {number = 1, name = main}
queue1 thread: {number = 3, name = (null)}
curl -X GET 
"https://github.com/" -i -v
Success (956ms): Status 200
observer thread: {number = 4, name = (null)}
observer thread: {number = 4, name = (null)}

В обоих примерах я не использую observeOn. Как видно в обоих случаях код внутри observer’а выполняется не в потоке того кода который сделал subscribe, а в том, что вернулся из rx_response (в этом можно убедиться, проставив логирование потоков внутри файла NSURLSession+Rx из проекта Rx)

public func rx_response(request: NSURLRequest) -> Observable<(NSData, NSHTTPURLResponse)> {
        return Observable.create { observer in
                print("RXRESPONSE thread: \(NSThread.currentThread())")

......                  

            let task = self.dataTaskWithRequest(request) { (data, response, error) in
print("TASK thread: \(NSThread.currentThread())")

в) если возникнет ошибка при обработке кода Observable, то в режиме Debug мы ловим fatalError, а в Release — в консоль уходит ошибка «Binding error to UI: Argument out of range.» и происходит автоматическая отвязка всего причастного в UI к этому Observable.

Чтобы проверить как это происходит — я немного модифицировал оригинальный IntroductionExampleViewController. Я закомментировал привязку к disposeButton.rx_tap, вместо нее я сделал свою (на гитхабе я закомментировал свой вариант, чтобы можно было менять имплементацию на лету)

disposeButton.rx_tap.debug("rx_tap")
    .flatMap{ value in
    return Observable.create{ observer in
        observer.on(.Next("1"))
        observer.onError(RxError.ArgumentOutOfRange)
        return NopDisposable.instance
    }
}
.bindTo(a.rx_text)
.addDisposableTo(disposeBag)

В Release режиме в консоли при старте появляется

2016-04-30 02:02:41.486: rx_tap -> subscribed

А при первом нажатии на кнопку

2016-04-30 02:02:48.248: rx_tap -> Event Next(())
Binding error to UI: Argument out of range.
2016-04-30 02:02:48.248: rx_tap -> disposed

Дальнейшие нажатия на кнопку уже ни к чему не приводят, т.к. rx_tap стал disposed

В итоге чтобы не следить за этими моментами и был создан Driver, он гарантирует как раз три вещи
а) данные будут шариться с помощью shareReplayLatestWhileConnected
б) поток исполняется на MainScheduler (грубо говоря UI поток)
в) не будет сгенерировано никаких ошибок (мы сами решаем какое значение вернуть вместо ошибки)

Таким образом создание драйвера можно представить как это делается в официальной документации

let safeSequence = xs
  .observeOn(MainScheduler.instance) // observe events on main scheduler
  .catchErrorJustReturn(onErrorJustReturn) // can't error out
  .shareReplayLatestWhileConnected         // side effects sharing
return Driver(raw: safeSequence)           // wrap it up

Если мы видим где то drive () вместо subscribe () мы понимаем что можем безопасно работать с ui.

Рассмотрим теперь пример GitHubSignup, там как раз сравнивают в лоб код с применением Driver и без оного.
Не используя Driver код создания viewModel будет следующим:

let viewModel = GithubSignupViewModel1(
    input: (
        username: usernameOutlet.rx_text.asObservable(),
        password: passwordOutlet.rx_text.asObservable(),
        repeatedPassword: repeatedPasswordOutlet.rx_text.asObservable(),
        loginTaps: signupOutlet.rx_tap.asObservable()
    )
    ...


т.к. rx_text — ControlProperty, то asObservable возвращает внутренний Observable без каких либо преобразований

Теперь как будет с применением Driver’а

let viewModel = GithubSignupViewModel2(
    input: (
        username: usernameOutlet.rx_text.asDriver(),
        password: passwordOutlet.rx_text.asDriver(),
        repeatedPassword: repeatedPasswordOutlet.rx_text.asDriver(),
        loginTaps: signupOutlet.rx_tap.asDriver()
    ),
    ...


Разница небольшая, вместо asObservable — asDriver, что приводит к выполнению вышеперечисленных 3х условий.

Если брать применение, то разница тоже минимальна, без Driver используются subscribe/bind и их модификации

viewModel.signupEnabled
    .subscribeNext { [weak self] valid  in
        self?.signupOutlet.enabled = valid
        self?.signupOutlet.alpha = valid ? 1.0 : 0.5
    }
    .addDisposableTo(disposeBag)

viewModel.validatedUsername
    .bindTo(usernameValidationOutlet.ex_validationResult)
    .addDisposableTo(disposeBag)

С Driver используем drive и его модификации

viewModel.signupEnabled
    .driveNext { [weak self] valid  in
        self?.signupOutlet.enabled = valid
        self?.signupOutlet.alpha = valid ? 1.0 : 0.5
    }
    .addDisposableTo(disposeBag)

viewModel.validatedUsername
    .drive(usernameValidationOutlet.ex_validationResult)
    .addDisposableTo(disposeBag)


Чуть интересней будет взглянуть в GithubSignupViewModel1/GithubSignupViewModel2 где создаются Driver’а

Многословный код в GithubSignupViewModel1

validatedUsername = input.username
        .flatMapLatest { username in
            return validationService.validateUsername(username)
                .observeOn(MainScheduler.instance)
                .catchErrorJustReturn(.Failed(message: "Error contacting server"))
        }
        .shareReplay(1) 

упрощается до

validatedUsername = input.username
        .flatMapLatest { username in
            return validationService.validateUsername(username)
                .asDriver(onErrorJustReturn: .Failed(message: "Error contacting server"))
        }

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

UIBindingObserver


2) на примере GeolocationViewController.swift можно увидеть как вешать собственные Observer’ы на UI элементы

private extension UILabel {
    var rx_driveCoordinates: AnyObserver {
        return UIBindingObserver(UIElement: self) { label, location in
            label.text = "Lat: \(location.latitude)\nLon: \(location.longitude)"
        }.asObserver()
    }
}

Итак, UIBindingObserver — generic класс помощник, позволяющий создавать привязку переданного в замыкание параметра (в нашем случае location) к изменениям свойства/свойств переданного объекта (в нашем случае свойство text). UIBindingObserver параметризуется классом объекта (в нашем случае UILabel, т.к. extension UILabel), в замыкание в качестве параметров будут передаваться как сам объект (label) так и значение с помощью которого мы будем менять состояние объекта (location)
Тип для параметра location в данном примере определяется автоматом, благодаря параметризации возвращаемого значения AnyObserver
Этот код к примеру не заработает

var rx_driveCoordinates: AnyObserver {
    let observer = UIBindingObserver(UIElement: self) { label, location in
        label.text = "Lat: \(location.latitude)\nLon: \(location.longitude)"
    }
    return observer.asObserver()
}


Ведь на момент создания observer, — UIBindingObserver понятия не имеет какой тип будет у location, ведь в отличии от оригинала не идет сразу возврата из замыкания. «Магия» автоопределения типов не сработает.
А вот такой уже пойдет, т.к. мы явно указали при создании UIBindingObserver тип всех параметров

var rx_driveCoordinates: AnyObserver {
    let uiBindingObserver: UIBindingObserver = UIBindingObserver(UIElement: self) { label, location in
        label.text = "Lat: \(location.latitude)\nLon: \(location.longitude)"
    }
    return uiBindingObserver.asObserver()
}

Подытожим. С одной стороны эта ремарка не имеет прямого отношения к RxSwift, это скорее отсылка к тому как Swift работает с типами переданных значений и их автоматическому распознанию, что избавляет нас от рутинного явного указания типов. С другой стороны важно понимать, что никакой магии в биндингах RXSwift нет. Со знанием что откуда берется и передается, можно для закрепления придумать задачку, — к примеру мы хотим, чтобы цвет текста UILabel’а менял цвет в зависимости от значения передаваемого в замыкание параметра типа Bool. Если он равен true пусть цвет текста станет красным, и черным в случае false
Все что нужно — параметризовать возвращаемый при определении Observer типом Bool, ну и корректно воспользоваться этим знанием внутри замыкания

var rx_wasError: AnyObserver {
    return UIBindingObserver(UIElement: self) { label, error in
        label.textColor = error ? UIColor.redColor() : UIColor.blackColor()
        }.asObserver()
}

Хорошо, последний момент, почему бы нам не вернуть UIBindingObserver, зачем приводить к AnyObserver? Потому что иначе нам бы пришлось тип возвращаемого значения параметризовать еще и типом объекта (UILabel), что абсолютно не важно в рамках задачи.

var rx_driveCoordinatesUIB: UIBindingObserver {
    return UIBindingObserver(UIElement: self) { label, location in
        label.text = "Lat: \(location.latitude)\nLon: \(location.longitude)"
        }
}

Правы ли мы? Смотрим в определение AnyObserver

/**
A type-erased `ObserverType`.

Forwards operations to an arbitrary underlying observer with the same `Element` type, hiding the specifics of the underlying observer type.
*/
Так и есть, AnyObserver — это обертка скрывающая тип переданного объекта, оставляя только тип передаваемого в замыкание параметра.

Следующее расширение благодаря полученным знаниям читается легко и просто. В зависимости от передаваемого в замыкание параметра типа Bool мы скрываем UIView, или наоборот делаем видимым.

private extension UIView {
    var rx_driveAuthorization: AnyObserver {
        return UIBindingObserver(UIElement: self) { view, authorized in
            if authorized {
                view.hidden = true
                view.superview?.sendSubviewToBack(view)
            }
            else {
                view.hidden = false
                view.superview?.bringSubviewToFront(view)
            }
        }.asObserver()
    }
}

ControlEvent

3) Чтобы в Rx окружении обрабатывать target-event паттерн ввели структуру ControlEvent<>
Она обладает следующими свойствами:
— ее код никогда не упадет
— при подписке не будет отправляться никакого изначального значения
— при освобождении памяти контролом будет сгенерировано .Completed
— наружу никогда не выйдет никаких ошибок
— все события будут выполняться на MainScheduler

Рассмотрим на примере нажатия на простую кнопку. Для UIButton создано расширение, где определено свойство rx_tap

extension UIButton {
    /**
    Reactive wrapper for `TouchUpInside` control event.
    */
    public var rx_tap: ControlEvent {
                return rx_controlEvent(.TouchUpInside)
    } 
}

Для UIControl’а же в расширении определен метод

public func rx_controlEvent(controlEvents: UIControlEvents) -> ControlEvent {
    let source: Observable = Observable.create { [weak self] observer in
        MainScheduler.ensureExecutingOnScheduler() // убеждаемся что мы в Main потоке

        guard let control = self else { // если контрол уже не существует - генерируем .Competed 
            observer.on(.Completed)
            return NopDisposable.instance
        }

        // самое важное, создаем ControlTarget на основании текущего контрола, переданных в качестве параметров событий на который мы должны реагировать и callback который будет вызываться при срабатывании события
        let controlTarget = ControlTarget(control: control, controlEvents: controlEvents) {
            control in
            observer.on(.Next())
        }
        
        return AnonymousDisposable {
            controlTarget.dispose()
        }
    }.takeUntil(rx_deallocated) // заканчиваем генерировать элементы при деаллокации контрола
    
    return ControlEvent(events: source)
}

Внутри класса ControlTarget уже и происходит подписка на события
control.addTarget (self, action: selector, forControlEvents: controlEvents)

Пользоваться же такими расширениями так же просто как и обычными Observable
Рассмотрим пример GeolocationExample, а точнее класс GeolocationViewController

class GeolocationViewController: ViewController {
    @IBOutlet weak private var button: UIButton!
...
    override func viewDidLoad() {
...
        button.rx_tap
            .bindNext { [weak self] in
                self?.openAppPreferences()
            }
            .addDisposableTo(disposeBag)
...
    }
...
}

Здесь мы просто делаем bindNext на каждое нажатие по кнопке, и в коде замыкания открываем панель настроек.
bindNext кстати всего лишь обертка над subscribe с проверкой на то, что мы в главном потоке

public func bindNext(onNext: E -> Void) -> Disposable {
    return subscribe(onNext: onNext, onError: { error in
        let error = "Binding error: \(error)"
        #if DEBUG
            rxFatalError(error)
        #else
            print(error)
        #endif
    })
}

Так же мы можем в любой момент при необходимости получить из ControlEvent — Observable с помощью .asObservable () или Driver с помощью .asDriver ()

ControlProperty

4) Чтобы сделать двустороннюю привязку к свойствам UI элемента на помощь приходит структура ControlProperty<> обладающая следующими свойствами

— её код никогда не упадет
— на последовательности элементов применен shareReplay (1)
— при осовобождении контролом памяти будет сгенерировано .Completed
— наружу никогда не выйдет никаких ошибок
— все события будут выполняться на MainScheduler

Для примера конечно же возьмем свойство text от UITextField

extension UITextField {
    /**
    Reactive wrapper for `text` property.
    */
    public var rx_text: ControlProperty {
        return UIControl.rx_value(
            self,
            getter: { textField in
                textField.text ?? ""
            }, setter: { textField, value in
                textField.text = value
            }
        )
    }
}

посмотрим что из себя представляет метод rx_value

static func rx_value(control: C, getter: (C) -> T, setter: (C, T) -> Void) -> ControlProperty {
        let source: Observable = Observable.create { [weak weakControl = control] observer in
                guard let control = weakControl else { // если контрол уже не существует - генерируем .Competed 
                    observer.on(.Completed)
                    return NopDisposable.instance
                }

                observer.on(.Next(getter(control))) // сразу генерируем новый элемент последовательности на основе начального значения свойства контрола посредством переданного getter'а

                                // сюрприз, и снова рассмотренный ранее ControlTarget
                let controlTarget = ControlTarget(control: control as! UIControl, controlEvents: [.AllEditingEvents, .ValueChanged]) { _ in
                    if let control = weakControl {
                        observer.on(.Next(getter(control)))
                    }
                }
                
                return AnonymousDisposable {
                    controlTarget.dispose()
                }
            }
            .distinctUntilChanged() // срабатывает только если значение действительно поменялось
            .takeUntil((control as! NSObject).rx_deallocated) // заканчиваем генерировать элементы при деаллокации контрола

        // и снова сюрприз, старый добрый UIBindingObserver позволяет сделать привязку элементов Observable к полю через переданный setter
        let bindingObserver = UIBindingObserver(UIElement: control, binding: setter) 

        return ControlProperty(values: source, valueSink: bindingObserver)
    }
}

Как мы видим двусторонняя привязка — это комбинация уже рассмотренных ControlTarget и UIBindingObserver
Если посмотреть на определение ControlProperty, то видно что он реализует протокол ControlPropertyType, который в свою очередь наследуется как от ObservableType, так и от ObserverType.
Еще раз взглянем на код IntroductionExampleViewController

@IBOutlet var a: NSTextField!
@IBOutlet var b: NSTextField!
@IBOutlet var c: NSTextField!
...

override func viewDidLoad() {
...
        //Таким образом ControlProperty может выступать как в качестве Observable
    let sum = Observable.combineLatest(a.rx_text, b.rx_text) { (a: String, b: String) -> (Int, Int) in
        return (Int(a) ?? 0, Int(b) ?? 0)
    }
...
    sum
        .map { (a, b) in
            return "\(a + b)"
        }
        .bindTo(c.rx_text) //так и в качестве Observer'а
        .addDisposableTo(disposeBag)
}

Если же нам нужно одновременно оба поведения, т.е. делать двустороннюю привязку — то можно подсмотреть как создать свой оператор в коде Rx

infix operator <-> {
}

func <-> (property: ControlProperty, variable: Variable) -> Disposable {
    let bindToUIDisposable = variable.asObservable()
        .bindTo(property)
    let bindToVariable = property
        .subscribe(onNext: { n in
            variable.value = n
        }, onCompleted:  {
            bindToUIDisposable.dispose()
        })

    return StableCompositeDisposable.create(bindToUIDisposable, bindToVariable)
}

Оператор позволяет создавать привязку просто и ясно

let textViewValue = Variable("")
textView.rx_text <-> textViewValue

DelegateProxy

5) краеугольный камень архитектуры Cocoa — делегаты. Но обычно предполагается — один делегат на один объект, поэтому в Rx добавили класс DelegateProxy, который позволяет одновременно использовать как обычный делегат, так и Rx последовательности.

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

public var rx_cancelButtonClicked: ControlEvent {
        let source: Observable = rx_delegate.observe(#selector(UISearchBarDelegate.searchBarCancelButtonClicked(_:)))
            .map { _ in
                return ()
            }
        return ControlEvent(events: source)
    }

Работать с ней легко и просто:

searchBar.rx_cancelButtonClicked.subscribeNext { _ in
            // выполняем нужные действия
        }

А вот работа с tableView меня несколько расстроила.
Если взять пример (SimpleTableViewExample), то все просто

class SimpleTableViewExampleViewController : ViewController {
    @IBOutlet weak var tableView: UITableView!

    override func viewDidLoad() {
        super.viewDidLoad()

        // создаем Observable  массивом элементов
        let items = Observable.just([
            "First Item",
            "Second Item",
            "Third Item"
        ])

                // связываем эти элементы с tableView (внутри автоматически создается прокси dataSource), так же указывая фабрику для создания ячеек
        items
            .bindTo(tableView.rx_itemsWithCellIdentifier("Cell", cellType: UITableViewCell.self)) { (row, element, cell) in
                cell.textLabel?.text = "\(element) @ row \(row)"
            }
            .addDisposableTo(disposeBag)

// добавляем реакцию на нажатие по строке, rx_modelSelected - обертка над tableView:didSelectRowAtIndexPath:
        tableView
            .rx_modelSelected(String)
            .subscribeNext { value in
                DefaultWireframe.presentAlert("Tapped `\(value)`")
            }
            .addDisposableTo(disposeBag)

// добавляем реакцию на нажатие по кнопки информации - обертка над tableView(_:accessoryButtonTappedForRowWithIndexPath:)
        tableView
            .rx_itemAccessoryButtonTapped
            .subscribeNext { indexPath in
                DefaultWireframe.presentAlert("Tapped Detail @ \(indexPath.section),\(indexPath.row)")
            }
            .addDisposableTo(disposeBag)

    }
}

Классно, rx_itemsWithCellIdentifier определен в самом Rx, так что доступен всем. Ок. А как дела обстоят с таблицей с секциями? Посмотрим пример SimpleTableViewExampleSectioned

class SimpleTableViewExampleSectionedViewController
    : ViewController
    , UITableViewDelegate {
    @IBOutlet weak var tableView: UITableView!

    let dataSource = RxTableViewSectionedReloadDataSource>()

    override func viewDidLoad() {
        super.viewDidLoad()

        let dataSource = self.dataSource

        let items = Observable.just([
            SectionModel(model: "First section", items: [
                    1.0,
                    2.0,
                    3.0
                ]),
            SectionModel(model: "Second section", items: [
                    1.0,
                    2.0,
                    3.0
                ]),
            SectionModel(model: "Second section", items: [
                    1.0,
                    2.0,
                    3.0
                ])
            ])

        dataSource.configureCell = { (_, tv, indexPath, element) in
            let cell = tv.dequeueReusableCellWithIdentifier("Cell")!
            cell.textLabel?.text = "\(element) @ row \(indexPath.row)"
            return cell
        }

        items
            .bindTo(tableView.rx_itemsWithDataSource(dataSource))
            .addDisposableTo(disposeBag)

        tableView
            .rx_itemSelected
            .map { indexPath in
                return (indexPath, dataSource.itemAtIndexPath(indexPath))
            }
            .subscribeNext { indexPath, model in
                DefaultWireframe.presentAlert("Tapped `\(model)` @ \(indexPath)")
            }
            .addDisposableTo(disposeBag)

        tableView
            .rx_setDelegate(self)
            .addDisposableTo(disposeBag)
    }

    func tableView(tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
        let label = UILabel(frame: CGRect.zero)
        label.text = dataSource.sectionAtIndex(section).model ?? ""
        return label
    }
}

Обращаем внимание на RxTableViewSectionedReloadDataSource, а где он определен? В проекте RxExample, т.е. как я понимаю это не обкатанное решение, которое рекомендуется всем, а так, для примера. Если заглянуть внутрь, понимаешь почему, там на каждый чих предлагают перезагружать данные для всей таблицы

public func tableView(tableView: UITableView, observedEvent: Event) {
    UIBindingObserver(UIElement: self) { dataSource, element in
        dataSource.setSections(element)
        tableView.reloadData()
    }.on(observedEvent)
}


Мягко говоря не самое лучшее решение. Какие альтернативы? Опять таки в RxExample определен класс RxTableViewSectionedAnimatedDataSource. Для примера работы с этим dataSource предоставлен пример TableViewPartialUpdates. Он демонстрирует в сравнении как обновлять данные в таблицах с секциями как с полной перезагрузкой данных (RxTableViewSectionedReloadDataSource), так и с частичной (RxTableViewSectionedAnimatedDataSource). Здесь же и пример работы с CollectionView. Но все это без учета возможности редактирования.
Что же, мне и карты в руки, создам ка я простенький пример работы с таблицей с секциями и возможностью редактирования. Пример TableViewEditPartialUpdate я положил к остальным примерам в RxExample.

Учитывая что это был мой первый опыт «боевого» кода работы с GUI в RxSwift я тут же получил свою порцию граблей.

class TablViewControllerEditPartialUpdate : ViewController {
    @IBOutlet weak var tableView: UITableView!
    var sections = Variable([NumberSection]())
    
    override func viewDidLoad() {
        super.viewDidLoad()

        // NumberSection - на самом деле typealias AnimatableSectionModel
        let items = [
            NumberSection(model: "Section 1", items: [1, 3, 5]),
            NumberSection(model: "Section 2", items: [2, 4, 6, 8]),
            NumberSection(model: "Section 3", items: [7, 11, 10])
        ]
        self.sections.value = items
        let editableDataSource = RxTableViewSectionedAnimatedDataSource()
        
        configDataSource(editableDataSource)

        // замечу что используется rx_itemsAnimatedWithDataSource, а не rx_itemsWithDataSource
        self.sections.asObservable()
            .bindTo(tableView.rx_itemsAnimatedWithDataSource(editableDataSource))
            .addDisposableTo(disposeBag)
        
        // удаляем элемент из модели
        tableView.rx_itemDeleted.subscribeNext{[weak self] item in
            if let controller = self {
                controller.sections.value[item.section].items.removeAtIndex(item.row)
            }
            }.addDisposableTo(disposeBag)
        
        // извлекаем объект при нажатии на строку таблицы
        tableView
            .rx_modelSelected(IdentifiableValue)
            .subscribeNext { i in
                DefaultWireframe.presentAlert("Tapped `\(i)`")
            }
            .addDisposableTo(disposeBag)
        
        // получаем NSIndexPath при нажатии на строку таблицы, что очень помогло при определении типа объекта
        tableView
            .rx_itemSelected
            .subscribeNext { [weak self]  i in
                if let controller = self {
                    print("Tapped `\(i)` - \(controller.sections.value[i.section].items[i.row].dynamicType)")
                }
            }
            .addDisposableTo(disposeBag)
    }
    
    func configDataSource(dataSource: RxTableViewSectionedDataSource) {
        dataSource.configureCell = { (_, tv, ip, i) in
            let cell = tv.dequeueReusableCellWithIdentifier("Cell")
                ?? UITableViewCell(style:.Default, reuseIdentifier: "Cell")
            cell.textLabel!.text = "\(i)"
            return cell
        }
        
        dataSource.titleForHeaderInSection = { (ds, section: Int) -> String in
            return dataSource.sectionAtIndex(section).model
        }
        
        dataSource.canEditRowAtIndexPath = { (ds, ip) in
            return true
        }
    }
}

1) Я написал этот код, прописал свой класс для TableViewController созданному в storyboard и попробовал запустить. Ошибка.

fatal error: Failure converting from  to UITableViewDataSource: file /Users/SparkLone/projects/repos/RxSwift/RxCocoa/Common/RxCocoa.swift, line 340


Надо же. Далеко не сразу я понял в чем дело. Много Rx кода поковырял в тщетной попытке пробраться через дебри. А дело было в том, что по умолчанию при создании ViewTableController’а в дизайнере он указывает наш контроллер в качестве dataSource. А когда Rx создает прокси — он пытается текущий dataSource указать как forwardToDelegate. А мой контроллер не реализует DataSource в каноническом виде. Винить конечно некого, но видимо начиная работать с библиотекой такого рода, подсознательно ожидаешь каких то хитрых багов.

2) Окей, хотели хитрых багов, — пожалуйста.
Изначально вместо строки

rx_modelSelected(IdentifiableValue)

была

rx_modelSelected(Int)

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

fatal error: Failure converting from 4 to Int: file /Users/SparkLone/projects/repos/RxSwift/RxCocoa/Common/RxCocoa.swift, line 340

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

tableView
    .rx_itemSelected
    .subscribeNext { [weak self]  i in
        if let controller = self {
            print("Tapped `\(i)` - \(controller.sections.value[i.section].items[i.row].dynamicType)")
        }
    }
    .addDisposableTo(disposeBag)

Эту ошибку я могу принять на свой счет уже с натяжкой, нигде упоминания ни про какой IdentifiableValue в примерах не было.

3) изначально я указал в качестве данных для первой секции не [1, 3, 5], а [1, 3, 3]
Приложение нормально стартовало, но при попытке удалить строку совершенно в другой секции я получил уже такую ошибку

precondition failed: Item 3 has already been indexed at (0, 1): file /Users/SparkLone/projects/repos/RxSwift/RxExample/RxDataSources/DataSources/Differentiator.swift, line 130


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

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

Хорошо, давайте рассмотрим как все это работает.

90e52a15cf29484b82bb3d0ad502ea06.png

Мы создаем расширение над UIView subclass, внутри него определяем переменную rx_delegate, которая в свою очередь создает прокси для делегата. Далее в расширении прописываем обертки над теми событиями которые мы планируем обрабатывать. Клиент подписывается на эти обертки над событиями, и при возникновении такого события прокси сначала генерирует Observable элемент, который поступает клиенту, затем если есть — отправляет (делает forward, чтобы было понятно API протокола) его обычному делегату, если он был назначен до создания Rx делегата.

В основе лежит протокол

protocol DelegateProxyType {
        // функция отвечающая за создание прокси
        static func createProxyForObject(object: AnyObject) -> AnyObject
        // прикрепляем прокси к объекту посредством objc_setAssociatedObject
        static func assignProxy(proxy: AnyObject, toObject object: AnyObject)
        // получаем прикрепленный прокси к объекту посредством objc_getAssociatedObject
    static func assignedProxyFor(object: AnyObject) -> AnyObject?
    
    
    // функции отвечающие за прикрепление / получении доступа к обычному(не Rx) делегату
    func setForwardToDelegate(forwardToDelegate: AnyObject?, retainDelegate: Bool)
    func forwardToDelegate() -> AnyObject?
    

    // функции отвечающие за присвоение / возврат делегату-прокси, с приведением типов
    static func currentDelegateFor(object: AnyObject) -> AnyObject?
    static func setCurrentDelegate(delegate: AnyObject?, toObject object: AnyObject)
}

Так же есть базовый класс DelegateProxy который реализует первые 5 методов из этого протокола. Оставшиеся два обычно переопределяют конкретные расширения, т.к. именно они знают какого типа должен быть UI объект и какое имя имеет свойство содержащее делегат в конкретном UIControl

class DelegateProxy {
        public class func createProxyForObject(object: AnyObject) -> AnyObject {}
        public class func assignedProxyFor(object: AnyObject) -> AnyObject? {}
        public class func assignProxy(proxy: AnyObject, toObject object: AnyObject) {}
        public func setForwardToDelegate(delegate: AnyObject?, retainDelegate: Bool) {}
        public func forwardToDelegate() -> AnyObject? {}
}

Чтобы было чуть понятней рассмотрим на примере класса UISearchController.
Для него создали расширение

extension UISearchController {
        // создаем обертку над делегатом, создающую наш прокси RxSearchControllerDelegateProxy
    public var rx_delegate: DelegateProxy {
        return proxyForObject(RxSearchControllerDelegateProxy.self, self)
    }

    // Rx обертка над конкретным событием UISearchControllerDelegate.didDismissSearchController(_:)
    public var rx_didDismiss: Observable {
        return rx_delegate
            .observe(#selector(UISearchControllerDelegate.didDismissSearchController(_:)))
            .map {_ in}
    }
    ...
}


Прокси для UISearchController является RxSearchControllerDelegateProxy

public class RxSearchControllerDelegateProxy
    : DelegateProxy
    , DelegateProxyType
    , UISearchControllerDelegate {
    
    // для проставление делегата (нашего прокси) нам нужно знать тип класса (UISearchController) и имя свойства для делегата (delegate)
    public class func setCurrentDelegate(delegate: AnyObject?, toObject object: AnyObject) {
        let searchController: UISearchController = castOrFatalError(object)
        searchController.delegate = castOptionalOrFatalError(delegate)
    }
    
    // те же знания необходимы и для того, чтобы вернуть прокси по запросу
    public class func currentDelegateFor(object: AnyObject) -> AnyObject? {
        let searchController: UISearchController = castOrFatalError(object)
        return searchController.delegate
    }
}

Копнем чуть глубже.

Прокси в примере создается с помощью

proxyForObject(RxSearchControllerDelegateProxy.self, self)

proxyForObject — это глобальная функция, ядро работы с прокси для делегатов. В качестве параметров ей передаются тип прокси (RxSearchControllerDelegateProxy.self) и объект к которому мы будем прикреплять прокси.

В нашем случае type будет RxSearchControllerDelegateProxy, object — текущим объектом типа UISearchController

public func proxyForObject(type: P.Type, _ object: AnyObject) -> P {
    MainScheduler.ensureExecutingOnScheduler() // проверяем что код выполняется в главном потоке
    
    let maybeProxy = P.assignedProxyFor(object) as? P // assignedProxyFor определена в DelegateProxy и больше нигде не переопределяется
    
    let proxy: P
    if maybeProxy == nil {
        proxy = P.createProxyForObject(object) as! P // создаем прокси (в нашем случае типа RxSearchControllerDelegateProxy). Метод createProxyForObject определен в DelegateProxy и при инициализации просто сохраняет объект для которого является прокси, но его могут переопределять классы с наследованием, для иерархии которых каждому нужен свой делегат
        P.assignProxy(proxy, toObject: object) // прикрепляем новый прокси к объекту. assignProxy так же определен в DelegateProxy, и его не переопределяют, как и assignedProxyFor
        assert(P.assignedProxyFor(object) === proxy)
    }
    else {
        proxy = maybeProxy! // если прокси уже был прикреплен ранее - берем его
    }
    
    let currentDelegate: AnyObject? = P.currentDelegateFor(object) // возвращает текущий делегат содержащийся в свойстве UI объекта (обычно delegate/dataSource). Все кто реализуют протокол DelegateProxy переопределяют этот метод, т.к. там есть приведение as! к типу объекта к которому мы прикрепляем прокси
    
    if currentDelegate !== proxy { // если текущий делегат не идентичен текущей прокси
        proxy.setForwardToDelegate(currentDelegate, retainDelegate: false) // проставляем старый делегат в качестве делегата к которому будут пересылаться сообщения. Таким образом у UI объекта будет одновременно и прокси делегат и обычный. Изначальная реализация лежит в Objective-C части Rx, а точнее в _RXDelegateProxy.
        P.setCurrentDelegate(proxy, toObject: object) // все кто соответствует протоколу DelegateProxy переопределяют этот метод, т.к. там есть приведение as! к типу объекта к которому мы прикрепляем прокси
        assert(P.currentDelegateFor(object) === proxy)
        assert(proxy.forwardToDelegate() === currentDelegate)
    }
        
    return proxy
}

Таким образом эта функция создает прокси если не был создан ранее, проставляет её в качестве текущего делегата, и если был — сохраняет обычный делегат.

Совсем глубоко в реализацию лезть не очень хочется, скажу лишь, что подмена метода при вызове методов делегатов делается стандартным swizzling’ом из Objective-C кода.

Я создал UML диаграмму последовательности, надеюсь с ней станет чуть понятней как происходит создание прокси (картинка кликабельна).

2f17ba30992448a7be33a9ef268e0d11.png

А теперь нырнем еще чуть глубже, в последний раз, обещаю.
Что делать если наш UI класс имеет делегат, но он является наследником от другого UI класса также имеющего делегат? Фабричный метод нам поможет.

Рассмотрим на примере UITableView. Он является наследником UIScrollView, и которого тоже есть делегат. Поэтому rx_delegate определен в родительском классе (UIScrollView), а не в UITableView.
Прокси для RxTableViewDelegateProxy является наследником RxScrollViewDelegateProxy

extension UIScrollView {
    
    /**
    Factory method that enables subclasses to implement their own `rx_delegate`.
    
    - returns: Instance of delegate proxy that wraps `delegate`.
    */
    public func rx_createDelegateProxy() -> RxScrollViewDelegateProxy {
        return RxScrollViewDelegateProxy(parentObject: self)
    }
    
    /**
    Reactive wrapper for `delegate`.
    
    For more information take a look at `DelegateProxyType` protocol documentation.
    */
    public var rx_delegate: DelegateProxy {
        return proxyForObject(RxScrollViewDelegateProxy.self, self)
    }
...
}

В его прокси переопределён метод класса createProxyForObject, который делегирует создание прокси методу rx_createDelegateProxy

public class RxScrollViewDelegateProxy
    : DelegateProxy
    , UIScrollViewDelegate
    , DelegateProxyType {

    public override class func createProxyForObject(object: AnyObject) -> AnyObject {
        let scrollView = (object as! UIScrollView)
        
        return castOrFatalError(scrollView.rx_createDelegateProxy())
    }
    ...
}

В UItableView же метод rx_createDelegateProxy переопределен

extension UITableView {
 
    /**
    Factory method that enables subclasses to implement their own `rx_delegate`.
    
    - returns: Instance of delegate proxy that wraps `delegate`.
    */
    public override func rx_createDelegateProxy() -> RxScrollViewDelegateProxy {
        return RxTableViewDelegateProxy(parentObject: self)
    }
        ...
}

Конструктор RxTableViewDelegateProxy при создании вызывает родительский конструктор (в нашем случае RxScrollViewDelegateProxy)

public class RxTableViewDelegateProxy
    : RxScrollViewDelegateProxy
    , UITableViewDelegate {

    public weak private(set) var tableView: UITableView?

    public required init(parentObject: AnyObject) {
        self.tableView = (parentObject as! UITableView)
        super.init(parentObject: parentObject)
    }
}

Таким образом ини

© Habrahabr.ru