지난 글에서 디자인 패턴, 아키텍처 등을 전혀 고려하지 않고 더럽게 URLSession을 사용해보았다. 이번에는 MVVM을 적용해보고 어느정도 레이어를 나누어 코드를 보완해보자.
우선 각 ViewController에서 사용할 ViewModel을 만들어주고 비즈니스 로직과 관련한 코드들을 모두 옮기자.
import UIKit
import SnapKit
final class MainViewController: UIViewController {
// ...
var viewModel = MainViewModel()
// ...
}
// ...
// MARK: - UITableViewDataSource, UITableViewDelegate
extension MainViewController: UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return viewModel.articles.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: ArticleTableViewCell.identifier, for: indexPath) as? ArticleTableViewCell else { return UITableViewCell() }
let article = self.viewModel.articles[indexPath.row]
cell.titleLabel.text = article.title
cell.authorLabel.text = article.author
return cell
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let index = indexPath.row
let vc = DetailViewController()
vc.viewModel = DetailViewModel(article: viewModel.articles[index])
self.navigationController?.pushViewController(vc, animated: true)
}
}
// MARK: - Interaction
extension MainViewController {
@objc private func updateButtonPressed(_ sender: UIButton) {
viewModel.updateArticles {
DispatchQueue.main.async {
self.articleTableView.reloadData()
}
}
}
}
import Foundation
final class MainViewModel {
var articles: [Article] = []
init() {}
func updateArticles(completion: @escaping () -> Void) {
self.articles = []
self.loadArticles { result in
switch result {
case .success(let articles):
for _ in 0..<5 {
self.articles.append(articles.randomElement()!)
}
case .failure(let error):
print(error.localizedDescription)
}
completion()
}
}
}
// MARK: - Network
extension MainViewModel {
private func loadArticles(completion: @escaping (Result<[Article], Error>) -> Void) {
let urlString = "https://newsapi.org/v2/top-headlines?country=us&category=business&apiKey=bb8e4fcfd4bc4ea4a1ba2b1b105a592f"
guard let url = URL(string: urlString) else {
print("URL 생성 실패")
return
}
let session = URLSession.shared
let task = session.dataTask(with: url) { data, response, error in
if let error = error {
print(error.localizedDescription)
return
}
if let data = data {
do {
let dto = try JSONDecoder().decode(ArticleDTO.self, from: data)
completion(.success(self.convertToArticles(dto: dto)))
} catch {
print(error.localizedDescription)
completion(.failure(error))
}
}
}
task.resume()
}
private func convertToArticles(dto: ArticleDTO) -> [Article] {
var articles: [Article] = []
dto.articles?.forEach({ article in
articles.append(Article(author: article.author ?? "nil", title: article.title, description: article.description ?? "nil", urlToImage: article.urlToImage ?? "nil"))
})
return articles
}
}
import UIKit
import SnapKit
final class DetailViewController: UIViewController {
// ...
var viewModel: DetailViewModel?
override func viewDidLoad() {
super.viewDidLoad()
setup()
layout()
}
}
// MARK: - UI
extension DetailViewController {
private func setup() {
self.titleLabel.text = self.viewModel?.article.title
self.authorLabel.text = self.viewModel?.article.author
self.descriptionLabel.text = self.viewModel?.article.description
}
private func layout() {
// ...
}
}
import Foundation
final class DetailViewModel {
var article: Article
init(article: Article) {
self.article = article
}
}
네트워크와 관련한 코드들을 Presenter 레이어에서 빼서 따로 관리해보자.
간단하게 싱글톤 객체를 만들고 MainViewModel에 있던 네트워크 관련 코드들을 분리하여 새로 만든 Network 레이어에 넣으면 된다.
import Foundation
final class APIService {
static let shared: APIService = APIService()
private init() {}
func loadArticles(completion: @escaping (Result<[Article], Error>) -> Void) {
let urlString = "https://newsapi.org/v2/top-headlines?country=us&category=business&apiKey=bb8e4fcfd4bc4ea4a1ba2b1b105a592f"
guard let url = URL(string: urlString) else {
print("URL 생성 실패")
return
}
let session = URLSession.shared
let task = session.dataTask(with: url) { data, response, error in
if let error = error {
print(error.localizedDescription)
return
}
if let data = data {
do {
let dto = try JSONDecoder().decode(ArticleDTO.self, from: data)
completion(.success(self.convertToArticles(dto: dto)))
} catch {
print(error.localizedDescription)
completion(.failure(error))
}
}
}
task.resume()
}
}
// MARK: - Convert Method
extension APIService {
private func convertToArticles(dto: ArticleDTO) -> [Article] {
var articles: [Article] = []
dto.articles?.forEach({ article in
articles.append(Article(author: article.author ?? "nil", title: article.title, description: article.description ?? "nil", urlToImage: article.urlToImage ?? "nil"))
})
return articles
}
}
import Foundation
final class MainViewModel {
var articles: [Article] = []
var service: APIService = APIService.shared
init() {}
func updateArticles(completion: @escaping () -> Void) {
self.articles = []
service.loadArticles { result in
switch result {
case .success(let articles):
for _ in 0..<5 {
self.articles.append(articles.randomElement()!)
}
case .failure(let error):
print(error.localizedDescription)
}
completion()
}
}
}
지난번에 비해서 아주 조금 깔끔해졌다.
앞으로 Rx를 사용해서 바인드하고, 클린 아키텍처도 적용해보고, Alamofire, Moya를 사용해서 통신해보고...까지 포스팅할 예정이다.