[iOS] URLSession 더럽게 사용해보기(2)

Charlie·2022년 10월 1일
0

지난 글에서 디자인 패턴, 아키텍처 등을 전혀 고려하지 않고 더럽게 URLSession을 사용해보았다. 이번에는 MVVM을 적용해보고 어느정도 레이어를 나누어 코드를 보완해보자.

MVVM

우선 각 ViewController에서 사용할 ViewModel을 만들어주고 비즈니스 로직과 관련한 코드들을 모두 옮기자.

MainViewController

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()
            }
        }
    }
}

MainViewModel

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
    }
}

DetailViewController

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() {
    	// ...
    }
}

DetailViewModel

import Foundation

final class DetailViewModel {
    var article: Article
    
    init(article: Article) {
        self.article = article
    }
}

레이어 구분

네트워크와 관련한 코드들을 Presenter 레이어에서 빼서 따로 관리해보자.
간단하게 싱글톤 객체를 만들고 MainViewModel에 있던 네트워크 관련 코드들을 분리하여 새로 만든 Network 레이어에 넣으면 된다.

APIService

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
    }
}

MainViewModel

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를 사용해서 통신해보고...까지 포스팅할 예정이다.

profile
Hello

0개의 댓글