iOS의 Clean Architecture
Layers
- Presentation Layer는 Domain Layer에 의존
- Data Layer는 Domain Layer에 의존
- Domain Layer는 의존성이 없기 때문에 분리해서 다른 프로젝트에서도 사용 가능
- 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
}
...
}