Published on

๐ŸŽ Swift - RxSwift Traits

Authors
  • avatar
    Name
    ์ด์ฐฝ์ค€
    Twitter

Traits

Traits๋Š” Observable์˜ ํ•œ ์ข…๋ฅ˜๋ผ๊ณ  ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

Traits๋Š” UI ์˜์—ญ์—์„œ ๋ณต์žกํ•˜๊ณ  ๋‹ค์–‘ํ•œ ๊ธฐ๋Šฅ์ด ์žˆ๋Š” Observable ๋Œ€์‹  ์‚ฌ์šฉ๋˜๊ธฐ ์œ„ํ•ด ๋งŒ๋“ค์–ด์กŒ์Šต๋‹ˆ๋‹ค.

๋”ฐ๋ผ์„œ RxCocoa์™€ ์•„์ฃผ ๋ฐ€์ ‘ํ•œ ๊ด€๋ จ์ด ์žˆ์ง€๋งŒ, ๋ช‡๋ช‡ ๊ธฐ๋Šฅ๋“ค์€ RxSwift ์ „๋ฐ˜์ ์œผ๋กœ ์‚ฌ์šฉ๋  ์ˆ˜ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— RxSwift์™€ RxCocoa์— ๋‚˜๋ˆ„์–ด ๊ตฌํ˜„๋˜์—ˆ๋‹ค๊ณ  ํ•ฉ๋‹ˆ๋‹ค.

๋“ฑ์žฅ ์ด์œ 

Traits๋ฅผ ์™œ ์‚ฌ์šฉํ•ด์•ผํ•˜๋Š”์ง€๋ถ€ํ„ฐ ์•Œ์•„๋ณด๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

Traits๋Š” Observable ์‹œํ€€์Šค๊ฐ€ ์•ˆ์ „ํ•˜๊ฒŒ ํ†ต์‹ ๋  ์ˆ˜ ์žˆ๋„๋ก ๋•์Šต๋‹ˆ๋‹ค.

๋ชจ๋“  ๋ฐฉ๋ฉด์—์„œ ์‚ฌ์šฉ๋  ์ˆ˜ ์žˆ๋Š” Observable์„ ๋Œ€์‹ ํ•˜์—ฌ UI ์ฒ˜๋ฆฌ์— ํŠนํ™”๋œ ๊ธฐ๋Šฅ๊ณผ ๋ฌธ๋ฒ•์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

๋”ฐ๋ผ์„œ Traits๋Š” Observable์ด ์ œ๊ณตํ•˜๋Š” ๊ธฐ๋Šฅ์˜ ์ผ๋ถ€๋ฅผ ๋–ผ์–ด๋‚ธ ๊ฒƒ๊ณผ ๋‹ค๋ฆ„์—†๊ธฐ ๋•Œ๋ฌธ์— ์‚ฌ์šฉํ• ์ง€ ๋ง์ง€์˜ ์—ฌ๋ถ€๋Š” ์‚ฌ์šฉ์ž์—๊ฒŒ ๋‹ฌ๋ ค์žˆ๋‹ค๊ณ  ํ•ฉ๋‹ˆ๋‹ค!

๊ธฐ๋ณธ ๊ฐœ๋…

Traits๋Š” ๋‹จ์ˆœํžˆ ํ•˜๋‚˜์˜ read-onlyํ•œ Observable์„ ๊ฐ์‹ธ๊ณ  ์žˆ๋Š” struct์ž…๋‹ˆ๋‹ค.

struct Single<Element> {
	let source: Observable<Element>
}

struct Driver<Element> {
	let source: Observable<Element>
}

Traits๋ฅผ Observable๋กœ ๋Œ๋ ค๋†“๊ณ  ์‹ถ์„ ๋•Œ๋Š” .asObservable()๋กœ ๊ฐ„๋‹จํ•˜๊ฒŒ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

RxSwift Traits

Single

Single์€ ๋ฌด์กฐ๊ฑด ํ•˜๋‚˜์˜ ๊ฐ’ ๋˜๋Š” ์—๋Ÿฌ๋ฅผ ๋ฐฉ์ถœํ•ฉ๋‹ˆ๋‹ค.

