로직부분을 viewmodel 로 옮겨 MVC(Massive view controller) 의 크기를 줄이자.
- 기본 iOS 아키텍쳐 패턴은 MVC(Model View Controller) 이다.
- MVC패턴이 잘못된 것은 아니지만 개발하다 보면 view controller의 코드량이 많아진다.
- MVVM은 .NET 커뮤니티에서 주로 몇년째 사용되었다.
- .NET WPF 프레임워크와 iOS 프레임워크는 다르다.(WPF는 XAML을 통해 two-way seamless binding 을 지원한다)
- iOS는 그런거 없고 declarative UI code도 없다(SwiftUI는?)
- MVVM 패턴의 핵심 아이디어는 화면에 표시되는 각각의 View가 해당 View의 data를 제공해 주는 View Model 에 의해 backed 된다는 것이다.
- 이번에 작업해 볼 간단한 앱은
- UITableView : news를 표시하는 테이블뷰
- view model : title, description, publishedDate, author, source 등의 news data
로 이루어 질 것이다.
뷰를 먼저 짠다.
UITableView와 추가 버튼으로 간단하게 구성한다.
이제 뷰에 뿌려질 데이터에 대해 생각해본다.
단순함을 위해 title과 description을 표시할 것이다. 그래서 view model은 title 과 description을 properties로 가질것이다.
struct ArticleViewModel {
var title :String
var description :String
}
extension ArticleViewModel {
init(article :Article) {
self.title = article.title
self.description = article.description
}
}
이런식으로 view model을 구성하면 view 전체에 대한 데이터를 표현하지 못한다.(title과 descriptio은 cell의 데이터를 표현)
UITableView에 대한 데이터를 표시하려면
struct ArticleListViewModel {
var title :String? = "Articles"
var articles :[ArticleViewModel] = [ArticleViewModel]()
}
extension ArticleListViewModel {
init(articles :[ArticleViewModel]) {
self.articles = articles
}
}
이런식으로 구성해야한다.
View Models + Networking
MVVM을 iOS에 적용할때 많은 개발자들이 네트워킹과 데이터 접근 코드를 view model 에 구현하는 걸 볼 수 있었다.
예를 들면
struct ArticleListViewModel {
var title :String? = "Articles"
var articles :[ArticleViewModel] = [ArticleViewModel]()
}
extension ArticleListViewModel {
init(articles :[ArticleViewModel]) {
self.articles = articles
}
func loadArticles(callback :(([Article]) -> ())) {
URLSession.shared.dataTask(with: url) { data, response, error in
if let data = data {
let json = try! JSONSerialization.jsonObject(with: data, options: [])
let dictionary = json as! JSONDictionary
let articleDictionaries = dictionary["articles"] as! [JSONDictionary]
articles = articleDictionaries.flatMap { dictionary in
return Article(dictionary :dictionary)
}
}
DispatchQueue.main.async {
callback(articles)
}
}
}
네트워크 함수인 loadArticle을 ArticleListViewModel에 구현한 것이다.
이런식으로 구현하면 view model 레이어와 web service 레이어의 coupling이 tight하게 될 수 있다.
하지만 view model은 최대한 기능이 단순해야하기 때문에 networking이나 data access 관련 코드가 없어야 한다.
예를 들어 아래 코드는 view controller 안에 있지만 web service 레이어로 나뉘어진 코드이다.
private func loadArticles() {
// this url should be part of the URL builder scheme and not right inside the
// view controller but right now we are focused on MVVM
let url = URL(string: "https://newsapi.org/v1/articles?source=the-next-web&sortBy=latest&apiKey=0cf790498275413a9247f8b94b3843fd")!
// this web service should use generic types. Again this is not part of the implementation
// as we are focusing on MVVM model
Webservice().getArticles(url: url) { articles in
print(articles)
let articles = articles.map { article in
return ArticleViewModel(article :article)
}
self.viewModel = ArticleListViewModel(articles :articles)
}
}
위 코드에서 web service는 view model 객체에 mapped 된 domain object를 리턴한다. 이 mapping은 직접 해도되고 AutoMapper같은 자동 매핑 툴을 사용해도 된다.
마지막으로 mapping이 끝나면 view model 이 UITableView를 reload 시킬 트리거를 만든다. 이 트리거는 자동으로 되는데 didSet 코드 때문이다.
*didSet은 class 의 constructor 안에서는 트리거되지 않는다.
MVVM Bindings
바인딩은 View와 View Model의 data 의 flow를 말한다.
예를들어 등록 화면을 만들때 정보를 text field에 넣을때 바로 RegistrationViewModel 객체가 만들어지는 것이다.
그럼 아래의 코드를 쓸 필요가 없어진다.
self.viewModel.title = self.titleTextField.text!
self.viewModel.description = self.descriptionTextField.text!
Binding은 양방향이다. 이 뜻은 view model 을 바꿔도 view에 반영이 된다는 뜻이다.
string type을 observe 할 수 없어서 custom class 를 만든다.
import Foundation
class Dynamic<T> {
var bind :(T) -> () = { _ in }
var value :T? {
didSet {
bind(value!)
}
}
init(_ v :T) {
value = v
}
}
Dynamic<T>는 T타입의 value를 가질 수 있다. value가 변화되면 didSet이 실행되고 bind function 을 호출하여 value를 caller로 주게 된다.
class AddArticleViewController : UIViewController {
// For this example assume they are UITextField
@IBOutlet weak var titleTextField :BindingTextField!
@IBOutlet weak var descriptionTextField :BindingTextField!
var viewModel :AddArticleViewModel! {
didSet {
viewModel.title.bind = { [unowned self] in self.titleTextField.text = $0 }
viewModel.description.bind = { [unowned self] in self.descriptionTextField.text = $0 }
}
}
@IBAction func AddArticleButtonPressed(_ sender: Any) {
self.viewModel.title.value = "hello world"
self.viewModel.description.value = "description"
}
override func viewDidLoad() {
super.viewDidLoad()
self.viewModel = AddArticleViewModel()
}
}
이 코드는 한방향의 바인딩 (view model 로 부터 ui로) 을 세팅해준다. 이말은 view model 이 바뀌면 ui도 notified 된다는 것이다.
양방향으로 하고 싶다면 custom UITextField control을 구현해야 한다.
import Foundation
import UIKit
class BindingTextField : UITextField {
var textChanged :(String) -> () = { _ in }
func bind(callback :@escaping (String) -> ()) {
self.textChanged = callback
self.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)
}
@objc func textFieldDidChange(_ textField :UITextField) {
self.textChanged(textField.text!)
}
}
위와같이 editingChanged 이벤트를 UITextField에 붙이고 custom callback 함수를 만든다. 그럼 IBOutlets를 아래와 같은 방법으로 update할 수 있다.
@IBOutlet weak var titleTextField :BindingTextField! {
didSet {
titleTextField.bind { self.viewModel.title.value = $0 }
}
}
@IBOutlet weak var descriptionTextField :BindingTextField! {
didSet {
descriptionTextField.bind { self.viewModel.description.value = $0 }
}
}
결론
MVVM 모델의 단순한 버전
MVVM은 logic test를 편하게 해줌
unit tests하기 쉽게 해줌
다읽어 봤으니 한번 만들어 보자.
원래 프로젝트(아래 링크)는 다른 기능까지 있는 코드이지만 나는 단순히 뉴스 헤드라인만 받아와서 리스트에 뿌리는 기능만 구현했다.
1. 새 Xcode 프로젝트를 생성한다.
2. 기본적인 View를 짠다.
*UITableVIewControler로 해야한다.(예제가 그렇게되있다)
cell identifier 을 'Cell' 로한다. (ArticleListViewTableViewController에서 identifier를 Cell로 했기때문)
3. 프로젝트 디렉토리 정리 한다.
4. Controllers - ArticleListTableViewController.swift
//
// AddArticleViewController.swift
// SimpleNewsApp_MVVM
//
// Created by 김태형 on 2020/04/23.
// Copyright © 2020 김태형. All rights reserved.
//
import Foundation
import UIKit
class ArticleListTableViewController : UITableViewController {
private var viewModel :ArticleListViewModel = ArticleListViewModel() {
didSet {
self.tableView.reloadData()
}
}
var didSelect: (ArticleViewModel) -> () = { _ in }
var addArticleTapped: () -> () = {}
override func viewDidLoad() {
super.viewDidLoad()
self.title = self.viewModel.title
loadArticles()
}
private func loadArticles() {
// this url should be part of the URL builder scheme and not right inside the
// view controller but right now we are focused on MVVM
let url = URL(string: "https://newsapi.org/v1/articles?source=the-next-web&sortBy=latest&apiKey=0cf790498275413a9247f8b94b3843fd")!
// this web service should use generic types. Again this is not part of the implementation
// as we are focusing on MVVM model
Webservice().getArticles(url: url) { articles in
print(articles)
let articles = articles.map { article in
return ArticleViewModel(article :article)
}
self.viewModel = ArticleListViewModel(articles :articles)
}
}
@IBAction func addArticleButtonTapped(_ sender: Any) {
addArticleTapped()
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let articleViewModel = self.viewModel.articles[indexPath.row]
didSelect(articleViewModel)
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return self.viewModel.articles.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
let articleViewModel = self.viewModel.articles[indexPath.row]
cell.textLabel?.text = articleViewModel.title
return cell
}
}
5. Models - Article.swift
6. View Models - ArticleListViewModel.swift
7. Web Servives - Webservice.swift
8. storyboard 랑 ArticleListTableVIewController 연결해준다.
10. 실행시키면 뉴스를 들고오는 것을 볼 수 있다.
내가 이해한 데로 요약하자면
프로젝트에는
- Controllers : view controllers, WebService의 getArticle을 호출해 viewModel을 만들어서 UI 에 뿌려주는 역할(데이터는 모름)
- WebServices : api에서 json을 받아와 Article.swift 에 작성된 모델형태로 객체반환(view controller와 네트워크 레이어 분리를 위해)
- ViewModels : Models 데로 파싱한 걸 view controllers에서 인스턴스화 해서 UI에 뿌려질 데이터를 가지고 있는 역할
- Models : 파싱할 데이터 json 구조
- Storyboard : storyboard, UI
결론적으로 view model 이 없으면 예를 들어
ArrayList<Article> articleArraylist = new ArrayList()
로 Article 데이터를 배열로 가져서 데이터가 업데이트 될 때마다
//if articleArrayList changed
tableview.reloadData()
해줘야 되는건데 이걸 view model을 만들어서 view model 이 업데이트 될 때 마다 didSet을 통해 자동으로 tableview 를 reload 해주고
private var viewModel :ArticleListViewModel = ArticleListViewModel() {
didSet {
self.tableView.reloadData()
}
}
view controller 에 서 UI로 뿌려질 데이터를 모두 view model 로 처리하는
//처음 데이터 받아올떄 부터 viewModel에 넣는다.
Webservice().getArticles(url: url) { articles in
print(articles)
let articles = articles.map { article in
return ArticleViewModel(article :article)
}
self.viewModel = ArticleListViewModel(articles :articles)
}
느낌이든다.
'iOS > Architecture' 카테고리의 다른 글
클린 아키텍쳐 4부 정리 (12장~14장) (0) | 2022.04.08 |
---|---|
클린 아키텍쳐 1, 2, 3부 정리 (1장~11장) (0) | 2022.03.31 |
클린 아키텍쳐를 어떻게 공부하지 (0) | 2022.03.26 |
Swift 아키텍쳐 노트 (0) | 2021.03.03 |
iOS: Repository pattern in Swift (0) | 2020.08.07 |
댓글