Hello Clean Architecture With SwiftUI

Overview

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를 줄 수 있음

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"]), ] )">
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
   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
   Error>
    
   let repository: ServiceRepositoryInterface
    
    
   public 
   init(
   repository: ServiceRepositoryInterface) {
        
   self.
   repository 
   = repository
    }
    
    
   public 
   func 
   execute(
   value: InsertServiceRequestValue) 
   -> AnyPublisher
   
    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 사용
ServiceModel { guard let serviceName = value.serviceName else { throw ServiceError.unknown } let insertData = ServiceModel(id: Int64.random(in: 0.. [ServiceModel] { return mockData } }">
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
   
    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
    
     
      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
   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

You might also like...
Приложение является реализацией модуля новостной ленты VK. Архитектура Clean Swift.
Приложение является реализацией модуля новостной ленты VK. Архитектура Clean Swift.

Новостная лента VK Приложение является реализацией модуля новостной ленты VK. Особенности приложения архитектура Clean Swift; программная реализация и

MVP-Clean sample iOS Swift project
MVP-Clean sample iOS Swift project

RestaurantsApp MVP-Clean sample iOS Swift project The purpose of this document is to explain the architecture of application. This application shows r

Dogs - A fun exploration of using Clean Swift methodology (VIP) to build a simple app
Dogs - A fun exploration of using Clean Swift methodology (VIP) to build a simple app

Dogs A fun exploration of using Clean Swift methodology (VIP) to build a simple app Was following the directory structure and templates as described i

A simple clean application to provide you with weather forecast data as well as currency rates, all with beautiful melodies and sounds
A simple clean application to provide you with weather forecast data as well as currency rates, all with beautiful melodies and sounds

A simple clean application to provide you with weather forecast data as well as currency rates, all with beautiful melodies and sounds.

A simple to use iOS app with clean UI to calculate time until a specified date
A simple to use iOS app with clean UI to calculate time until a specified date

A simple to use iOS app with clean UI to calculate time until a specified date.Added new feature that is now you can measure time from a specified date as well. Like time spent from the day you were born.

🖼 Gallery App for Harvest (Elm Architecture + Optics) + SwiftUI + Combine.
🖼 Gallery App for Harvest (Elm Architecture + Optics) + SwiftUI + Combine.

🖼 Harvest-SwiftUI-Gallery Gallery App for Harvest (Elm Architecture + Optics) + SwiftUI + Combine. Examples Todo List Stopwatch GitHub Search TimeTra

Open source game built in SwiftUI and the Composable Architecture.
Open source game built in SwiftUI and the Composable Architecture.

isowords This repo contains the full source code for isowords, an iOS word search game played on a vanishing cube. Connect touching letters to form wo

Porting the example app from our Advanced iOS App Architecture book from UIKit to SwiftUI.

SwiftUI example app: Koober We're porting the example app from our Advanced iOS App Architecture book from UIKit to SwiftUI and we are sharing the cod

An iOS template project using SwiftUI, Combine and MVVM-C software architecture
An iOS template project using SwiftUI, Combine and MVVM-C software architecture

SwiftUI-MVVM-C A template project that uses SwiftUI for UI, Combine for event handling, MVVM-C for software architecture. I have done some small proje

Owner
null
Best architecture for SwiftUI + CombineBest architecture for SwiftUI + Combine

Best architecture for SwiftUI + Combine The content of the presentation: First of the proposed architectures - MVP + C Second of the proposed architec

Kyrylo Triskalo 3 Sep 1, 2022
Mahmoud-Abdelwahab 5 Nov 23, 2022
RippleQueries is an iOS application built as assessment task at Ripple Egypt. Built Using MVVM (Model-View-ViewModel) and Clean Architecture concepts

RippleRepositories RippleRepositories is an iOS application built as an assessment task at Ripple Egypt. Built Using RxSwift & MVVM (Model-View-ViewMo

Muhammad Ewaily 3 Sep 16, 2021
Weather Forecast App (OpenWeather API & CLLocationManager). Clean Swift VIP architecture.

WeatherApp Weather Forecast App (OpenWeather API & CLLocationManager). Clean Swift VIP architecture. Without storyboard or xib. The application shows

Nikita Lomovtsev 7 Dec 25, 2022
App for displaying VK news feed (VKSDK API). Clean Swift VIP architecture

VKNewsFeed VKNewsFeed - application for displaying VK news feed with dynamic cells and a collection of images in the post. Data request occurs from th

Areg Vardanian 0 Dec 18, 2021
An iOS application written in Swift to demonstrate how to implement a Clean Architecture in iOS

Reminders iOS An iOS application written in Swift to demonstrate how to implement a Clean Architecture in iOS. Idea The idea is to implement the simpl

Tiago Martinho 306 Nov 9, 2022
Assignment: iOS app in VIP Clean architecture

countries_vip_clean Assignment: iOS app in VIP Clean architecture. for countries

Vishwa Deepak Choudhary 1 Feb 7, 2022
Github repo search with using mvvm-c and clean architecture and using combine swift

GitSearchWithMVVM-C-CleanArchitecture Github repo search with using mvvm-c and clean architecture and using combine swift. Content Overview How To Run

Muhammad Qasim Majeed 1 Mar 16, 2022
A Flutter Clean Architecture Using GetX.

flutter-getx-clean-architecture A Flutter Clean Architecture Using GetX. Work Flow Project Structure |-- lib |-- main.dart |-- app |--

Duc Pham 93 Dec 27, 2022
Mvi Architecture for SwiftUI Apps. MVI is a unidirectional data flow architecture.

Mvi-SwiftUI If you like to read this on Medium , you can find it here MVI Architecture for SwiftUI Apps MVI Architecture Model-View-Intent (MVI) is a

null 12 Dec 7, 2022