iOS

ReactorKit으로 랜덤 통신 구현하기

GREEN.1229 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