Skip to content

State and Data Flow & Clean Architecture for SwiftUI

Keunna Lee edited this page Oct 3, 2021 · 2 revisions

writer: 이근나
의견/피드백은 issue 에 코멘트로 달아주세요!! 🤓🤓

BBOXX iOS 아키텍쳐

Clean Architecture for SwiftUI

Clean Swift(VIP)를 염두한 SwiftUI를 위한 Clean Architecture 입니다.

State and Data Flow

Components and Roles

AppState

AppState는 사용자의 데이터, 인증 토큰(authentication tokens), 화면 네비게이션 상태(selected tabs, presented sheets) 및 시스템 상태(is active / is backgrounded, etc.)를 포함하여 전체 앱의 상태를 유지합니다. AppState는 다른 계층에 대해 아무것도 모르며 비즈니스 논리를 포함하지 않습니다.

👉🏻 SAMPLE CODE

class AppState: ObservableObject, Equatable {
    @Published var userData = UserData()
    @Published var routing = ViewRouting()
    @Published var system = System()
}

View

View는 SwiftUI의 view 입니다. View가 인스턴스화되면 @Environment, @EnvironmentObject 또는 @ObservedObject 속성을 가진 변수의 SwiftUI 기본 의존성 주입를 통해 AppState 및 Interactor를 받습니다.

사이드 이펙트는 사용자의 액션(탭이나 버튼) 또는 onAppear같은 view lifecycle event로 인해 트리거 되어 Interactor에 전달됩니다.

👉🏻 SAMPLE CODE

struct CountriesList: View {
    
    @EnvironmentObject var appState: AppState
    @Environment(\.interactors) var interactors: InteractorsContainer
    
    var body: some View {
        ...
        .onAppear {
            self.interactors.countriesInteractor.loadCountries()
        }
    }
}

Interactor

Interactor은 특정 View나 View 그룹을 위한 비즈니스 로직을 캡슐화합니다. AppState와 함께 비즈니스 로직 레이어를 이루고 있고, 비즈니스 로직 레이어는 presentation과 외부 리소스에 완전히 독립적인 레이어입니다. Interactor는 상태가 없고 오로지 AppState object만 참조합니다. (AppState는 생성자 파라미터로 넘겨줍니다) Interactor들은 프로토콜을 통해 ‘파사드’화 되어야 합니다. 그렇게 하면 View는 목업 Interactor와 소통하여 테스트를 할 수도 있습니다.

Interactor들은 외부 소스로부터 데이터를 가져오거나 계산을 하는 등의 작업을 수행하라는 요청을 받지만 결과를 직접적으로 반환하지 않습니다. 클로저 처럼. 대신에 결과를 AppState혹은 View에게서 제공받은 Binding에 전달합니다. Binding은 작업수행의 결과가 하나의 View에만 내부적으로 쓰이고 전역 AppState에 속하지 않을 때 사용합니다. 앱 내에서 유지할 필요가 없거나, 다른 화면과 공유할 필요가 없을 때.

👉🏻 SAMPLE CODE

protocol CountriesInteractor {
    func loadCountries()
    func load(countryDetails: Binding<Loadable<Country.Details>>, country: Country)
}

// MARK: - Implemetation

struct RealCountriesInteractor: CountriesInteractor {
    
    let webRepository: CountriesWebRepository
    let appState: AppState
    
    init(webRepository: CountriesWebRepository, appState: AppState) {
        self.webRepository = webRepository
        self.appState = appState
    }

    func loadCountries() {
        appState.userData.countries = .isLoading(last: appState.userData.countries.value)
        weak var weakAppState = appState
        _ = webRepository.loadCountries()
            .sinkToLoadable { weakAppState?.userData.countries = $0 }
    }

    func load(countryDetails: Binding<Loadable<Country.Details>>, country: Country) {
        countryDetails.wrappedValue = .isLoading(last: countryDetails.wrappedValue.value)
        _ = webRepository.loadCountryDetails(country: country)
            .sinkToLoadable { countryDetails.wrappedValue = $0 }
    }
}

Repository

Repository는 데이터를 읽고 쓰는 추상 게이트웨이입니다. 웹 서버 또는 로컬 데이터베이스와 같은 단일 데이터 서비스에 대한 액세스를 제공합니다. 예를 들어 앱이 자체 백엔드, Google Maps API, 로컬 데이터베이스를 쓰는 경우 총 3개의 Repository가 존재합니다: 웹 API 제공용 2개, 데이터베이스 IO용 하나.

Repository는 상태가 없고 AppState에 대한 쓰기 권한이 없습니다. 데이터 작업과 관련된 로직만 포함하고 있기 때문에 View, Interactor에 대해서는 전혀 모릅니다.

실제 Repository는 프로토콜 뒤에 숨겨 Interactor가 목업 Repository와도 소통하여 테스트가 가능하도록 구성해야 합니다.

👉🏻 SAMPLE CODE

protocol CountriesWebRepository: WebRepository {
    func loadCountries() -> AnyPublisher<[Country], Error>
    func loadCountryDetails(country: Country) -> AnyPublisher<Country.Details.Intermediate, Error>
}

// MARK: - Implemetation

struct RealCountriesWebRepository: CountriesWebRepository {
    
    let session: URLSession
    let baseURL: String
    let bgQueue = DispatchQueue(label: "bg_parse_queue")
    
    init(session: URLSession, baseURL: String) {
        self.session = session
        self.baseURL = baseURL
    }
    
    func loadCountries() -> AnyPublisher<[Country], Error> {
        return call(endpoint: API.allCountries)
    }

    func loadCountryDetails(country: Country) -> AnyPublisher<Country.Details, Error> {
        return call(endpoint: API.countryDetails(country))
    }
}

// MARK: - API

extension RealCountriesWebRepository {
    enum API: APICall {
        case allCountries
        case countryDetails(Country)
        
        var path: String { ... }
        var httpMethod: String { ... }
        var headers: [String: String]? { ... }
    }
}

Sample Code와 같이 하는 경우, WebRepository 가 생성자 파라미터로 URLSession을 받기 때문에 커스텀 URLProtocol로 네트워크 콜을 목업하여 테스트하기가 쉽습니다.

🔖 참고 자료

원본(영어)

한국어
SwiftUI를 위한 클린 아키텍처