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

ViewModel

SwiftUI ViewModel

struct Actions {
    let details: (Coffee) -> Void
}

struct Item: Equatable, Identifiable {
    let id = UUID()
    let title: String
}
class ViewModel: ObservableObject {
    private let useCase: UseCase
    private let actions: Actions?
    
    @Published var items = [Item]()
    @Published var error = ""
    
    init(useCase: UseCase, actions: Actions) {
        self.useCase = useCase
        self.actions = actions
    }
    
    private func load(query: Query, page: Int) {        
        useCase.execute(request: Request(query: query.query, page: page)) { result in
            // result 사용
        }
    }
}

extension ViewModel {
    func didSearch(query: String) {
        load(query: Query(query: query), page: 0)
    }
    
    func didSelect(at index: Int) {
        actions?.details(items[index])
    }
}

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)
        }
    }
}
struct Actions {
    let details: (Coffee) -> Void
}

struct Item: Equatable {
    let title: String
}
protocol ViewModelInput {
    func didSearch(query: String)
    func didSelect(at indexPath: IndexPath)
}

protocol ViewModelOutput {
    var items: Observable<[Item]> { get }
    var error: Observable<String> { get }
}

protocol ViewModel: ViewModelInput, ViewModelOutput { }

extension ViewModel {
    func didSearch(query: String) {
        load(query: Query(query: query), page: 0)
    }
    
    func didSelect(at indexPath: IndexPath) {
        actions?.details(coffee[indexPath.row])
    }
}

class DefaultViewModel: ViewModel {
    private let useCase: UseCase
    private let actions: Actions
    
    private var coffee = [Coffee]()
    
    let items: Observable<[Item]> = Observable([])
    let error: Observable<String> = Observable("")
    
    init(useCase: UseCase, actions: Actions) {
        self.useCase = useCase
        self.actions = actions
    }
    
    private func load(query: Query, page: Int) {
        useCase.execute(query: Request(query: query.query, page: page)) { result in
            // result 사용
        }
    }
}

View

SwiftUI View

struct DefaultView: View {
    @StateObject private var viewModel: ViewModel
    
    init(useCase: UseCase, actions: Actions) {
        _viewModel = StateObject(wrappedValue: ViewModel(useCase: useCase, actions: actions))
    }
        
    var body: some View {
        List(Array(viewModel.items.enumerated()), id: \.element) { index, item in
            Title(item.title)
                .onTapGesture {
                    viewModel.didSelect(at: index)
                }
        }
        .onAppear {
            viewModel.didSearch("")
        }
    }
}

UIKit ViewController

class DefaultViewController: UIViewController, StoryboardInstantiable {
    private var viewModel: ViewModel!
    
    static func create(with viewModel: ViewModel) -> 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 사용
        }
    }
}

Data 바인딩

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

Domain Layer

Entity

  • Business Logic에 사용될 Data 형태
  • Codable Data를 Entity 형태로 맵핑해서 사용
struct Coffee: Equatable, Identifiable {
    typealias Identifier = String

    let id: Identifier
    let title: String?
}

struct Page: Equatable {
    let page: Int
    let totalPages: Int
}

UseCase

  • Business Logic을 구현하는 곳
  • Interactor로 불리기도 함
protocol UseCase {
    func execute(request: Request, completion: @escaping (Result<Page, Error>) -> Void) -> Cancellable?
}

class DefaultUseCase: UseCase {
    private let repository: Repository
    
    init(repository: Repository) {
        self.repository = repository
    }
    
    func execute(request: Request, completion: @escaping (Result<Page, Error>) -> Void) -> Cancellable? {
        return repository.fetchList(query: request.query, page: request.page) { result in
            // result 활용
            completion(result)
        }
    }
}

Repository 인터페이스

  • Dependency Inversion을 위해 필요
  • 간단히 말해, Data Layer가 Domain Layer에 의존하기 위해 필요
protocol Repository {
    func fetchList(query: Query, page: Int, completion: @escaping (Result<Page, Error>) -> Void) -> Cancellable?
}

Data Layer

Repository 구현부

class DefaultRepository: Repository {    
    private let networkService: NetworkService
    
    init(networkService: NetworkService) {
        self.networkService = networkService
    }
    
    func fetchList(query: Query, page: Int, completion: @escaping (Result<Page, Error>) -> Void) -> Cancellable? {        
        let endpoint = APIEndpoints.get(with: Request(query: query.query, page: page))

        return networkService.request(with: endpoint) { response: Result<Response, Error> in
            // response 활용
        }
    }
}

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 totalPages: Int
}

extension Response {
    func toDomain() -> Page {
        Page(page: page, totalPages: totalPages)
    }
}

Dependency Injection

SwiftUI

  • @Environment, @EnvironmentObject, @ObservedObject 사용

UIKit

  • Dependency와 Coordinator
protocol Dependency  {
    func makeViewController(actions: Actions) -> UIViewController
    func makeDetailsViewController(coffee: Coffee) -> 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(coffee: Coffee) {
        let viewController = dependency.makeDetailsViewController(coffee: coffee)
        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
    }
    ...
}

참고