HelloCleanArchitectureWithSwiftUI

CleanArchitecture for SwiftUI with Combine, Concurrency

개요

Clean Architecture를 SwiftUI와 Combine을 사용한 iOS 프로젝트에 적용한 예제

image

Layer와 Data Flow

먼저 역할별 레이어들부터 알아보자면 다음과 같다.

  • Presentation Layer: UI 관련 레이어
  • Domain Layer: 비즈니스 룰과 로직 담당 레이어
  • Data Layer: 원격/로컬등 외부에서 데이터를 가져오는 레이어

image

  • 각 레이어들의 Dependency 방향은 모두 원밖에서 원안쪽으로 향하고 있음
  • UI를 담당하는 Presentation Layer는 MVVM 패턴으로 구현됨

각 레이어의 데이터 흐름은 다음과 같다.

image

  • Domain Layer에서 Data Layer를 실행 시킬 수 있는 이유는 Dependency Inversion 으로 구현되었기 때문

Dependency Inversion 이란?

각 모듈간의 의존성을 분리시키기 위해 추상화된 인터페이스만 제공하고 의존성은 외부에서 주입(Dependency Injection)시킴

프로젝트 구성 (Swift Package Manager)

Clean Architecture의 각 Layer 별 의존성을 구현하기 위해 SPM을 사용하여 프로젝트를 구성한다.

  • 로컬 패키지를 하나 추가하고 폴더 구조를 다음과 같이 구성한다.

    image

  • LayerPackage/Package.swift 에서 다음과 같이 Dependency를 줄 수 있음

import PackageDescription

let package = Package(
    name: "LayerPackage",
    platforms: [.iOS(.v15), .macOS("12")],
    products: [
        .library(
            name: "LayerPackage",
            targets: ["DataLayer", "DomainLayer", "PresentationLayer"]),
        
    ],
    dependencies: [
    ],
    targets: [
        
        //MARK: - Data Layer
        // Dependency Inversion : UseCase(DomainLayer) <- Repository <-> DataSource
        .target(
            name: "DataLayer",
            dependencies: ["DomainLayer"]),
        
        //MARK: - Domain Layer
        .target(
            name: "DomainLayer",
            dependencies: []),
        
        //MARK: - Presentation Layer (MVVM)
        // Dependency : View -> ViewModel -> Model(DomainLayer)
        .target(
            name: "PresentationLayer",
            dependencies: ["DomainLayer"]),
                        
        //MARK: - Tests
        .testTarget(
            name: "DataLayerTests",
            dependencies: ["DataLayer"]),
        
        .testTarget(
            name: "DomainLayerTests",
            dependencies: ["DomainLayer"]),
    
        .testTarget(
            name: "PresentationLayerTests",
            dependencies: ["PresentationLayer"]),
    ]
)

Domain Layer 구현

  • 원의 가장 내부 계층이며 핵심 기능을 담당하는 데이터 구조
  • 상위 계층에 의존성을 갖고 있지 않음으로 독립적으로 수행 가능해야 함

public struct ServiceModel: Identifiable {
    public var id: Int64 = 0
    public var otpCode: String?
    public var serviceName: String?
    public var additinalInfo: String?
    public var period: Int
    
    public init(id: Int64 = 0,
                otpCode: String? = nil,
                serviceName: String? = nil,
                additinalInfo: String? = nil,
                period: Int = 30) {
        self.id = id
        self.otpCode = otpCode
        self.serviceName = serviceName
        self.additinalInfo = additinalInfo
        self.period = period
    }
}
  • Data Layer에서 구현될 Repository에 대한 인터페이스를 추상화 함으로써 Dependency Inversion 구현을 가능하도록 함

import Combine

public protocol ServiceRepositoryInterface {
    func insertService(value: InsertServiceRequestValue) -> AnyPublisher<ServiceModel, Error>
    func fetchServiceList() -> AnyPublisher<[ServiceModel], Never>
}
  • 비즈니스 로직에 대한 각 UseCase를 구현
  • ServiceUseCase는 associatedtype을 활용한 UseCase 프로토콜

protocol ServiceUseCase {
    associatedtype RequestValue
    associatedtype ResponseValue
    var repository: ServiceRepositoryInterface { get }
    
    init(repository: ServiceRepositoryInterface)
    func execute(value: RequestValue) -> ResponseValue
}

public struct FetchServiceListUseCase: ServiceUseCase {
    typealias RequestValue = Void
    typealias ResponseValue = AnyPublisher<[ServiceModel], Never>
    let repository: ServiceRepositoryInterface
    
    public init(repository: ServiceRepositoryInterface) {
        self.repository = repository
    }
    
