HelloCleanArchitectureWithSwiftUI
CleanArchitecture for SwiftUI with Combine, Concurrency
개요
Clean Architecture를 SwiftUI와 Combine을 사용한 iOS 프로젝트에 적용한 예제
Layer와 Data Flow
먼저 역할별 레이어들부터 알아보자면 다음과 같다.
- Presentation Layer: UI 관련 레이어
- Domain Layer: 비즈니스 룰과 로직 담당 레이어
- Data Layer: 원격/로컬등 외부에서 데이터를 가져오는 레이어
- 각 레이어들의 Dependency 방향은 모두 원밖에서 원안쪽으로 향하고 있음
- UI를 담당하는 Presentation Layer는 MVVM 패턴으로 구현됨
각 레이어의 데이터 흐름은 다음과 같다.
- Domain Layer에서 Data Layer를 실행 시킬 수 있는 이유는 Dependency Inversion 으로 구현되었기 때문
Dependency Inversion 이란?
각 모듈간의 의존성을 분리시키기 위해 추상화된 인터페이스만 제공하고 의존성은 외부에서 주입(Dependency Injection)시킴
프로젝트 구성 (Swift Package Manager)
Clean Architecture의 각 Layer 별 의존성을 구현하기 위해 SPM을 사용하여 프로젝트를 구성한다.
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()
}
}
}
- ObservedObject로 선언된 ViewModel 내의 데이터가 업데이트되면 화면이 갱신됨
- Insert 버튼 누르면 ViewModel의 insert UseCase를 실행
- 화면이 보일때 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
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 구현
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)
}
}
}