article thumbnail image
Published 2023. 5. 27. 17:45

Architecture Pattern들은 Logic 측면에서 "관심사의 분리(SoC)"를 목표로 한다.

 

그중 MV(X) 계열의 Architecture Pattern들은 

UI Logic(View)과 Business Logic(Model)을 분리를 중점으로 여겼다.

 

UI Logic과 Business Logic의 분리로 인해, 둘 간의 중계자가 필요하게 되었고, 

중계자에 따라 MVC, MVP, MVVM으로 나뉘게 된다.

MVC 패턴은 중계자 역할을 Controller가 맡게 되었다.

하지만, Controller을 UIViewController가 담당하게 되면서, 

Controller는 View와 결합성이 강해, View의 일부 역할까지 담당하게 되었다. 

 

이를 해결하고자, MVP 패턴에선

UIKit과 독립된 Presenter가 중계자 역할을 맡게 된다.

하지만, View와 Presenter가 서로를 알아야 하는 구조 때문에,

재사용성이 떨어지고, 서로 간의 의존성이 강하다는 문제가 있었다.

 

MVVM은 View Model을 통해 View와 중계자 간의 의존성과, 재사용을 늘렸다.

 

MVVM Architecture Pattern

MVVM은 Model-View-View Model의 약자이다.

 

앞서, 말했듯 View와 Model의 역할은 동일하다. 

MVVM에선 중계자 역할을 View Model이 담당하게 되고, 

이 역시 MVP와 같이 UIKit과 독립적이다.

  • Model : Data관련 로직과 Business 로직을 담당
  • View : UI 로직을 담당
  • View Model : Model과 View의 중계자 역할.

MV(X) 계열 패턴에서 중계자는 View의 Input을 처리하여, View에 전달한다. 

MVC, MVP는 직접 전달하는 구조였다면, 

MVVM의 View Model은 View에 output을 전달하지 않는다.

 

View Model은 View에 들어온 input에 대해 자신의 데이터만 변경한다. 

View는 "Binding"을 통해 View Model의 데이터 변화를 감지하여, UI를 변경한다.

즉, MVC, MVP의 중계자는 직접 Output을 전달하였다면, 

MVVM의 중계자는 자신의 데이터만 변경할 뿐, 나머지는 View가 알아서 하는 구조이다. 

 

Binding을 통해 View Model은 View를 몰라도 되는 구조가 되었다.

따라서, View와 View Model 간의

1:1관계가 아닌 1:N관계가 가능해지게 되었다.

즉, 데이터의 구조가 비슷한 View라면 해당 View Model을 공유할 수 있다는 장점이 있다. 

 

View Model은 Presenter처럼 UIKit과 독립적이기 때문에 테스트가 용이해졌다. 

또한, Binding을 통해 View Model은 View를 모르는 구조가 되었기 때문에, 

View로부터 완전히 독립적인 구조를 이루게 된다.

 

 

Binding

앞서 말했듯, View Model은 View를 모르는 구조이기 때문에, 

View는 View Model의 데이터 변화를 감지하기 위해 Data Binding을 해야했다.

 

Binding을 하는 방법에는 여러 가지가 있다. 

  • Observables(Custom Binding)
  • FRP Technique
  • Combine FrameWork

 

다음과 같이 + Button을 누르면 Label 숫자가 "+1" 되고 - Button을 누르게 되면 숫자가 "-1"되는 View가 있다 가정하자.  

Binding 방법에 따라, View와 View Model만 바뀌기 때문에 Model부터 살펴보자. 

class CountModel {
	private(set) var number: Int = 0
	
	// Business Logic
	func increaseBy(_ value: Int) {
		number += value
	}
	func decreaseBy(_ value: Int) {
		number -= value
	}
}

 

Observables (Custom Binding) 

Swift의 UIKit Application에서는 기본적으로 Binding을 지원하지 않는다.

따라서, 첫 번째는 직접 구현하는 방법부터 알아보자. 

우선, 좀 더 사용성을 늘리기 위해 Generic 타입으로 구현하였다. 

class Observable<T> {
    
    typealias Listner = (T) -> Void

    // value의 변화가 이루어 지면 lister를 통해 value를 전달한다.
    var value: T {
        didSet {
            listener?(value)
        }
    }
    
    private var listener: Listner?
    
    init(_ value: T) {
        self.value = value
    }
    
    // View에서 bind 호출을 통해 전달하는 closure를 listner에 assign한다.
    func bind(_ closure: @escaping (T) -> Void) {
        listener = closure
    }
}

value의 값 변화는 property 감시자를 통해 listnervalue를 전달하고,

클로저를 listner에 assign하는 bind메서드를 구현한다.

 

class CustomBindViewModel {
	var count: Observable<CountModel>
	
	init() {
		count = Observable(CountModel())
	}
	
	func plusCount() {
		count.value.increaseBy(1)
	}
	
	func minusCount() {
		count.value.decreaseBy(1)
	}
}

ViewModel은 위와 같이, View를 소유하지 않고 

단순 자신의 데이터만 변화시킨다. 

 

그런 다음, View에서 ViewDidLoad함수에서 bind 함수를 호출한다.

후행 Closure로 구현한 Closure를 listener에 할당한다. 

따라서, ViewModel의 값이 변할 때마다, 프로퍼티 감시자를 통해 listener가 호출된다.

// CounterViewController - Output

