iOS의 Clean Architecture

Layers

ios-clean-architecture
  • Presentation Layer는 Domain Layer에 의존
  • Data Layer는 Domain Layer에 의존
  • Domain Layer는 의존성이 없기 때문에 분리해서 다른 프로젝트에서도 사용 가능
ios-clean-architecture
  • Presentation: View(SwiftUI/UIKit), ViewModel(Presenter)
  • Domain: Entity, Use Case(Interactor), Repository 인터페이스
  • Data: Repository 구현, Data Source(remote/local), JSON Data 맵핑

Data 흐름

  • View가 ViewModel의 method 호출
  • ViewModel이 UseCase 실행
  • UseCase가 Repository의 data를 조합
  • Repository는 Remote/Local에서 data 받아와 리턴
  • View에 Information 표시

Presentation Layer

View

SwiftUI View

struct DefaultView: View {
    @StateObject private var viewModel = ViewModel()
        
    var body: some View {
        List(viewModel.items) { item in
            Title(item.title)
        }
        .task {
            await viewModel.didSearch("")
        }
    }
}

UIKit ViewController

class DefaultViewController: UIViewController, StoryboardInstantiable {
    private var viewModel: ViewModel!
    
    static func create() -> DefaultViewController {
        let viewController = DefaultViewController.instantiateViewController()
        viewController.viewModel = ViewModel()
        return viewController
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        bind(to: viewModel)
    }
    
    private func bind(to viewModel: ViewModel) {
        viewModel.items.observe(on: self) { [weak self] items in
            // items 사용
        }
        viewModel.error.observe(on: self) { [weak self] error in
            // error 사용
        }
    }
}

ViewModel

SwiftUI ViewModel

class ViewModel: ObservableObject {
    private let useCase = DefaultUseCase()
    
    @Published var items = [Item]()
    @Published var error = ""
}

extension ViewModel {
    func didSearch(query: String) async {
        let page = 0
        let query = Query(query: query)
        
        await load(query: query, page: page)
    }
    
    private func load(query: Query, page: Int) async {
        do {
            let request = Request(query: query.query, page: page)
            self.items = try await useCase.execute(request: request)
        } catch {
            self.error = error
        }
    }
}

UIKit ViewModel

final class Observable<Value> {
    struct Observer<Value> {
        weak var observer: AnyObject?
        let block: (Value) -> Void
    }
    
    private var observers = [Observer<Value>]()
    
    var value: Value {
        didSet { notifyObservers() }
    }
    
    init(_ value: Value) {
        self.value = value
    }
    
    func observe(on observer: AnyObject, observerBlock: @escaping (Value) -> Void) {
        observers.append(Observer(observer: observer, block: observerBlock))
        observerBlock(self.value)
    }
    
    func remove(observer: AnyObject) {
        observers = observers.filter { $0.observer !== observer }
    }
    
    private func notifyObservers() {
        for observer in observers {
            observer.block(self.value)
        }
    }
}
class ViewModel {
    private let useCase = DefaultUseCase()
    
    let items: Observable<[Item]> = Observable([])
    let error: Observable<String> = Observable("")
    
    private func load(query: Query, page: Int) async {
        do {
            let request = Request(query: query.query, page: page)
            self.items = try await useCase.execute(request: request)
        } catch {
            self.error = error.localizedDescription
        }
    }
}

Data 바인딩

  • SwiftUI는 SwiftUI Property Wrappers, Observable Macro, Combine ObservableObject 등 사용
  • UIKit은 (자체) Observable, RxSwift, Closure, Delegate 등 사용

Domain Layer

Entity

  • Business Logic에 사용될 Data 형태
  • Codable Data를 Entity 형태로 맵핑해서 사용
struct Item: Equatable, Identifiable {
    let id = UUID()
    let title: String
}

UseCase

  • Business Logic을 구현하는 곳
  • Interactor로 불리기도 함
protocol UseCase {
    func execute(request: Request) async throws -> [Item]
}

class DefaultUseCase: UseCase {
    private let repository = DefaultRepository()
    
    func execute(request: Request) async throws -> [Item] {
        let result = try await repository.fetchList(query: request.query, page: request.page)
        
        // result 활용
        
        return result
    }
}

Repository 인터페이스

  • Dependency Inversion을 위해 필요
  • 간단히 말해, Data Layer가 Domain Layer에 의존하기 위해 필요
protocol Repository {
    func fetchList(query: Query, page: Int) async throws -> [Item]
}

Data Layer

Repository 구현부

class DefaultRepository: Repository {    
    private let networkService = NetworkService()
    
    func fetchList(query: Query, page: Int) async throws -> [Item] {
        let request = Request(query: query.query, page: page)        
        let endpoint = APIEndpoints.get(with: request)
        
        let response: Response = try await networkService.request(with: endpoint)
        let result = response.toDomain()
        
        return result
    }
}

Network Service

  • Remote data source로써 API와 통신하는 역할
  • Alamofire 같은 써드파티 라이브러리를 사용해도 됨
  • Network Layer 글 참고해서 자체적인 구현도 가능

Local Data

  • Local data source로써 CoreData, Realm, Cache 등에 해당

JSON Codable

  • JSON을 Codable로 파싱하는 Data 구조체
  • Domain에서 사용하기 위해 Entity로 맵핑하는 부분도 구현
struct Request: Encodable {
    let query: String
    let page: Int
}

struct Response: Decodable {
    let page: Int
    let titles: [String]
}

extension Response {
    func toDomain() -> [Item] {
        titles.map { Item(title: $0) }        
    }
}

Dependency Injection

SwiftUI

  • @Environment, @EnvironmentObject, @ObservedObject 사용

UIKit

  • Dependency와 Coordinator
struct Actions {
    let details: (Item) -> Void
}

protocol Dependency  {
    func makeViewController(actions: Actions) -> UIViewController
    func makeDetailsViewController(item: Item) -> UIViewController
}

class DefaultDependency: Dependency {
    ...
    
    func makeCoordinator(navigationController: UINavigationController) -> Coordinator {
        return Coordinator(navigationController: navigationController, dependency: self)
    }
}

class Coordinator {    
    private weak var navigationController: UINavigationController?
    private let dependency: Dependency

    init(navigationController: UINavigationController, dependency: Dependency) {
        self.navigationController = navigationController
        self.dependency = dependency
    }
    
    func start() {
        let actions = Actions(details: details)
        let viewController = dependency.makeViewController(actions: actions)        
        navigationController?.pushViewController(viewController, animated: false)
    }
    
    private func details(item: Item) {
        let viewController = dependency.makeDetailsViewController(item: item)
        navigationController?.pushViewController(viewController, animated: true)
    }
}
  • Closure
class DefaultDependency {
    ...
    func makeCoordinator(navigationController: UINavigationController) -> Coordinator {
        return Coordinator(navigationController: navigationController, makeViewController: self.makeViewController)
    }
    
    func makeViewController() -> DefaultViewController { ... }
}

class Coordinator {   
    private var makeViewController: () -> DefaultViewController

    init(navigationController: UINavigationController, makeViewController: @escaping () -> DefaultViewController) {
        ...
        self.makeViewController = makeViewController
    }
    ...
}

참고