ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • ReactorKit으로 랜덤 통신 구현하기
    iOS 2021. 7. 10. 09:30

    안녕하세요. 그린입니다🟢

    이번 포스팅에서는 ReactorKit으로 랜덤한 통신을 구현해보겠습니다🧑🏻‍💻

     

    우선 간략한 기능을 설명드리겠습니다.

    ReactorKit을 이용하여 리액터를 다룰 수 있는 아주 간단한 예제로 통신 시 GET에 ID 인덱스를 넘겨 통신할때 해당 인덱스를

    랜덤하게 뽑고 통신 및 파싱한 후 ID와 타이틀을 UI에 3초마다 자동 갱신되도록 뷰를 업데이트 하는 기능을 가집니다🧑🏻‍💻

    여기 포스팅에서는 필수 파일에 대한 구현들만 간단히 소개하고 아래 제 Git 레포 주소를 남겨두겠습니다!

    더 참고하실 분들을 Git을 참고해주세요👍🏻

     

    1. Model

     1) JSON 구조체

     

    - 통신 후 JSON 데이터를 파싱할 구조체를 생성합니다.

    import Foundation
    
    struct RandomInfo: Decodable {
      let userId: Int
      let id: Int
      let title: String
      let completed: Bool
      
      enum CodingKeys: String, CodingKey {
        case userId, id, title, completed
      }
    }

     2) 통신 및 파싱

    import Foundation
    import Alamofire
    import RxSwift
    
    class FetchRandomInfo: ReactiveCompatible {
      static var shared = FetchRandomInfo()
      
      private init() {}
      
      // 서버 통신할 기본 URL
      let baseURL = "https://jsonplaceholder.typicode.com/todos/"
      
      // 서버 통신으로 데이터 가져와 디코딩 해주는 메서드
      func fetchData(completion: @escaping (_ result: Result<RandomInfo, NSError>) -> Void) {
        let convertURL = baseURL+"\(randomDataIndex())"
        AF.request(convertURL, method: .get, encoding: JSONEncoding.default)
          .responseJSON { response in
            switch response.result {
            case .success(let value):
              do {
                let data = try JSONSerialization.data(withJSONObject: value, options: .prettyPrinted)
                let randomInfo = try JSONDecoder().decode(RandomInfo.self, from: data)
                completion(Result.success(randomInfo))
              } catch {
                completion(Result.failure(decodingError(convertURL: convertURL, responseValue: value, underlying: error)))
              }
            case .failure(let error):
              completion(Result.failure(networkError(convertURL: convertURL, underlying: error)))
            }
          }
      }
      
      // 랜덤한 서버 통신 전달 인덱스 값을 만드는 메서드
      fileprivate func randomDataIndex() -> Int {
        return Int.random(in: 1...100)
      }
    }
    
    // 리액터에서 서버 통신 호출 시 동작하여 싱글 타입으로 반환 해주는 메서드
    extension Reactive where Base == FetchRandomInfo {
      func fetch() -> Single<RandomInfo> {
        return Single.create(subscribe: { single in
          self.base.fetchData(completion: { randomInfo in
            switch randomInfo {
            case let .success(randomInfo):
              single(.success(randomInfo))
            case let .failure(error):
              single(.failure(error))
            }
          })
          return Disposables.create()
        })
      }
    }
    
    // 네트워크 에러 시 호출되는 메서드
    fileprivate func networkError(convertURL: String, underlying: Error) -> NSError {
      return NSError(domain: "FetchRandomInfo", code: 1, userInfo: [
        "identifier": "FetchRandomInfo.networkError",
        "convertURL": convertURL,
        NSUnderlyingErrorKey: underlying,
      ])
    }
    
    // 디코딩 에러 시 호출되는 메서드
    fileprivate func decodingError(convertURL: String, responseValue: Any, underlying: Error) -> NSError {
      return NSError(domain: "FetchRandomInfo", code: 2, userInfo: [
        "identifer": "FetchRandomInfo.decodingError",
        "convertURL": convertURL,
        "responseValue": responseValue,
        NSUnderlyingErrorKey: underlying,
      ])
    }

    - 리액터에서 패치를 호출하면 내부적으로 fetchData 메서드를 호출한다.

    - fetchData 메서드에서 랜덤한 전달값을 만들어주는 randomDataIndex 메서드를 호출하여 인덱스 값을 기본 URL과 합쳐 서버 통신을 한다.

    - 통신 성공 시 JSON 디코딩을 시도하고 실패 시 네트워크 통신 실패 메서드를 실행한다.

    - 디코딩 성공과 실패에 따라 컴플리션 핸들러에서 성공하면 디코딩된 랜덤 정보를 실패하면 디코딩 실패 메서드를 실행하게 한다.

    - 해당 fetchData의 컴플리션 값을 가지고 fetch 메서드에서 성공 실패 케이스를 구분하고 값을 싱글 타입으로 반환해준다.

    - 에러는 NSError로 반환되도록 구성하였으며 도메인과 코드를 지정하고 에러 시 어떤 데이터를 로그로 보여줄지 userInfo에 넣어준다.


    2. View

    - 뷰는 스토리보드에서 아주 아주 간단하게 ID/Title 레이블 두개와 버튼 하나를 두었습니다.

    - 그 후 ViewController에서 ReactorKit을 임포트하고 스토리보드뷰를 채택하여 리액트와 바인딩될 수 있게 구현하였습니다.

    import UIKit
    import ReactorKit
    import RxCocoa
    
    class RandomIDViewController: UIViewController, StoryboardView {
      var disposeBag = DisposeBag()
      let reactor = RandomIDReactor()
      
      // ID, Text, Button
      @IBOutlet weak var idLabel: UILabel!
      @IBOutlet weak var titleLabel: UILabel!
      @IBOutlet weak var changeButton: UIButton!
      
      // 뷰 나타날때 리액터의 viewDidAppear 액션과 바인딩
      override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(true)
        bind(reactor: reactor)
        reactor.action.onNext(.viewDidAppear)
      }
      
      func bind(reactor: RandomIDReactor) {
        // 버튼 누를때 발생하는 액션 바인딩
        self.changeButton.rx.tap
          .map { .clickButton }
          .bind(to: reactor.action)
          .disposed(by: self.disposeBag)
        
        reactor.state.map(\.displayIDText)
          .distinctUntilChanged()
          .bind(to: self.idLabel.rx.text)
          .disposed(by: self.disposeBag)
        
        reactor.state.map(\.displayTitleText)
          .distinctUntilChanged()
          .bind(to: self.titleLabel.rx.text)
          .disposed(by: self.disposeBag)
      }
    }

    - 뷰가 나타날때 3초마다 자동으로 ID/Title이 자동 갱신 될 수 있도록 리액터의 viewDidAppear 액션과 바인딩 해줍니다.

    - 버튼이 클릭될때도 바뀔 수 있도록 액션 바인딩 해줍니다.

    - 레이블의 값 변경을 위해 리액터의 state와 바인딩 해줍니다.

     

    3. SceneDelegate

    - 2의 뷰컨에 생성하는 리액터를 주입할 수 있도록 씬딜리게이트의 세션 연결 설정에서 선언해줍니다.

    import UIKit
    
    class SceneDelegate: UIResponder, UIWindowSceneDelegate {
      
      var window: UIWindow?
      
      
      func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
        // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
        // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
        guard let _ = (scene as? UIWindowScene) else { return }
        
        let viewController = self.window?.rootViewController as! RandomIDViewController
        viewController.reactor = RandomIDReactor()
      }
      ...
     }

     

    4. ViewModel

    import Foundation
    import ReactorKit
    
    final class RandomIDReactor: Reactor {
      // 버튼 눌렀을때, 화면 나타났을때 액션
      enum Action {
        case clickButton
        case viewDidAppear
      }
      
      enum Mutation {
        case fetchResult(Result<RandomInfo, NSError>)
      }
      
      // 뷰 상태 업데이트하기 위한 상태 값
      struct State {
        // 통신 후 정보값
        var fetchResult: Result<RandomInfo, NSError>?
        
        // ID 상태 값
        var displayIDText: String? {
          return fetchResult.flatMap({ fetchResult in
            switch fetchResult {
            case let .success(randomInfo):
              return "\(randomInfo.id)"
            default:
              return nil
            }
          })
        }
        
        // Title 상태 값
        var displayTitleText: String? {
          return fetchResult.flatMap({ fetchResult in
            switch fetchResult {
            case let .success(randomInfo):
              return "\(randomInfo.title)"
            default:
              return nil
            }
          })
        }
      }
      
      let initialState: State = .init()
    }
    
    extension RandomIDReactor {
      // 액션 발생 시 mutate 메서드
      func mutate(action: Action) -> Observable<Mutation> {
        switch action {
        case .clickButton:
          return FetchRandomInfo.shared.rx.fetch()
            .asObservable()
            .materialize()
            .map({ event -> Event<Result<RandomInfo, NSError>> in
              switch event {
              case .completed:
                return .completed
              case let .error(error):
                return .next(Result.failure(error as NSError))
              case let .next(randomInfo):
                return .next(Result.success(randomInfo))
              }
            })
            .dematerialize()
            .map(Mutation.fetchResult)
          
        case .viewDidAppear:
          return Observable<Int>
            .interval(RxTimeInterval.seconds(3), scheduler: MainScheduler.asyncInstance)
            .flatMapLatest { _ in
              FetchRandomInfo.shared.rx.fetch().asObservable()
            }
            .materialize()
            .map({ event -> Event<Result<RandomInfo, NSError>> in
              switch event {
              case .completed:
                return .completed
              case let .error(error):
                return .next(Result.failure(error as NSError))
              case let .next(randomInfo):
                return .next(Result.success(randomInfo))
              }
            })
            .dematerialize()
            .map(Mutation.fetchResult)
        }
      }
      
      // 뷰 업데이트
      func reduce(state: State, mutation: Mutation) -> State {
        var state = state
        
        switch mutation {
        case .fetchResult(let result):
          state.fetchResult = result
        }
        return state
      }
    }

    - state 구조체에서 flatMap을 통해 상태값의 배열 속 RandomInfo 정보를 추출하여 분기를 태워 뷰에 연결됨

    - 버튼 클릭과 뷰가 나타났을때의 액션 2가지는 다 동일하나 viewDidAppear에서는 interval을 3초로 주고 스케쥴러는 뷰의 반영되는것임으로 메인 스케쥴러로 설정하여 3초마다 동작을 취해 뷰가 바뀌도록 구성하였다.

    - 3초마다 flatMapLatest를 통해 서버 통신을 하여 데이터를 받아와 asObservable을 거쳐 싱글 타입을 옵저버블 타입으로 변경한다.

    - 현재 타입이 다름으로 materialize를 통해 옵저버블<옵저버블> 타입으로 감싸준다.

    - 이벤트에 따라 케이스에 맞는 처리를 한고 dematerialize를 통해 감싸줬던 이벤트 타입을 다시 원래대로 변경한다.

    - 마지막으로 뷰 업데이트 reduce를 거치면 3초마다 통신하여 뷰가 변경된다.

     

    [더 자세히 알아보기]

    https://github.com/GREENOVER/randomID_Reactor

     

    GREENOVER/randomID_Reactor

    Random Data Parsing Reactor. Contribute to GREENOVER/randomID_Reactor development by creating an account on GitHub.

    github.com

     

    'iOS' 카테고리의 다른 글

    DI & Swinject  (0) 2021.08.03
    Test Doubles - fake, stub, mock  (0) 2021.07.22
    Run Loop  (0) 2021.06.01
    CI / CD  (0) 2021.04.29
    Machine Learning  (0) 2021.04.20
Designed by Tistory.