์ง€์†์ ์ธ ๊ฐ’์ด ์•„๋‹ˆ๋ผ ๋”ฑ ํ•œ ๋ฒˆ์˜ ๊ฒฐ๊ณผ๊ฐ’์ด ํ•„์š”ํ•  ๋–„ ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค.

ํ•œ ๋ฒˆ์˜ ๊ฐ’๋งŒ์„ ๋ฐฉ์ถœํ•˜๊ธฐ ๋–„๋ฌธ์— .completed๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.

์‚ฌ์šฉ๋˜๊ธฐ ๊ฐ€์žฅ ์ข‹์€ ๊ณณ์€ HTTP ํ†ต์‹ ์„ ํ•  ๋•Œ์ด๋ฉฐ, request์— ํ•˜๋‚˜์˜ ์‘๋‹ต๋งŒ์ด ๋Œ์•„์˜ฌ ๋•Œ ์‚ฌ์šฉํ•˜๋ฉด ์œ ์šฉํ•ฉ๋‹ˆ๋‹ค.

func getRepo(_ repo: String) -> Single<[String: Any]> {
	return Single<[String: Any]>.create { single in
		let task = URLSession.dataTask(with: URL(string: "https://api.github.com/\(repo)")! { data, _, error in
			if let error {
				single(.failure(error))
				return
			}

			guard let data,
				let json = try? JSONSerialization.jsonObject(with: data, option: .mutableLeaves),
				let result = json as? [String: Any] else {
					single(.failure(DataError.cantParseJSON))
					return	  
			}

			single(.success(result))
		}
		task.resume()

		return Disposables.create { task.cancel() }
	}
}
getRepo("ReactiveX/RxSwift")
	.subscribe(onSuccess: { json in
			print("JSON: ", json)
		},
		onError: { error in
			print("Error: ", error)
		})
	.disposed(by: self.disposeBag)

Completable

Completable์€ .completed๋‚˜ .error๋งŒ์„ ๋ฐฉ์ถœํ•ฉ๋‹ˆ๋‹ค.

ํ•œ๋งˆ๋””๋กœ ๊ฐ’(element)์„ ๋ฐฉ์ถœํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

์ž‘์—…์˜ ๋‚ด์šฉ์€ ์ค‘์š”ํ•˜์ง€ ์•Š๊ณ , ์„ฑ๊ณต/์‹คํŒจ ์—ฌ๋ถ€๋งŒ ์ค‘์š”ํ•œ ๊ฒฝ์šฐ์— ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค.

func cacheLocally() -> Completable {
	return Completable.create { completable in
		guard success else {
			completable(.error(CacheError.failedCaching))
			return Disposables.create { }
		}

		completable(.completed)
		return Disposables.create { }
	}
}
cacheLocally()
	.subscribe(onCompleted: {
		print("Completed with no error.")
	},
	onError: { error in
		print("Completed with an error: \(error.localizedDescription)")
	})
	.disposed(by: self.disposebag)

Maybe

Maybe๋Š” Single๊ณผ Completable์˜ ์ค‘๊ฐ„์— ์žˆ๋Š” Trait์ž…๋‹ˆ๋‹ค.

ํ•˜๋‚˜์˜ ๊ฐ’์„ ๋ฐฉ์ถœํ•˜๊ฑฐ๋‚˜ ๋ฐฉ์ถœ ์—†์ด .complete๋˜๊ฑฐ๋‚˜ .error๋ฅผ ๋ฐฉ์ถœํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

func generateString() -> Maybe<String> {
	return Maybe<String>.create { maybe in
		maybe(.success("RxSwift"))
		// or
		maybe(.completed)
		// or
		maybe(.error(error))

		return Disposables.create { }
	}
}
generateString()
	.subscribe(onSuccess: { element in
		print("Completed with element \(element)")
	},
	.onError { error in
		print("Completed with an error \(error.localizedDescription)")
	},
	onCompleted: {
		print("Completed with no element")
	})
	.disposed(by: self.disposeBag)

RxCocoa Traits

Driver

๊ฐ€์žฅ ๋งŽ์ด ์‚ฌ์šฉ๋˜๋Š” Trait์ž…๋‹ˆ๋‹ค.

UI ๋ ˆ์ด์–ด๋งŒ์„ ์œ„ํ•ด ํŠน๋ณ„ํ•˜๊ฒŒ ๊ฐœ๋ฐœ๋œ ๊ธฐ๋Šฅ์ž…๋‹ˆ๋‹ค.

ํŠน์ง•์„ ์ •๋ฆฌํ•ด๋ณด๋ฉด ์•„๋ž˜์™€ ๊ฐ™์Šต๋‹ˆ๋‹ค.

  • ์—๋Ÿฌ๊ฐ€ ๋ฐฉ์ถœํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค
  • observe๊ฐ€ Main Scheduler์—์„œ ์ด๋ฃจ์–ด์ง‘๋‹ˆ๋‹ค.
  • side effect๋ฅผ ๊ณต์œ ํ•ฉ๋‹ˆ๋‹ค.
let results = query.rx.text
	.throttle(.milliseconds(300), scheduler: MainScheduler.instance)
	.flatMapLatest { query in
		fetchAutoCompleteItems(query)
	}

results
	.map { "\($0.count)" }
	.bind(to: resultCount.rx.text)
	.disposed(by: self.disposeBag)

results
	.bind(to: resultsTableView.rx.items(cellIdentifier: "Cell")) { _, result, cell in
		cell.textLabel?.text = "\(result)"
	}
	.disposed(by: self.disposeBag)

์œ„ ์˜ˆ์‹œ๋Š” ์„ธ๊ฐ€์ง€ ๋ฌธ์ œ์ ๋“ค์„ ๊ฐ€์ง€๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

  1. fetchAutoCompleteItems๊ฐ€ ์—๋Ÿฌ๋ฅผ ๋ฐฉ์ถœํ•  ๊ฒฝ์šฐ, UI์— ๋ฐ”์ธ๋”ฉ๋˜์–ด ์žˆ๋˜ ๊ฒƒ๋“ค์ด ๋ชจ๋‘ unbind๋˜๋ฉด์„œ ์ดํ›„์˜ ์ฟผ๋ฆฌ๋“ค์— ๋” ์ด์ƒ UI๊ฐ€ ๋ณ€ํ™”ํ•˜์ง€ ์•Š์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค.
  2. fetchAutoCompleteItems๊ฐ€ ๋ฉ”์ธ์“ฐ๋ ˆ๋“œ๊ฐ€ ์•„๋‹Œ ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์“ฐ๋ ˆ๋“œ์— ๊ฒฐ๊ณผ๋ฅผ ๋ฐ˜ํ™˜ํ•  ๊ฒฝ์šฐ, UI์— ๊ฒฐ๊ณผ๊ฐ’์„ ๋ฐ”์ธ๋”ฉํ•˜๋Š” ์ž‘์—…์ด ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์“ฐ๋ ˆ๋“œ์—์„œ ์ผ์–ด๋‚  ์ˆ˜ ์žˆ๊ณ , ์ด๋Š” ์˜ˆ๊ธฐ์น˜ ์•Š์€ ํฌ๋ž˜์‹œ๋ฅผ ๋ฐœ์ƒ์‹œํ‚ฌ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  3. ๊ฒฐ๊ณผ๊ฐ’์ด ๋‘ ๊ฐœ์˜ UI ์ปดํฌ๋„ŒํŠธ(TableView, UILabel)์— ๋ฐ”์ธ๋”ฉ๋˜์–ด ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ๊ฐ๊ฐ์ด ๋”ฐ๋กœ HTTP request๋ฅผ ์š”์ฒญํ•ด ๋ถˆํ•„์š”ํ•œ ์ค‘๋ณต ์š”์ฒญ์ด ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค.

์ด๋ฅผ ํ•ด๊ฒฐํ•œ ์ฝ”๋“œ๋Š” ์•„๋ž˜์™€ ๊ฐ™์Šต๋‹ˆ๋‹ค.

let results = query.rx.text
	.throttle(.milliseconds(300), scheduler: MainScheduler.instance)
	.flatMapLatest { query in
		fetchAutoCompleteItems(query)
			.observeOn(MainScheduler.instance)
			.catchErrorJustReturn([])
	}
	.share(replay: 1)

์˜ˆ์‹œ์—์„œ๋Š” ์ด๋Ÿฐ ๊ณผ์ •์ด ์‰ฌ์šธ ์ˆ˜ ์žˆ์œผ๋‚˜, ์‹ค์ „์—์„œ ํ”„๋กœ์ ํŠธ๊ฐ€ ์ปค์ง์— ๋”ฐ๋ผ ์ด๋Ÿฐ ์‚ฌ์†Œํ•œ ๋ฌธ์ œ์ ๋“ค์„ ๋ฐœ๊ฒฌํ•˜๋Š” ๊ฒƒ์€ ์–ด๋ ค์šธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๋”ฐ๋ผ์„œ RxCocoa๋Š” ์ด๋Ÿฐ ๋ฌธ์ œ๋“ค์„ ํ•ด๊ฒฐํ•œ UI ๋ ˆ์ด์–ด ์ „์šฉ์˜ Trait์„ ์ œ๊ณตํ•˜๋Š”๋ฐ, ๊ทธ๊ฒƒ์ด ๋ฐ”๋กœ Driver์ธ ๊ฒƒ ์ž…๋‹ˆ๋‹ค.

let results = query.rx.text.asDriver()
	.throttle(.milliseconds(300), scheduler: MainScheduler.instance)
	.flatMapLatest { query in
		fetchAutoCompleteItems(query)
			.asDriver(onErrorJustReturn: [])
	}

results
	.map { "\($0.count)" }
	.drive(resultCount.rx.text)
	.disposed(by: self.disposeBag)

results
	.drive(resultsTableView.rx.items(cellIdentifier: "Cell")) { _, result, cell in
		cell.textLabel?.text = "\(result)"
	}
	.disposed(by: self.disposeBag)

์ด ์„ธ ๊ตฐ๋ฐ๋งŒ ์ฃผ๋ชฉํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค!

query.rx.text.asDriver()

asDriver ๋ฉ”์„œ๋“œ๋Š” ControlProperty๋ฅผ Driver๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค.

Driver๋Š” ControlProperty์˜ ๋ชจ๋“  ํ”„๋กœํผํ‹ฐ๋ฅผ ๊ฐ–๊ธฐ ๋•Œ๋ฌธ์— ๋”ฐ๋กœ ์‹ ๊ฒฝ์“ธ ๋ถ€๋ถ„์€ ์—†์ด ๋ณ€ํ™˜๋งŒ ํ•˜๋ฉด ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

.asDriver(onErrorJustReturn: [])

์ด์ „์— ์‚ดํŽด๋ดค๋˜ ๋‹ค์Œ ์„ธ ๊ฐ€์ง€ ์กฐ๊ฑด๋งŒ ๋งŒ์กฑํ•œ๋‹ค๋ฉด, ์–ด๋–ค Observable์ด๋˜ Driver๋กœ ๋ณ€ํ™˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

  • ์—๋Ÿฌ๊ฐ€ ๋ฐฉ์ถœํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค
  • observe๊ฐ€ Main Scheduler์—์„œ ์ด๋ฃจ์–ด์ง‘๋‹ˆ๋‹ค.
  • side effect๋ฅผ ๊ณต์œ ํ•ฉ๋‹ˆ๋‹ค. (.share(replay:, scope:))
let safeSequence = xs
	.observeOn(MainScheduler.instance)
	.catchErrorJustReturn(onErrorJustReturn)
	.share(replay: 1, scope: .whileConnected)
return Driver(raw: safeSequence)

์ •๋ฆฌํ•ด๋ณด๋ฉด ์œ„ ๊ณผ์ •๊ณผ asDriver(onErrorJustReturn: [])์€ ๋™์ผํ•ฉ๋‹ˆ๋‹ค.

.drive()

๋งˆ์ง€๋ง‰์œผ๋กœ bind(to:) ๋Œ€์‹  drive()๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

drive()๋ฅผ ์‚ฌ์šฉํ•จ์œผ๋กœ์„œ UI์— ๋ฐ์ดํ„ฐ๋ฅผ ์•ˆ์ „ํ•˜๊ฒŒ ๋ฐ”์ธ๋”ฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

Signal

๊ตฌ๋…๊ณผ ๋™์‹œ์— ๊ฐ€์žฅ ๋งˆ์ง€๋ง‰ ์ด๋ฒคํŠธ๋ฅผ replayํ•˜์ง€ ์•Š๋Š”๋‹ค๋Š” ์  ์™ธ์—๋Š” Driver์™€ ๋™์ผํ•ฉ๋‹ˆ๋‹ค.

ํ•˜์ง€๋งŒ sequence๋ฅผ ๊ณต์œ ํ•œ๋‹ค๋Š” ์ ์€ ๋ณ€ํ•˜์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์— share ๋ฉ”์„œ๋“œ๋ฅผ ํ†ตํ•ด ์›ํ•  ๋•Œ๋Š” ๊ฐ’์„ ๊ณต์œ ๋ฐ›์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ControlProperty / ControlEvent

ControlProperty

UI ์ปดํฌ๋„ŒํŠธ์˜ ํ”„๋กœํผํ‹ฐ๋ฅผ ๋‚ด์šฉ์œผ๋กœ ๊ฐ–๋Š” Observable / ObservableType ์ž…๋‹ˆ๋‹ค.

  • ์‹คํŒจํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
  • share(replay: 1)
    • Stateful ํ•ฉ๋‹ˆ๋‹ค. (๊ตฌ๋…๊ณผ ๋™์‹œ์— ๋งˆ์ง€๋ง‰ ๊ฐ’์„ ํ•œ ๋ฒˆ ๋ฐฉ์ถœํ•ฉ๋‹ˆ๋‹ค.)
  • ์—๋Ÿฌ๋ฅผ ๋ฐœ์ƒ์‹œํ‚ค์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
  • ๋ฉ”์ธ ์“ฐ๋ ˆ๋“œ์—์„œ ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค.
extension Reactive where Base: UISearchBar {
	public var value: ControlProperty<String?> {
		let source: Observable<String?> = Observable.deferred { [weak searchBar = self.base as UISearchBar] () -> Observable<String?> in
			let text = searchBar?.text
			return (searchBar?.rx.delegate.methodInvoked(#selector(UISearchBarDelegate.searchBar(_:textDidChange:))) ?? Observable.empty())
				.map { a in
					return a[1] as? String
				}
				.startWith(text)
		}

		let bindingObserver = Binder(self.base) { (searchBar, text: String?) in
			searchBar.text = text
		}

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

ControlEvent

UI ์ปดํฌ๋„ŒํŠธ์˜ ์ด๋ฒคํŠธ๋ฅผ ๋‚ด์šฉ์œผ๋กœ ๊ฐ–๋Š” Observable / ObservableType ์ž…๋‹ˆ๋‹ค.

  • ์‹คํŒจํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
  • ๊ตฌ๋…์ด ์ด๋ฃจ์–ด์กŒ์„ ๋•Œ ์ดˆ๊ธฐ๊ฐ’์„ ๋ฐฉ์ถœํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
  • ์—๋Ÿฌ๋ฅผ ๋ฐฉ์ถœํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
  • ๋ฉ”์ธ ์“ฐ๋ ˆ๋“œ์—์„œ ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค.
extension Reactive where Base: UICollectionView {
	public var itemSelected: ControlEvent<IndexPath> {
		let source = delegate.methodInvoked(#selector(UICollectionViewDelegate.collectionView(_:didSelectItemAt:)))
			.map { a in
				return a[1] as! IndexPath
			}

		return ControlEvent(events: source)
	}
}

References

RxSwift Traits