-
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
'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