iyOmSd/Title: RxSwift

[RxSwift] ReactorKit 체험 (Hello ReactorKit)

냄수 2021. 3. 25. 23:39
반응형

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()
    }
}

 

완성!

 

 

사용해본 느낌으로는 구조화된 느낌이 들어서 깔끔해보이고 추적하기 쉬운것 같아요

 

 

 

 

 

 

 

 

반응형