    public func execute(value: Void) -> AnyPublisher<[ServiceModel], Never> {
        return repository.fetchServiceList()
    }
}

public struct InsertServiceRequestValue {
    public let serviceName: String?
    public let secretKey: String?
    public let additionalInfo: String?
    
    public init(serviceName: String? = nil,
                secretKey: String? = nil,
                additionalInfo: String? = nil) {
        self.serviceName = serviceName
        self.secretKey = secretKey
        self.additionalInfo = additionalInfo
    }
}

public struct InsertServiceUseCase: ServiceUseCase {
    typealias RequestValue = InsertServiceRequestValue
    typealias ResponseValue = AnyPublisher<ServiceModel, Error>
    let repository: ServiceRepositoryInterface
    
    public init(repository: ServiceRepositoryInterface) {
        self.repository = repository
    }
    
    public func execute(value: InsertServiceRequestValue) -> AnyPublisher<ServiceModel, Error> {
        return repository.insertService(value: value)
    }
}

Presentation Layer 구현

  • UI 를 담당하는 Layer
  • MVVM 패턴으로 구현
  • View와 ViewModel 사이는 Combine으로 Data Binding 처리

import Foundation
import Combine
import DomainLayer

public protocol TokenViewModelInput {
    func executeFetchList()
    func executeInsertService(serviceName: String?,
                              secretKey: String?,
                              additionalInfo: String?)
}

public protocol TokenViewModelOutput {
    var services: [ServiceModel] { get }
}

public final class TokenViewModel: ObservableObject, TokenViewModelInput, TokenViewModelOutput {
    @Published public var services = [ServiceModel]()
    
    private let fetchListUseCase: FetchServiceListUseCase?
    private let insertServiceUseCase: InsertServiceUseCase?
    
    private var bag = Set<AnyCancellable>()
    
    public init(fetchListUseCase: FetchServiceListUseCase? = nil,
                insertServiceUseCase: InsertServiceUseCase? = nil) {
        self.fetchListUseCase = fetchListUseCase
        self.insertServiceUseCase = insertServiceUseCase
    }
    
    public func executeFetchList() {
        self.fetchListUseCase?.execute(value: ())
            .assign(to: \.services, on: self)
            .store(in: &bag)
    }
    
    public func executeInsertService(serviceName: String?,
                                     secretKey: String?,
                                     additionalInfo: String?) {
        
        let value = InsertServiceRequestValue(serviceName: serviceName,
                                              secretKey: secretKey,
                                              additionalInfo: additionalInfo)
        self.insertServiceUseCase?.execute(value: value)
            .sink(receiveCompletion: { completion in
                switch completion {
                case .finished:
                    break
                case .failure(let error):
                    print(error.localizedDescription)
                    break
                }
            }, receiveValue: { service in
                self.services.append(service)
            })
            .store(in: &bag)
    }
}
  • ObservableObject로 구현
  • Domain Layer에 대한 의존성

List로 service를 보여줄 TokenView 작성

import SwiftUI
import DomainLayer

public struct TokenView: View {
    //1
    @ObservedObject var viewModel: TokenViewModel
    
    public init(viewModel: TokenViewModel) {
        self.viewModel = viewModel
    }
    
    public var body: some View {
        NavigationView {
            List {
                //1
                ForEach(self.viewModel.services) { service in
                    VStack(alignment: .leading) {
                        Text(service.serviceName ?? "")
                        Text(service.otpCode ?? "")
                            .font(.title)
                            .bold()
                        Text(service.additinalInfo ?? "")
                    }
                    .padding()
                }
            }
            .navigationTitle("Tokens")
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button("Insert") {
                        //2
                        self.viewModel.executeInsertService(serviceName: "Token",
                                                            secretKey: "123",
                                                            additionalInfo: "[email protected]")
                    }
                }
            }
        }
        .onAppear {
            //3
            self.viewModel.executeFetchList()
        }
    }
}
  1. ObservedObject로 선언된 ViewModel 내의 데이터가 업데이트되면 화면이 갱신됨
  2. Insert 버튼 누르면 ViewModel의 insert UseCase를 실행
  3. 화면이 보일때 ViewModel의 fetch list UseCase를 실행

Data Layer 구현

  • DB, Network 등 내/외부 데이터를 사용하는 Layer
  • DataSource는 비동기로 동작하기 위해 Concurrency 로 구현
  • mocked data로 구현, data race를 방지하기 위해 actor 사용

import Foundation
import DomainLayer

public protocol ServiceDataSourceInterface {
    func insertService(value: InsertServiceRequestValue) async throws -> ServiceModel
    func fetchServiceList() async -> [ServiceModel]
}

