ReactorKit으로 랜덤 통신 구현하기
안녕하세요. 그린입니다🟢
이번 포스팅에서는 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