MVI pattern이 오고 있습니다.(이미 왔나요?)
이번에 다녀온 SyncSwift2022 컨퍼런스에서
MVI 패턴에 대한 언급이 2회 등장했습니다.
두번 모두 SwiftUI에 대한 설명중에
선언적 UI인 SwiftUI에서는 MVVM보다는 MVI패턴이 더 어울린다는 내용이었습니다.
여기서 MVI는 Model, View, Intent를 의미하고,
아래와 같은 단방향 데이터 흐름을 보여주는 패턴입니다.
View로부터 사용자가 UI event를 일으키면,
그 action을 받아서 Intent가 Model의 State를 update시키고,
그 update된 Model의 State를 View가 반영해서 사용자에게 보여주는 흐름입니다.
(SyncSwift2022에서 문상봉님 세션에서는 Intent역할을 좀더 세분화해서 SideEffect레이어를 추가해서 적용을 하셨다고 소개를 해주셨습니다)
위의 단방향 데이터 흐름은
애플의 WWDC2019 'Data Flow Through SwiftUI'세션에서 보여주었던 흐름과 일치합니다.
ViewModel자리에 Intent가 있어서 비슷해보이지만,
단방향 데이터 흐름에 주목해서 보시면 조금 다릅니다.
꼭 SwiftUI로만 MVI를 할 필요는 없죠?
RxSwift와 UIKit으로 구현한 MVI의 간단한 예제를 올려봅니다.
사용자가 [Get Poster!]버튼을 누를 때마다 (User가 UI Event를 일으키면)
랜덤으로 이미지를 받아오는 API로부터 이미지를 받아와서 (그 Action을 Intent가 받아서 Model의 State를 Update시키고)
화면 포스터영역을 갱신시켜 보여주는 (Update된 Model의 State를 View에 반영)
단순한 앱입니다.
먼저 Model입니다.
getImage 함수를 호출하면 Single로 UIImage를 방출합니다.
(lorem.space 참 고마운 API입니다🤩)
import Foundation
import RxSwift
import UIKit
import Alamofire
struct ImageModel {
func getImage() -> Single<UIImage> {
return Single.create { observer -> Disposable in
AF.request("https://api.lorem.space/image/movie")
.responseData { response in
switch response.result {
case let .success(imageData):
if let image = UIImage(data: imageData) {
observer(.success(image))
}
else {
fatalError()
}
case let .failure(error):
observer(.failure(error))
}
}
return Disposables.create()
}
}
}
그 다음은 Intent입니다.
버튼의 액션을 ImageModel로 전달하며, Update된 상태(Image)를 PublishRelay로 방출시키고,
PublishRelay로 방출된 Image를 View의 ImageView로 갱신시켜주는 역할을 하고 있습니다.
import Foundation
import RxSwift
import RxRelay
import UIKit
class PosterViewIntent {
let imageObserver = PublishRelay<UIImage>()
let imageModel = ImageModel()
let disposeBag = DisposeBag()
var viewController: PosterViewController?
func bind(to viewController: PosterViewController) {
self.viewController = viewController
imageObserver.subscribe(on: MainScheduler.instance)
.subscribe { image in
viewController.imageView.image = image
viewController.imageView.backgroundColor = .clear
}
.disposed(by: disposeBag)
}
func getImageButtonTouched() {
imageModel.getImage()
.subscribe(onSuccess: { image in
self.imageObserver.accept(image)
}, onFailure: { error in
print("error:", error)
})
.disposed(by: disposeBag)
}
}
마지막으로 View입니다.
View에서는 layout을 그려주고 Intent와 바인딩만 해주면 끝입니다.
import UIKit
import SnapKit
import Then
import RxSwift
import RxCocoa
class PosterViewController: UIViewController {
let disposeBag = DisposeBag()
let intent = PosterViewIntent()
let imageView = UIImageView().then {
$0.backgroundColor = .lightGray
$0.contentMode = .scaleAspectFit
}
let button = UIButton().then {
$0.backgroundColor = .blue
$0.layer.cornerRadius = 10
$0.setTitle("Get Poster!", for: .normal)
}
override func viewDidLoad() {
super.viewDidLoad()
layout()
bind()
}
func layout() {
[imageView, button]
.forEach { view.addSubview($0) }
imageView.snp.makeConstraints {
$0.top.equalTo(view.safeAreaLayoutGuide.snp.top)
$0.leading.trailing.equalToSuperview()
$0.height.equalTo(imageView.snp.width)
}
button.snp.makeConstraints {
$0.top.equalTo(imageView.snp.bottom).offset(20)
$0.leading.trailing.equalToSuperview().inset(20)
$0.height.equalTo(40)
}
}
func bind() {
intent.bind(to: self)
button.rx.tap.bind {
self.intent.getImageButtonTouched()
}
.disposed(by: disposeBag)
}
}
샘플 소스는 아래에 공유되어 있습니다.
개발 편의를 위해 SnapKit, Then, SwiftUI Preview도 사용되었습니다.
https://github.com/redxoul/RxMVISample
GitHub - redxoul/RxMVISample
Contribute to redxoul/RxMVISample development by creating an account on GitHub.
github.com
아직은 많은 프로젝트들이 MVVM패턴을 사용중이지만,
(기존이나 새로운)프로젝트들이 조금씩 좀 더 높은 iOS타겟으로 올라가게 되면서
기존 솔루션들의 단점을 극복할 수 있게 해주는 최신의 도구들(SwiftUI, Combine, Swift Concurrency, ...)과 새로운 디자인패턴의 선택을 고민하는 시기인거 같습니다.
어떤 패턴이 가장 좋은지는 프로젝트 규모에 따라 구성원에 따라 다를 수 있기 때문에,
미리미리 알아두면 상황에 맞게 장단점을 따져서 좋은 도구를 선택할 수 있겠죠?