public final actor ServiceMockDataSource {
    // 테스트 데이터
    var mockData: [ServiceModel] = [
        ServiceModel(id: 0, otpCode: "123 123", serviceName: "Google", additinalInfo: "[email protected]"),
        ServiceModel(id: 1, otpCode: "456 456", serviceName: "Github", additinalInfo: "[email protected]"),
        ServiceModel(id: 2, otpCode: "789 789", serviceName: "Amazon", additinalInfo: "[email protected]")
    ]
    
    public init() {}
}

extension ServiceMockDataSource: ServiceDataSourceInterface {
    
    public func insertService(value: InsertServiceRequestValue) async throws -> ServiceModel {
        guard let serviceName = value.serviceName else { throw ServiceError.unknown }
        
        let insertData = ServiceModel(id: Int64.random(in: 0..<Int64.max),
                                      otpCode: "123 456",
                                      serviceName: serviceName,
                                      additinalInfo: value.additionalInfo ?? "")
        
        self.mockData.append(insertData)
        
        return insertData
    }
    
    public func fetchServiceList() async -> [ServiceModel] {
        return mockData
    }

}
  • Combine operator에서 concurrency 호출을 위해 Future를 래핑하여 사용

import Combine

extension Publisher {
    func asyncMap<T>(
        _ transform: @escaping (Output) async -> T
    ) -> Publishers.FlatMap<Future<T, Never>, Self> {
        flatMap { value in
            Future { promise in
                Task {
                    let output = await transform(value)
                    promise(.success(output))
                }
            }
        }
    }
    
    func tryAsyncMap<T>(
        _ transform: @escaping (Output) async throws -> T
    ) -> Publishers.FlatMap<Future<T, Error>, Self> {
        flatMap { value in
            Future { promise in
                Task {
                    do {
                        let output = try await transform(value)
                        promise(.success(output))
                    } catch {
                        promise(.failure(error))
                    }
                }
            }
        }
    }
}
  • Domain Layer의 Repository Interface를 구현하여 Dependency Inversion을 완성

import Foundation
import Combine
import DomainLayer

public struct ServiceRepository: ServiceRepositoryInterface {
    
    private let dataSource: ServiceDataSourceInterface
    
    public init(dataSource: ServiceDataSourceInterface) {
        self.dataSource = dataSource
    }
    
    public func insertService(value: InsertServiceRequestValue) -> AnyPublisher<ServiceModel, Error> {
        return Just(value)
            .setFailureType(to: Error.self)
            .tryAsyncMap { try await dataSource.insertService(value: $0) }
            .receive(on: RunLoop.main)
            .eraseToAnyPublisher()
    }
        
    public func fetchServiceList() -> AnyPublisher<[ServiceModel], Never> {
        return Just(())
            .asyncMap { await dataSource.fetchServiceList() }
            .receive(on: RunLoop.main)
            .eraseToAnyPublisher()
    }
}

Dependency Injection 구현

  • 앱의 진입점에서 의존성 주입 및 환경 설정

    image

  • AppDI Interface는 Presentation Layer에 구현

public protocol AppDIInterface {
    var tokenViewModel: TokenViewModel { get }
}
  • AppDI는 모든 DI를 사용하는 컨테이너 역할

import Foundation
import DataLayer
import DomainLayer
import PresentationLayer

enum PHASE {
    case DEV, ALPHA, REAL
}

public struct AppEnvironment {
    let phase: PHASE = .DEV
}

public class AppDI: AppDIInterface {

    static let shared = AppDI(appEnvironment: AppEnvironment())

    private let appEnvironment: AppEnvironment

    private init(appEnvironment: AppEnvironment) {
        self.appEnvironment = appEnvironment
    }

    public lazy var tokenViewModel: TokenViewModel = {

        //MARK: Data Layer
        let dataSource: ServiceDataSourceInterface

        switch appEnvironment.phase {
        case .DEV:
            dataSource = ServiceMockDataSource()
        default:
            dataSource = ServiceMockDataSource()
        }

        let repository = ServiceRepository(dataSource: dataSource)

        //MARK: Domain Layer
        let fetchListUseCase = FetchServiceListUseCase(repository: repository)
        let insertServiceUseCase = InsertServiceUseCase(repository: repository)

        //MARK: Presentation
        let viewModel = TokenViewModel(fetchListUseCase: fetchListUseCase,
                                       insertServiceUseCase: insertServiceUseCase)

        return viewModel
    }()
}
  • 뷰 초기화 시 AppDI를 사용하여 의존성 주입

import SwiftUI
import PresentationLayer

@main
struct HelloCleanArchitectureWithSwiftUIApp: App {
    var body: some Scene {
        WindowGroup {
            TokenView(viewModel: AppDI.shared.tokenViewModel)
        }
    }
}

References

GitHub

View Github