Rx를 사용하면서 MVVM을 쓰게되는데
Observable변수가 많아지게되면
상태를 나타내는 변수와 행동을 나타내는 변수의 구분이 잘 안가서 헷갈리고 의도를 파악하기 어려워서
쓰이는곳을 타고타고 찾아가서 직접 확인해야하는 경우가 생기죠
관리가 쉽지않다는것을 느꼈어요
이 문제점을 ReactorKit이 잡아주고 깔끔해진다고 해서 한번 써보려고 합니다!
간단하게 개념부터 읽고 가려해요
아키텍쳐의 모습이에요
View
뷰컨트롤러 셀도 View에 포함
View레이어에는 비지니스 로직이없고
입력을 action스트림에 바인딩하고
뷰의 state를 각 UI컴포넌트에 바인딩하는 역할
Reactor(ViewModel)
뷰의 상태를 관리하고 UI와는 독립된 계층
모든 뷰는 각각 1:1대응되는 reactor를 가지고 있음
뷰에 의존하지않아서 테스트쉽다
Action, Mutation, State타입을 정의해줘야 하는 곳
initialState를 정의해줘야하는 곳
Action
사용자의 입력 및 상호작용
State
뷰의 상태
Mutation
Action과 State사이의 다리역할
내부적으로 mutate()함수와 reduce()함수를 통해
action스트림을 state스트림으로 변환해주는 역할을하는 계층
.
.
.
MVVM에서 관리가 어려웠던 부분을
Action, State, Mutation타입으로 관리하는 것 같네요!
코딩
github.com/ReactorKit/ReactorKit
pod을이용해 리액터킷을 받아오고
또 필요한 것이 있다면 추가해주세요
저는
SnapKit, RxCocoa를 추가적으로 사용했어요
하게 될 프로젝트는
버튼을 눌렀을 때 이미지 다운을 해서 이미지뷰에 뿌려주는 로직이 전부인 간단한 프로젝트에요
프로젝트를 만들고!
Reactor 프로토콜을 준수한 클래스 파일을 하나 생성해볼게요
final class MainReactor: Reactor {
enum Action {
}
enum Mutation {
}
struct State {
}
let initialState: State
init() {
self.initialState = State()
}
}
이 리액터를
원하는 뷰에 연결해줘야해요
아래와같이 View 프로토콜을 준수하고있다면
그 뷰에는 reactor라는 프로퍼티가 생기고
뷰 외부에서 방금 만든 리액터 클래스를 넣어줘야해요
view.reactor = FooReactor() 이런 식으로말이죠
final class ViewController: UIViewController, View {}
// 스토리보드 사용시
final class ViewController: UIViewController, StoryboardView {}
뷰 메인이 시작화면이라면
AppDelegate 또는 SceneDelegate에서 추가해줘야겠죠?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else { return }
let window = UIWindow(windowScene: windowScene)
let vc = ViewController()
vc.view.backgroundColor = .white
window.rootViewController = vc
vc.reactor = MainReactor()
self.window = window
window.makeKeyAndVisible()
}
뷰컨트롤러에 bind(reactor:)라는 함수를 추가를 해줘야해요(따로 호출을 안해도 되요)
불리는 시점은 뷰의 reactor의 프로퍼티가 변경되면 호출되요(AppDelegate에서 넣어줄때 호출)
스토리보드사용시에는 뷰가 로드된 이후(viewDidLoad) 수행되요
reactor프로퍼티에 뷰에 넣어준 Reactor타입 클래스타입을 써주면
에러가 사라질거에요
여기서는 MainReactor에요
import UIKit
import ReactorKit
import RxSwift
import RxCocoa
import SnapKit
final class ViewController: UIViewController, View {
let imageView = CustomImageView()
let button = UIButton(type: .system)
var disposeBag = DisposeBag()
func bind(reactor: MainReactor) {
}
}
여기까지 초기셋팅이 끝난거같네요!
이제 기능을 정의해볼까요
1. 버튼 클릭시 어떤URL로 이미지로딩
2. 이미지 다운시 이미지뷰에 이미지 노출
3. 이미지 다운중 표시
이 기능을 토대로 Reactor를
final class MainReactor: Reactor {
enum Action {
case imageLoadButtonTap(index: Int) // 로딩버튼 클릭
}
enum Mutation {
case setImage(image: String) // 이미지URL 설정
case setLoading(Bool) // 로딩중 설정
}
struct State {
var image: String? // 이미지URL
var isLoading: Bool = false // 로딩중상태
}
let initialState: State
init() {
self.initialState = State(image: nil, isLoading: false)
}
}
이렇게 정의해봤어요
비지니스 로직을 구현해볼게요
private let imageURLs: [String] = [] // 임의의 이미지링크
// Action을 바탕으로 Observable생성, 비동기 연산이나 API호출
func mutate(action: Action) -> Observable<Mutation> {
switch action {
// 통신시작 -- 이미지URL받아옴 -- 통신끝 -->
case let .imageLoadButtonTap(index):
return Observable.concat([
Observable.just(Mutation.setLoading(true)), // 로딩시작
Observable.just(Mutation.setImage(image: imageURLs[index])).delay(.seconds(1), scheduler: MainScheduler.instance), // 통신으로 이미지URL받아온 경우
Observable.just(Mutation.setLoading(false)) // 로딩끝
])
}
}
// Mutation을 바탕으로 새로운 State생성
func reduce(state: State, mutation: Mutation) -> State {
var state = state
switch mutation {
case let .setImage(image):
state.image = image
case let .setLoading(isLoading):
state.isLoading = isLoading
}
return state
}
mutate()에서
버튼을 클릭할때 Action으로
로딩표시를 나타내고
가상의 통신을통해 이미지URL을 받아오는 것!
이 두가지 작업을 해주는 Observable을 만들어줬어요
그리고 생성된 Mutation을 바탕으로
reduce()에서
이전 State를 수정해서
새로운 State를 만들어주는 거죠
로직을 정의했으니
이제 뷰랑 연결해봐야겟죠?
뷰컨트롤러의 bind(reactor: ) 부분이에요
func bind(reactor: MainReactor) {
// MARK: - Action
// 버튼클릭시 랜덤인덱스를 포함해서 Action전달
button.rx.tap
.map { Reactor.Action.imageLoadButtonTap(index: Int.random(in: 0...4)) }
.bind(to: reactor.action)
.disposed(by: disposeBag)
// MARK: - State
// State에 이미지URL이 변경되면 ImageView에 설정
reactor.state
.map { $0.image ?? "" }
.distinctUntilChanged()
.subscribe(onNext: { self.imageView.loadImage(urlString: $0) })
.disposed(by: disposeBag)
// 로딩중 표시
reactor.state
.map { $0.isLoading }
.bind(to: indicator.rx.isAnimating)
.disposed(by: disposeBag)
}
action을 만들어줘서 reactor.action에 바인딩해주면
해당 action이 mutate함수로 들어가고 reduce가 실행되고 나온 State를 가지고있을거구요
그 State를 reactor.state를 이용해서 Observable로 접근할 수 있어요
어떻게 동작하나 로그를 찍어봤어요
mutate(action:)
reduce(state:mutation:)
reduce(state:mutation:)
reduce(state:mutation:)
Action으로 버튼을 클릭하면
mutate가 실행되고
거기서 3개의 옵저버블을 반들어서 던져주는데
reduce가 이 스트림을 구독하는거 같아요
스트림이 내려올 때마다 실행되네요
딜레이를 걸면
reduce가 하나 실행되고 딜레이 후에 나머지 2개가 실행되는걸 볼 수 있어요
(여기서 딜레이는 통신시간을 가정해서 임의로 추가했습니다)
좀 더 어려운 것을 해보려고해요
지금은 통신할때 인디케이터가 돌아가고 통신이끝나면 인디케이터가 끝났는데
통신할때 인디케이터가 돌아가고
이미지URL을 가져와서 이미지를 다운완료하면 그때 인디케이터가 꺼지도록 말이죠!
Action에 동작을 하나더 추가해볼거에요
enum Action {
case imageLoadButtonTap(index: Int) // 로딩버튼 클릭
case finishImageLoad // 이미지 완료상태
}
로딩 끝나는 로직을 방금 추가한 action이 일어나면 실행되도록 넣어줄거에요
func mutate(action: Action) -> Observable<Mutation> {
print(#function)
switch action {
// 통신시작 -- 이미지URL받아옴 -- 통신끝 -->
case let .imageLoadButtonTap(index):
return Observable.concat([
Observable.just(Mutation.setLoading(true)), // 로딩시작
Observable.just(Mutation.setImage(image: imageURLs[index])), // 통신으로 이미지URL 받아온 경우를 가정하여 1초 딜레이
])
case .finishImageLoad:
return Observable.just(Mutation.setLoading(false)) // 로딩끝
}
}
이미지뷰를 새로 만들어줬구요
자세히는 안보셔두되요
이부분만 보면 되고 아래는 이미지뷰의 전체코드에요
통신에 성공해서 이미지에 data를 넣어주고
해당 이미지뷰를 가지고있는 뷰컨트롤러의 Reactor에 Action을 전달한 경우에요
잘 동작하네요!
class CustomImageView: UIImageView {
var url: String?
var task: URLSessionDataTask?
unowned var parentVC: ViewController!
var disposeBag = DisposeBag()
func loadImage(urlString: String) {
url = urlString
cancel()
if let urlStringData = urlString.data(using: .utf8),
let url = URL(dataRepresentation: urlStringData, relativeTo: nil) {
task = URLSession.shared.dataTask(with: url, completionHandler: { (data, response, error) in
if let data = data {
if urlString == self.url {
// 도중 변경되지 않은 이미지만 표시
DispatchQueue.main.async { [weak self] in
self?.image = UIImage(data: data)
if let reactor = self?.parentVC.reactor {
Observable.just(MainReactor.Action.finishImageLoad)
.bind(to: reactor.action)
.disposed(by: self!.disposeBag)
}
}
}
}
})
task?.resume()
} else {
print("error")
}
}
func cancel() {
print(#function)
task?.cancel()
}
}
완성!
사용해본 느낌으로는 구조화된 느낌이 들어서 깔끔해보이고 추적하기 쉬운것 같아요
'iyOmSd > Title: RxSwift' 카테고리의 다른 글
[RxSwift] RxDataSource TableView Cell타입별 그리기 (0) | 2021.09.10 |
---|---|
[RxSwift] Filtering Operators (0) | 2021.09.08 |
[RxSwift] Observable? Driver? Relay? 알아보기 (0) | 2020.10.28 |
[RxSwift] bind, subscribe, drive (1) | 2020.10.03 |
[RxSwift] Observable, Subject, Relay (0) | 2020.06.27 |