TCA

Composable Architecture로 랜덤 통신 구현하기

GREEN.1229 2021. 7. 17. 15:32

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

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

 

뷰는 SwiftUI를 통해 간단히 구현하였습니다.

 

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

Composable Architecture를 이용하여 뷰의 상태를 이벤트 흐름에 따라 다룰 수 있는

아주 간단한 예제로 통신 시 GET에 ID 인덱스를 넘겨 통신할때 해당 인덱스를

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

또한 수동/자동 변경 버튼을 두어 해당 조건에 따라 통신되도록 구현합니다.

(이전에 ReactorKit으로 랜덤 통신을 구현한 스펙과 동일합니다.)

 

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

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

 

1. AppView

import SwiftUI
import ComposableArchitecture
import Combine

struct AppView: View {
  var store: Store<AppState, AppAction>
  
  var body: some View {
    WithViewStore(self.store) { viewStore in
      VStack {
        Text(viewStore.displayIDText ?? "ID")
        Text(viewStore.displayTitleText ?? "TITLE")
        Button("수동 변경", action: { viewStore.send(.clickButton)
        })
        Button("자동 변경", action: { viewStore.send(.viewDidAppear)
        })
      }
      .onAppear(perform: {
        viewStore.send(.viewDidAppear)
      })
    }
  }
}

// 미리보기 뷰
struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    AppView(store: Store<AppState, AppAction>.init(initialState: AppState.init(), reducer: appReducer, environment: AppEnvironment(fetch: FetchRandomInfo(), mainQueue: .main)))
  }
}

 - AppView로 AppCore를 통해 정의된 Store를 가져옴

 - 해당 뷰에 스토어를 가져와 스토어의 상태값을 사용하도록 설정

 - 액션에 따라 스토어에 해당하는 액션을 보내도록 설정

 - 뷰 나타날 시에 대한 액션 설정

 - 미리보기 뷰 초기화 시 해당 스토어를 초기화해줘야함

 

2. 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 Combine

class FetchRandomInfo {
  static var shared = FetchRandomInfo()
  
  public 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)))
        }
      }
  }
  
  // 리듀서에서 서버 통신 시 호출되는 메서드로 AnyPublisher 타입으로 반환되는 메서드
  func fetch() -> AnyPublisher<Result<RandomInfo, NSError>, Never> {
    return AnyPublisher<Result<RandomInfo, NSError>, Never>.create({ [unowned self] subscriber in
      self.fetchData(completion: { randomInfo in
        subscriber.send(randomInfo)
        subscriber.send(completion: .finished)
      })
      return AnyCancellable({})
    })
  }
  
  // 랜덤한 서버통신을 위해 랜덤 인덱스 값을 추출하는 메서드
  fileprivate func randomDataIndex() -> Int {
    return Int.random(in: 1...100)
  }
}

// 네트워크 통신 실패 시 호출되는 메서드
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에 넣어준다.

 

3. AppDelegate

import SwiftUI
import CombineExt
import ComposableArchitecture

@main
struct randomID_ComposableApp: App {
  static let fetchRandomInfo = FetchRandomInfo()
  
  var body: some Scene {
    WindowGroup {
      AppView(store: Store<AppState, AppAction>.init(initialState: AppState.init(), reducer: appReducer, environment: AppEnvironment(fetch: Self.fetchRandomInfo, mainQueue: .main)))
    }
  }
}

 - 앱 초기 설정 시 스토어 초기 구현 필요

 

4. AppCore

import SwiftUI
import Combine
import ComposableArchitecture

// 뷰 상태 정의
struct AppState: Equatable {
  var fetchResult: Result<RandomInfo, NSError>?
  
  var displayIDText: String? {
    return fetchResult.flatMap({ fetchResult in
      switch fetchResult {
      case let .success(randomInfo):
        return "\(randomInfo.id)"
      default:
        return nil
      }
    })
  }
  
  var displayTitleText: String? {
    return fetchResult.flatMap({ fetchResult in
      switch fetchResult {
      case let .success(randomInfo):
        return "\(randomInfo.title)"
      default:
        return nil
      }
    })
  }
}

// 뷰에 대한 액션 정의
enum AppAction {
  case clickButton
  case viewDidAppear
  case fetchResult(Result<RandomInfo, NSError>)
}

// 사이드이펙트에 대한 처리 정의
struct AppEnvironment {
  var fetch: FetchRandomInfo
  var mainQueue: AnySchedulerOf<DispatchQueue>
}

// 리액터의 DisposeBag과 같은 개념을 사용할 구조체 정의
struct TimerId: Hashable { }

// 액션 -> 상태를 변경해줄 리듀서 정의
let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, action, environment in
  switch action {
  // 수동 버튼 클릭 시 
  case .clickButton:
    return .concatenate([
      Effect(value: .stopRandomText),
      environment.fetch.fetch().map(AppAction.fetchResult).eraseToAnyPublisher()
      .eraseToEffect()
    ])
  // 자동 버튼 클릭 혹은 뷰 초기에 자동으로 나올 시
  case .viewDidAppear:
    return Effect.timer(id: TimerId(), every: .seconds(3), on: DispatchQueue.main.eraseToAnyScheduler())
      .flatMap({ _ in environment.fetch.fetch() })
      .map(AppAction.fetchResult)
      .eraseToEffect()
  // 뷰 상태값 변경
  case let .fetchResult(result):
    state.fetchResult = result
    return.none
  // 자동 변경 멈춤
  case .stopRandomText:
    return Effect.cancel(id: TimerId())
  }
}

- 컴포저블 아키텍쳐로 구현할 시 세가지를 정의한다

 1) State: 뷰 상태 정의로 Equatable 프로토콜을 채택해 이전 상태와 비교함

 2) Action: 뷰에 대한 사용자 인터렉션에 대한 케이스 정의

 3) Environment: 사이드 이펙트와 같은 순수함수가 아닌 처리를 해줌

- 리액터의 DisposeBag과 같은 개념으로 메모리 관리를 위해 별도의 구조체를 만들어줄 수도 있음

- Reducer에서 리액터의 mutate, reduce의 역할을 한번에 할 수 있음

- 어떤 리듀서를 사용할지 선언하고 액션에 따라 분류함

 1) clickButton: 버튼이 눌릴때 두가지 Effect를 반환해주기 위해 concatenate를 사용한다.

 concatenate는 들어온 Effect를 순차적으로 처리하는 메서드로 먼저 랜덤 통신을 멈추고 수동 통신을 하도록 한다.

 eraseToAnyPublisher와 eraseToEffect를 사용한 이유는 fetch()를 호출하면 해당 반환값이 Effect<AppAction, Never>가 아니고

 AnyPublisher타입이다 그리고 매핑을 해주면서 더 감싸짐으로 우선 하나의 AnyPublisher타입으로 맞춰주고 마지막에 Effect타입으로

 변경해주기 위함이다. 

 2) viewDidAppear: 뷰가 나타나거나 자동 통신 버튼 클릭 시 호출되는 메서드로 이펙트의 타이머 메서드를 사용해 시간을 설정해준다.

 3) fetchResult: 상태값을 변경해준다.

 4) stopRandomText: 자동 통신을 캔슬해준다.

 

[더 자세히 알아보기]

https://github.com/GREENOVER/randomID_Composable

 

GREENOVER/randomID_Composable

Random & Auto,Manual Change ID/Title Network with Composable Architecture - GREENOVER/randomID_Composable

github.com