customBindViewModel.count.bind { [weak self] countModel in
	guard let strongSelf = self else { return }
	
	strongSelf.CountLabel.text = "\(countModel.number)"
}

 

ViewModel에 Input은 다음과 같이 전달한다. 

// CounterViewController - Input

@IBAction func plusButtonTapped(_ sender: UIButton) {
	customBindViewModel.plusCount()
}

@IBAction func minusButtonTapped(_ sender: UIButton) {
	customBindViewModel.minusCount()
}

 

FRP Technique

FRP(Functional / Reactive Programing)은 Reactive Programming을 Functional Programming기반으로 구현한 방식을 말한다. 

보통 RxSwift나 RxCocoa같은 외부 라이브러리를 사용한다.

 

FRP 방식을 활용한 방식의 ViewModel은

Input Stream을 통해 View의 Input을 감지한다. 

// FRPViewModel - Input

let plusButtonTapped: PublishRelay<Void>
let minusButtonTapped: PublishRelay<Void>

Input이 들어오면, Model의 Business Logic을 수행한다. 

// FRPViewModel - Input

plusButtonTapped
	.subscribe(
		onNext: (plusNumber)
	)
	.disposed(by: disposeBag)

minusButtonTapped
	.subscribe(
		onNext: (minusNumber)
	)
	.disposed(by: disposeBag)

수행 후, 변경된 값을 Stream을 통해 방출한다. 

func plusNumber() {
	self.dependency.increaseBy(1)
	countValue$.onNext(dependency.number)
}

func minusNumber() {
	self.dependency.decreaseBy(1)
	countValue$.onNext(dependency.number)
}

 

Stream을 통해 이벤트를 전달받은 View는 변경된 값을 Label에 반영한다. 

// ViewController
frpViewModel.countValue
	.map { "\($0)" }
	.drive(countLabel.rx.text)
	.disposed(by: disposeBag)

 

전체적인 코드는 다음과 같다. 

class FRPViewModel {
	private let dependency: CountModel
	private let disposeBag = DisposeBag()
	
	// Input
	let plusButtonTapped: PublishRelay<Void>
	let minusButtonTapped: PublishRelay<Void>
	
	// Output
	let countValue: Driver<Int>
	private let countValue$: PublishSubject<Int>
	
	init(dependency: CountModel = CountModel()) {
		self.dependency = dependency
		self.plusButtonTapped = PublishRelay<Void>()
		self.minusButtonTapped = PublishRelay<Void>()
		
		self.countValue$ = PublishSubject<Int>()
		self.countValue = countValue$.asDriver(onErrorJustReturn: 0)
		
		plusButtonTapped
			.subscribe(
				onNext: (plusNumber)
			)
			.disposed(by: disposeBag)
		
		minusButtonTapped
			.subscribe(
				onNext: (minusNumber)
			)
			.disposed(by: disposeBag)
	}
	
	func plusNumber() {
		self.dependency.increaseBy(1)
		countValue$.onNext(self.dependency.number)
	}
	
	func minusNumber() {
		self.dependency.decreaseBy(1)
		countValue$.onNext(self.dependency.number)
	}
}

 

 

// CounterViewController

// Input : View에서 ViewModel로 Input(Action) 전달.
plusButton.rx.tap
	.bind(to: frpViewModel.plusButtonTapped)
	.disposed(by: disposeBag)

minusButton.rx.tap
	.bind(to: frpViewModel.minusButtonTapped)
	.disposed(by: disposeBag)

// Output
frpViewModel.countValue
	.map { "\($0)" }
	.drive(countLabel.rx.text)
	.disposed(by: disposeBag)

 

Combine Framework

Swift 5.1 이후로 제공되는 Combine FrameWork는 Signal을 캐치하고 처리하기 위한 publish-and-subscribe API를 제공한다. 

// CombineViewModel

class CombineViewModel: ObservableObject{
	private let dependency: CountModel
	@Published var outputValue: Int

	init(dependency: CountModel = CountModel()) {
		self.dependency = dependency
		outputValue = dependency.number
	}
	
	func plusNumber() {
		self.dependency.increaseBy(1)
		outputValue = dependency.number
	}
	
	func minusNumber() {
		self.dependency.decreaseBy(1)
		outputValue = dependency.number
	}
}
class CounterViewController: UIViewController {
	...
	override func viewDidLoad() {
		...
		
		combineViewModel.$outputValue.sink { [weak self] value in
			guard let strongSelf = self else { return }
			strongSelf.countLabel.text = "\(value)"
		}
		.store(in: &cancellables)
	}
	
	// Input
	@IBAction func minusButtonTapped(_ sender: UIButton) {
		combineViewModel.minusNumber()
	}
	
	@IBAction func plusButtonTapped(_ sender: UIButton) {
		combineViewModel.plusNumber()
	}
}

 

 

References

https://medium.com/ios-os-x-development/ios-architecture-patterns-ecba4c38de52

 

'iOS > Pattern' 카테고리의 다른 글

[Design Pattern] Coordinator 패턴  (0) 2024.05.26
[Architecture Pattern] SoftWare Architecture  (0) 2023.05.24
[Design Pattern] DI(Dependency Injection)  (0) 2023.05.08
[Architecture Pattern] MVP  (0) 2023.03.24
[Architecture Pattern] MVC  (0) 2023.03.22
복사했습니다!