목차

티스토리 뷰

iOS/Swift

[iOS] ReactorKit 적용하기

Assum 2024. 3. 26. 18:26
728x90
반응형

안녕하세요 🐾

iOSMVVM 디자인 패턴은 공식적으로 사용되는 표준이 없어 저는 주로 KickstarterMVVM 디자인 패턴인 Input, Output 프로토콜을 이용한 방법을 사용했었습니다. 때문에 개발자들마다 MVVM 디자인 패턴 구현 방법이 조금씩 달라지곤하는데요.

 

ReactorKitMVVM에서 뷰모델의 역할을 대신해 비즈니스 로직을 처리하고 뷰와 상호작용 하는 일련의 작업 과정을 정형화 된 구조로 제공하는 프레임워크입니다.

 

ReactorKit의 기본 개념 및 사용 방법을 간략히 정리해보았습니다. 혹시나 틀린곳이 있다면 알려주세요!

※ ReactorKit의 사용은 RxSwift에 대한 이해를 바탕으로 합니다.

 

 


 

1. 기본 개념

ReactorKitFluxReactive 프로그래밍의 조합입니다.

간략히 요약하자면, Flux는 앱의 데이터 흐름을 단방향으로 관리하는 패턴이고, Reactive 프로그래밍은 데이터 흐름에 초점을 맞추어 반응형 코드 패턴을 설계합니다.

 

ReactorKit의 기본 구조를 살펴보면 아래와 같습니다.

데이터 흐름을 단방향으로, ViewAction(작업)을 보내고 ReactorState(상태)를 보냅니다.

 

 

 

View사용자 UI 관련 코드, Reactor에는 비즈니스 로직 관련 코드가 들어갑니다.

즉, 로직과 뷰가 분리되어 ReactorView에 대한 의존성이 사라집니다. 

의존성이 낮을수록 테스트 가능한 코드 환경이 되고, 의존성이 높은 부분을 개선하면 프로젝트의 코드 품질이 향상됩니다.

 

 

1.1 View

View는 데이터 흐름에서 데이터를 표시하는 부분이고 ViewController와 Cell은 View로 처리됩니다.

View는 사용자 입력을 Action에 바인딩하고 전달 받을 State를 UI 구성 요소들에 바인딩 합니다.

class ProfileViewController: UIViewController {
    var disposeBag = DisposeBag()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        self.reactor = ProfileViewReactor()
    }

extension ProfileViewController: View {
    func bind(reactor: ProfileViewReactor) {
      // action (View -> Reactor)
      refreshButton.rx.tap.map { Reactor.Action.refresh }
        .bind(to: reactor.action)
        .disposed(by: self.disposeBag)

      // state (Reactor -> View)
      reactor.state.map { $0.isFollowing }
        .bind(to: followButton.rx.isSelected)
        .disposed(by: self.disposeBag)
    }
}

 

 

위 코드블럭처럼 뷰컨트롤러에서 ReactroKitView프토토콜을 상속한 뒤 reactor의 속성을 변경하면 

extension func에서 정의한 bind(reactor:Reactor)메소드가 실행됩니다.

 

 

ViewDefinition을 보면 그 이유가 아래와 같이 쓰여있네요.

 

지금까지 보신 예와 같이,

View계층에는 비즈니스 관련 로직이 없습니다.

오직 Action스트림과 State스트림을 매핑하는 방법만을 정의했습니다.

 

이제 Reactor 레이어에 대하여 알아볼까요?

 

 

 

1.2 Reactor

ReactorView로 보낼State 를 관리하는 계층입니다.

ReactorView에 대한 종속성이 없어 이곳에 들어갈 비즈니스 로직 관련 코드에 대한 테스트가 쉽습니다.

 

다만, 간단한 프로젝트가 아니라면 아래의 이미지처럼 Reactor에서 데이터 처리에 대한 직접적인 코드를 일체 사용하지는 않습니다.

(ex. 인터넷 연결이 끊어진 경우 이를 알리는 Service에서 필요한 데이터 처리 후 Reactor에 알리고 View에 전달)

 

 

Action과 연관된 사이드 이펙트나 API 호출과 같은 비동기 관련 작업들은 Service를 통해 후 처리를 한 뒤,

Reactor에 전달하고 View에 반영합니다.

이 과정에서 ReactorView 레이어와 좀 더 가까운 영역이 되겠네요!

(Service를 이용하는 세부 사용방법은 다른 포스팅에서 소개하겠습니다.)

 

 

다시 소개로 돌아가, Reactor프로토콜은 Action, Mutation, State 세 개의 유형에 대한 정의와 initialState라는 이름을 가진 프로퍼티가 아래와 같이 필요합니다.

class ProfileViewReactor: Reactor {
  // represent user actions
  enum Action {
    case refreshFollowingStatus(Int)
    case follow(Int)
  }

  // represent state changes
  enum Mutation {
    case setFollowing(Bool)
  }

  // represents the current view state
  struct State {
    var isFollowing: Bool = false
  }

  let initialState: State = State()
}

 

Action은 사용자와의 상호작용,

State는 뷰에 표시할 상태를 나타냅니다.

 

 

MutationActionState사이에 들어가는 브릿지 역할로 mutate() - reduce() 메소드의 두 단계를 거쳐 Action스트림을 State스트림으로 변환합니다.

 

 

 

