RxSwift 정리글에 이어서 RxCocoa를 정리를 조금 해봅니다.
RxSwift는 플랫폼에 구애받지 않고 익혀두면 어디에서도 적용할 수 있는 공통적인 Reactive 사양을 구현해 놓았다면,
RxCocoa는 iOS 개발에 (아직은) 많은 부분을 차지하고 있는 Cocoa Framework에 좀 더 특화되어 도움을 주는 클래스들이 있습니다.
이는 UIKit들에 반응형 확장(.rx)를 추가해서 다양한 이벤트를 subscribe 할 수 있게 해 줍니다.
.rx
RxCocoa를 import 하면
UIKit의 요소들(UIButton, UISwitch, UITableView, ...)의 인스턴스에서
.rx 키워드를 통해 해당 UI의 동작을 Reactive 하게 처리할 수 있도록 해줍니다.
UISwitch의 예시입니다.
UIKit에서 UISwitch, UIButton 등 에서 사용자의 입력에 대응하려면 아래와 같이 selector를 이용했는데,
lazy var someSwitch = UISwitch().then {
$0.isOn = true
$0.addTarget(self, action: #selector(switchTapped(sender:)), for: .valueChanged)
}
@objc func switchTapped(sender: UISwitch) {
print("is", sender.isOn ? "On!" : "Off!")
}
RxCocoa를 사용하면 .rx 를 통해 아래처럼 작성할 수 있습니다.
let someSwitch = UISwitch().then {
$0.isOn = true
}
func bind() {
someSwitch.rx.isOn
.observe(on: MainScheduler.instance)
.subscribe(onNext: { [weak self] isOn in
self?.someLabel1.text = isOn ? "is On!" : "is Off!"
})
.disposed(by: disposeBag)
}
.rx는 RxCocoa Extension을 사용할 수 있도록 해주는 역할이고,
후에 커스텀하게 만들어서 사용할 때도 rx를 통해서 사용하게 됩니다.
(후속글에서 해당 방법을 정리할 예정입니다)
isOn은 ControlProperty입니다.
ControlProperty는 Property의 변경사항을 시퀀스로 받아올 수 있게 해주는 ObservableType 입니다.
그래서 subscribe를 통해서 구독할 수 있게 됩니다.
.bind(to:)
RxCocoa를 사용하는 여러 가지 이유 중 하나인 .bind(to:)입니다.
.bind는 Observer Type에 바인드시켜 줄 수 있습니다.
Observer Type은 값을 주입시켜줄 수 있는 타입입니다.
여기서 Binder라는 개념이 나옵니다.
Binder는 새로운 값을 accept할 수는 있지만, subscribe할 수는 없는 무언가는 나타내는 유용한 구성이며, 값을 특정 구현이나 개체에 바인딩하는데 자주 사용됩니다.
rx확장에서 Binder는 @dynamicMemeberLookup을 통해서,
Reactive<Base>의 모든 writable 참조 property에 동적으로 만들어지도록 되어 있습니다.
Binder는 Observer Type으로서 bind(to:)를 통해 바인딩을 해줄 수 있다는 것이죠!
'RxCocoa가 지원하는 모든 클래스들의 writable property들은 rx확장을 통해 Binder 타입으로 쓸 수 있구나!'
하고 생각하면 되겠습니다.
(@dynamicMemberLookup에 대한 정리 포스팅은 글 아래에 첨부했습니다)
위의 UISwitch를 UILabel과 Bind시키도록 바꿔보면
아래처럼 작성할 수 있습니다.
let someLabel = UILabel().then {
$0.text = "is On!"
}
let someSwitch = UISwitch().then {
$0.isOn = true
}
func bind() {
someSwitch.rx.isOn
.observe(on: MainScheduler.instance)
.map { $0 ? "is On!" : "is Off!" }
.bind(to: someLabel.rx.text)
.disposed(by: disposeBag)
}
스위치를 동작하면 상태값 Bool을 받아서 map으로 변환 뒤 Label의 Text에 바인딩을 해줍니다.
아래처럼 Observer이자 Observable인 PublishRelay를 통해서 bind(to:)를 해줄 수도 있습니다.
MVVM에서 View로부터 받은 사용자의 입력을 ViewModel로 전달받을 때 사용하는 방법입니다.
View로부터의 입력은 View가 사라질 때까지 종료되지 않고(completed, error 이벤트가 발생하지 않고) 값을 받기만 하기 때문에 PublishRelay를 사용합니다.
let someLabel = UILabel().then {
$0.text = "is On!"
}
let someSwitch = UISwitch().then {
$0.isOn = true
}
let someLabelText = PublishRelay<String>()
func bind() {
someLabelText
.observe(on: MainScheduler.instance)
.subscribe(onNext: { [weak self] isOn in
self?.someLabel.text = string
})
.disposed(by: disposeBag)
someSwitch.rx.isOn
.map { $0 ? "is On!" : "is Off!" }
.bind(to: someLabelText)
.disposed(by: disposeBag)
}
Driver와 Signal
RxSwift와 함께 UI작업을 할 때는 몇가지 특징이 있습니다.
- 항상 메인스레드에서 동작을 시키기 때문에 .observeOn(MainScheduler.instance) 작업을 항상 해주게 됩니다.
- 클로저 내부에서 self를 사용하게 되면 순환참조가 발생되어 메모리 누수가 발생할 수 있습니다.
- Data를 처리하다가 error가 발생하면 시퀀스가 끊어져버릴 수 있고, 한번 끊어진 시퀀스는 재활용을 할 수 없게 됩니다. 이렇게 되면 오동작을 일으킬 수 있습니다. 그래서 항상 .catchErrorJustReturn()을 사용하게 됩니다.
위에 예시문에서도 .observeOn(MainScheduler.instance) 를 작성해주었지만,
UI작업을 하는 내내 위 처리들을 모두 하기엔 불편하기도 하고 코드 가독성도 좋지 않기 때문에
이와 같은 처리를 고려하지 않아도 되는 Traits들이 만들어져 있습니다.
바로 Driver와 Signal입니다.
Driver와 Signal은 다음과 같은 특징을 가지고 있습니다.
- 항상 MainScheduler에서 실행되기 때문에 observeOn을 작성하지 않아도 됩니다.(백그라운드 스레드에서 UI변경이 되지 않을 수 있으므로 주의해야 합니다.)
- 순환참조 해결을 위한 [weak self], 혹은 .withUnretained 처리를 해주지 않아도 됩니다.
- Complete, Error도 발생시키지 않기 때문에 시퀀스가 끊어지지 않습니다.
차이점은 Driver는 subscribe를 하면 새 Observer에게 최신값을 replay시켜주는 특징이 있고,
Signal은 최신값을 replay시켜주지는 않는다는 점입니다.
간단한 예제를 보겠습니다.
앞에서 작성한 스위치 예제를 MVVM에서 자주 쓰이는 형태로 바꾸었습니다.(Model은 생략)
먼저 ViewModel입니다. ViewModel에서는
Switch로부터 바인딩하여 값을 받을 switchIsOn이 PublishRelay<Bool>로 있고,
switchIsOn을 map으로 데이터 가공을 하고 asDriver를 통해 Driver로 전환하여 View로 값을 전달할 labelText가 Driver<String>로 되어 있습니다.
asDriver는 Obsevable을 .observeOn(MainScheduler.instance)와 .catchErrorJustReturn 처리를 한번에 하여 Driver로 변환시켜주는 역할을 해줍니다.
class SomeViewModel {
let disposeBag = DisposeBag()
// ViewModel -> View
let labelText: Driver<String>
// ViewModel <- View
let switchIsOn = PublishRelay<Bool>()
init() {
labelText = switchIsOn
.map { $0 ? "is On!" : "is Off!" }
.asDriver(onErrorJustReturn: "error!")
}
}
View에서 ViewModel의 Driver와 바인딩을 시켜주기 위해서는 .bind(to:) 대신 .drive()를 사용합니다.
class SomeViewController: UIViewController {
let disposeBag = DisposeBag()
var viewModel = SomeViewModel()
let someLabel = UILabel().then {
$0.text = "is On!"
}
let someSwitch = UISwitch().then {
$0.isOn = true
}
override func viewDidLoad() {
super.viewDidLoad()
attribute()
layout()
bind(to: viewModel)
}
func attribute() {
// (생략)
}
func layout() {
// (생략)
}
func bind(to viewModel: SomeViewModel) {
// ViewModel -> View
viewModel.labelText
.drive(someLabel.rx.text) // bind(to:) 대신 drive를 사용
.disposed(by: disposeBag)
// ViewModel <- View
someSwitch.rx.isOn
.bind(to: viewModel.switchIsOn)
.disposed(by: disposeBag)
}
}
(동작 결과는 위 예제들과 동일합니다)
간단한 예제들로 RxCocoa에서 주로 쓰이는
bind, ControlProperty, Driver, Signal의 개념들을 정리해봤습니다.
읽어주셔서 감사합니다.
샘플 코드
https://github.com/redxoul/RxCocoaSample
GitHub - redxoul/RxCocoaSample: RxCocoa
RxCocoa. Contribute to redxoul/RxCocoaSample development by creating an account on GitHub.
github.com
관련글
[Swift] @dynamicMemberLookup과 Builder 패턴
dynamicMemberLookup은 class, struct, enum, protocol에 적용하여 런타임에 dot(.) 문법으로 접근할 수 있도록 해주는 편리한 기능입니다. dynamicMemberLookup을 사용하려면 subscript(dynamicMember:)를 구현해주어야 합니
swifty-cody.tistory.com