💡 mutate()

Action을 전달받아 Observable<Mutation>을 생성합니다.

func mutate(action: Action) -> Observable<Mutation> {
  switch action {
  case let .refreshFollowingStatus(userID): // receive an action
    return UserAPI.isFollowing(userID) // create an API stream
      .map { (isFollowing: Bool) -> Mutation in
        return Mutation.setFollowing(isFollowing) // convert to Mutation stream
      }

  case let .follow(userID):
    return UserAPI.follow()
      .map { _ -> Mutation in
        return Mutation.setFollowing(true)
      }
  }
}

 

 

💡 recude()

기존의 StateMutation을 참조하여 newStateView에 반환합니다.

func reduce(state: State, mutation: Mutation) -> State {
    var state = state // create a copy of the old state
    
    switch mutation {
    case let .setFollowing(isFollowing):
    state.isFollowing = isFollowing // manipulate the state, creating a new state
    return state // return the new state
    }
}

 

 

💡 transform()

관찰 가능한 다른 스트림Mutation을 결합하는 것을 말합니다. 자세한 내용은 링크 참고

func transform(action: Observable<Action>) -> Observable<Action>
func transform(mutation: Observable<Mutation>) -> Observable<Mutation>
func transform(state: Observable<State>) -> Observable<State>

 

 

2. 설치 및 예제

Cocoapod

target 'AssumReactorKitTest' do
  # Comment the next line if you don't want to use dynamic frameworks
  use_frameworks!

  # Pods for AssumReactorKitTest
  pod 'ReactorKit'
  pod 'RxSwift'
  pod 'RxCocoa'
  pod 'SnapKit'
end

 

프로젝트 디렉토리 - podfile 수정 - pod install

 

예제

ViewController.swift

import UIKit
import ReactorKit
import RxSwift
import RxCocoa
import SnapKit

class ViewController: UIViewController {
    var disposeBag = DisposeBag()
    
    // MARK: - UI
    let increaseButton: UIButton = {
        let button = UIButton()
        button.setTitleColor(.blue, for: .normal)
        button.setTitle("+", for: .normal)
        return button
    }()
    let numberLabel: UILabel = {
        let label = UILabel()
        label.textColor = .blue
        label.textAlignment = .center
        label.text = "0"
        return label
    }()

    // MARK: - Lifecycle
    override func viewDidLoad() {
        super.viewDidLoad()
        
        self.reactor = ViewReactor()
        setupUI()
    }
    
    // MARK: - UI Function
    func setupUI() {
        view.addSubview(increaseButton)
        increaseButton.snp.makeConstraints {
            $0.center.equalToSuperview()
            $0.width.height.equalTo(20)
        }
        view.addSubview(numberLabel)
        numberLabel.snp.makeConstraints {
            $0.top.equalTo(increaseButton.snp.bottom).offset(12)
            $0.left.right.equalToSuperview().inset(40)
            $0.height.equalTo(20)
        }
    }
}

// MARK: - Reactor
extension ViewController: View {
    func bind(reactor: ViewReactor) {
        bindAction(reactor)
        bindState(reactor)
    }
    
    private func bindAction(_ reactor: ViewReactor) {
        increaseButton.rx.tap
            .map { Reactor.Action.increase }
            .bind(to: reactor.action)
            .disposed(by: disposeBag)
    }
    
    private func bindState(_ reactor: ViewReactor) {
        reactor.state
            .map { String($0.value) }
            .distinctUntilChanged()
            .bind(to: numberLabel.rx.text)
            .disposed(by: disposeBag)
    }    
}

 

클래스 내 코드를 보시면, 비즈니스로직 없이 Reactor에 보낼 Action과 전달 받을 State 스트림에 대해서만 매핑만 해놓은 것을 확인하실 수 있습니다.

 

 

ViewReactor.swift

import Foundation
import RxSwift
import RxCocoa
import ReactorKit

class ViewReactor: Reactor {
    let initialState = State()
    
    // 사용자 상호작용 구분
    enum Action {
        case increase
    }
    
    // 사용자 상호작용에 대한 처리 구분, mutate()와 reduce()의 두단계를 거쳐 Action 스트림을 State 스트림으로 변환
    enum Mutation {
        case increaseNumber
    }
    
    // 현재 상태, 뷰에 표시할 상태
    struct State {
        var value = 0
    }
    
    // Action을 전달받아 Observable<Mutation>을 생성
    func mutate(action: Action) -> Observable<Mutation> {
        switch action {
        case.increase:
            return .just(.increaseNumber)
        }
    }
    
    // 기존의 State와 Mutaion을 참조하여 newState를 View에 반환
    func reduce(state: State, mutation: Mutation) -> State {
        var newState = state
        switch mutation {
        case .increaseNumber:
            newState.value += 1
        }
        
        return newState
    }
}

 

Reactor에서는 mutate()reduce()를 거쳐, ViewController에서 들어온 Action 스트림을 State 스트림으로 반환하여

결과를 표시합니다.

 

 

3. 참고

- https://github.com/ReactorKit/ReactorKit

- https://en.wikipedia.org/wiki/Reactive_programming

- https://haruair.github.io/flux/

 

 

 

반응형
댓글
300x250
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/11   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
글 보관함