Archive/패캠(초격차)

part5. (ch5) BookReview 코드리뷰

lgvv 2022. 1. 9. 22:23

✅ 이번시간에는 BookReview를 MVP로 작성한 코드에 대해서 리뷰해보자

 

여기 포스팅에는 XCTest에 대한 부분은 제외하고 정리했음. 다음 포스팅에 이 부분도 있으니 확인해보기

 

✅ 파일의 구조

파일 구조

 

파일의 구조를 살펴보면 크게 특별한 것은 없어. MVP모델이라서 ViewModel이 보이지 않는 특이점은 있지만..!

그리고 Networking작업을 매번 어떤 파일에서 어떻게 처리할지 고민이었는데, 이번에 보니까 manager로 처리하더라.

물론 UserDefault도!! 

그러니까 Manager를 사용하는 습관을 기르자..!

 

✅ ReviewListViewController.swift

 

여기서 유심히 봐야하는 점음 property를 생성할때 tableViewDelegate가 어디에 위치하는지 보자.

//
//  ViewController.swift
//  BookReview
//
//  Created by Hamlit Jason on 2022/01/07.
//

import UIKit

import SnapKit

final class ReviewListViewController: UIViewController {
    
    private lazy var presenter = ReviewListPresenter(viewController: self)
    
    private lazy var tableView : UITableView = {
        let tableView = UITableView()
        tableView.dataSource = presenter // presenter에 위임한다.
        
        return tableView
    }()
    
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        presenter.viewDidLoad()
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        presenter.viewWillAppear()
        
    }
    
    
}

extension ReviewListViewController: ReviewListProtocol {
    
    func setupNavigationBar() {
        navigationItem.title = "도서 리뷰"
        navigationController?.navigationBar.prefersLargeTitles = true
        
        let rightBarButtonItem = UIBarButtonItem(
            barButtonSystemItem: .add,
            target: self,
            action: #selector(didTapRightBarButtonItem)
        )
        
        navigationItem.rightBarButtonItem = rightBarButtonItem
    }
    
    func setupViews() {
        view.addSubview(tableView)
        tableView.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
    }
    
    func presentToReviewWriteViewController() {
        let vc = UINavigationController(rootViewController: ReviewWriteViewController()) // 루트뷰를 바꾸는거라서 stack의 결과다 달라진다.
        vc.modalPresentationStyle = .fullScreen
        present(vc,animated: true)
    }
    
    func reloadTableView() {
        tableView.reloadData()
        
        print("새로운 테이블 뷰 리로드")
    }
}

extension ReviewListViewController {
    @objc func didTapRightBarButtonItem() {
        presenter.didTapRightBarButtonItem()
    }
}

/*
 MVP에서는
 dataSource는 presenter에서 처리한다.
 데이터를 구성하여 변경하고 호출해주는 것이기 때문에
 
 MVP에서는 presenter가 무조건 viewController 호출을 담당하고
 viewController은 실행을 담당한다.
 이벤트를 뷰 컨트롤러 내에서 직접 전달(호출)하면 안된다.
 */

 

👉 navigationController?.navigationBar.prefersLargeTitles = true

navigationController?.navigationBar.prefersLargeTitles = true

   func presentToReviewWriteViewController() {
        let vc = UINavigationController(rootViewController: ReviewWriteViewController()) // 루트뷰를 바꾸는거라서 stack의 결과다 달라진다.
        vc.modalPresentationStyle = .fullScreen
        present(vc,animated: true)
    }

이 방식을 사용하면 dismiss 상황에서도 원래 코드로 돌아오며, 그리고 화면전환 방식에서 present로 더 다양한 옵션을 사용할 수 있음!

 

🟠 ReviewListPresenter.swift

//
//  BookReviewPresenter.swift
//  BookReview
//
//  Created by Hamlit Jason on 2022/01/07.
//

import Foundation
import UIKit
import Kingfisher

protocol ReviewListProtocol {
    func setupNavigationBar()
    func setupViews()
    func presentToReviewWriteViewController()
    func reloadTableView()
}

final class ReviewListPresenter: NSObject { // NSObject 없다면 UITableViewDataSource 상속 시에 그려야 하는 것들이 많다.
    private let viewController: ReviewListProtocol
    private let userDefaultsManager: UserDefaultsManagerProtocol
    
    private var review: [BookReview] = []
    
    init(
        viewController: ReviewListProtocol,
        userDefaultsManager: UserDefaultsManagerProtocol = UserDefaultsManager()
        
    ) {
        self.viewController = viewController
        self.userDefaultsManager = userDefaultsManager
    }
    
    func viewDidLoad() {
        viewController.setupNavigationBar()
        viewController.setupViews()
    }
    
    func viewWillAppear() {
        // TODO: UserDefault 내용 업데이트 하기
        review = userDefaultsManager.getReviews()
        viewController.reloadTableView()
    }
    
    func didTapRightBarButtonItem() {
        viewController.presentToReviewWriteViewController()
    }
}

extension ReviewListPresenter: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        review.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) // 서브타이틀이 있는 셀 스타일 사용
        let review = review[indexPath.row]
        cell.textLabel?.text = review.title
        cell.detailTextLabel?.text = review.contents
        //cell.imageView?.kf.setImage(with: review.imageURL) // 이렇게 사용하면 리로드 중에 그릴 수 없어서 한번 더 레이아웃을 조정해야함
        cell.imageView?.kf.setImage(with: review.imageURL, placeholder: .none) { _ in
            cell.setNeedsLayout() // 한번 더 레이아웃 업데이트
        }
        
        cell.selectionStyle = .none
        
        return cell
        
        
    }
}

 

👉 setNeedsLayouts() 

 apple document를 살펴보니 레이아웃을 재조정하게끔 trigger하는 함수라고 합니다 

       cell.imageView?.kf.setImage(with: review.imageURL, placeholder: .none) { _ in
            cell.setNeedsLayout() // 한번 더 레이아웃 업데이트
        }

이와 관련해서는 다른 분께서 정리를 잘 해두신 블로그가 있어서 참고하기로..!

https://baked-corn.tistory.com/105

 

[ios] setNeedsLayout vs layoutIfNeeded

[ios] setNeedsLayout vs layoutIfNeeded 안녕하세요. 오늘은 애니매이션 동작에 대해 공부를 하면서 알게 된 내용 중 하나인 setNeedsLayout 과 layoutIfNeeded 에 대해 포스팅해보려 합니다. 아직은 모든 궁금..

baked-corn.tistory.com

 

🟠 ReviewWriteViewController.swift

//
//  ReviewWriteViewController.swift
//  BookReview
//
//  Created by Hamlit Jason on 2022/01/07.
//

import Foundation
import UIKit

import Kingfisher
import Then

final class ReviewWriteViewController: UIViewController {
    private lazy var presenter = ReviewWritePresenter(viewController: self)
    
    private lazy var bookTitleButton: UIButton = {
        let button = UIButton()
        button.setTitle("책 제목", for: .normal)
        button.setTitleColor(.tertiaryLabel, for: .normal)
        button.contentHorizontalAlignment = .left
        button.titleLabel?.font = .systemFont(ofSize: 23.0, weight: .bold)
        button.addTarget(self, action: #selector(didTapBookTitleButton), for: .touchUpInside)
        
        return button
    }()
    
    private lazy var contentsTextView: UITextView = {
        let textView = UITextView()
        textView.textColor = .tertiaryLabel // 옅은 회색
        textView.text = presenter.contentTextViewPlaceHolderText // lazy var라서 가능한 코드!
        textView.font = .systemFont(ofSize: 16.0, weight: .medium)
        textView.delegate = self
        
        return textView
    }()
    
    private lazy var imageView: UIImageView = {
        let imageView = UIImageView()
        imageView.contentMode = .scaleAspectFit
        imageView.clipsToBounds = true // 이 속성은 이미지가 화면보다 크면 넘쳐흐르는 것을 막는다.
        imageView.backgroundColor = .secondarySystemBackground
        
        return imageView
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        presenter.viewDidLoad()
    }
}

extension ReviewWriteViewController: UITextViewDelegate {
    func textViewDidBeginEditing(_ textView: UITextView) {
        guard textView.textColor == .tertiaryLabel else { return } 
        
        textView.text = nil
        textView.textColor = .black
        
    }
}

extension ReviewWriteViewController: ReviewWriteProtocol {
    
    
    func setUpNavigationBar() {
        navigationItem.leftBarButtonItem = UIBarButtonItem(
            barButtonSystemItem: .close,
            target: self,
            action: #selector(didTapLeftBarButton)
        )
        
        navigationItem.rightBarButtonItem = UIBarButtonItem(
            barButtonSystemItem: .save,
            target: self,
            action: #selector(didTapRightBarButton)
        )
    }
    
    func showCloseAlertController() {
        let alertController = UIAlertController(
            title: "작성중인 메시지가 있습니다. 정말 닫으시겠습니까?",
            message: nil,
            preferredStyle: .alert
        )
        
        let closeAction = UIAlertAction(title: "닫기", style: .destructive) { [weak self] _ in
            // 클로저 내에서 weak self는 개발자마다 아직도 의견이 분분하나, 혹시 모를 메모리 누수를 막을 수 있어서 사용하는거 권장
            self?.dismiss(animated: true)
        }
        
        let cancelAction = UIAlertAction(title: "취소", style: .cancel)
        
        [closeAction, cancelAction].forEach {
            alertController.addAction($0)
        }
        
        present(alertController, animated: true)
        
    }
    
    func close() {
        dismiss(animated: true)
    }
    
    func setupViews() {
        view.backgroundColor = .systemBackground

        [bookTitleButton, contentsTextView, imageView]
            .forEach { view.addSubview($0) }

        bookTitleButton.snp.makeConstraints {
            $0.leading.equalToSuperview().inset(20.0)
            $0.trailing.equalToSuperview().inset(20.0)
            $0.top.equalTo(view.safeAreaLayoutGuide).inset(20.0)
        }

        contentsTextView.snp.makeConstraints {
            $0.leading.equalToSuperview().inset(16.0)
            $0.trailing.equalToSuperview().inset(16.0)
            $0.top.equalTo(bookTitleButton.snp.bottom).offset(16.0)
        }

        imageView.snp.makeConstraints {
            $0.leading.equalTo(contentsTextView.snp.leading)
            $0.trailing.equalTo(contentsTextView.snp.trailing)
            $0.top.equalTo(contentsTextView.snp.bottom).offset(16.0)

            $0.height.equalTo(200.0)
            $0.bottom.equalTo(view.safeAreaLayoutGuide)
        }
    }
    
    func presentToSearchBookViewController() {
        let vc = UINavigationController(rootViewController: SearchBookViewContoller(searchBookDelegate: presenter))
        present(vc, animated: true)
    }
    
    func updateViews(title: String, imageURL: URL?) {
        bookTitleButton.setTitle(title, for: .normal)
        bookTitleButton.setTitleColor(.label, for: .normal)
        imageView.kf.setImage(with: imageURL)
    }
}

private extension ReviewWriteViewController {
    @objc func didTapLeftBarButton() {
        presenter.didTapLeftBarButton()
    }
    
    @objc func didTapRightBarButton() {
        presenter.didTapRightBarButton(contentsText: contentsTextView.text)
    }
    
    @objc func didTapBookTitleButton() {
        presenter.didTapBookTitleButton()
    }
}

 

👉 textView는 placeholder를 갖고 있지 않아서, 글자의 색으로 판단하는 코드인데, 유의깊게 보자

extension ReviewWriteViewController: UITextViewDelegate {
    func textViewDidBeginEditing(_ textView: UITextView) {
        guard textView.textColor == .tertiaryLabel else { return } 
        
        textView.text = nil
        textView.textColor = .black
        
    }
}

 

🟠 ReviewWritePresenter.swfit

//
//  ReviewWritePresenter.swift
//  BookReview
//
//  Created by Hamlit Jason on 2022/01/07.
//

import Foundation

protocol ReviewWriteProtocol {
    func setUpNavigationBar()
    func showCloseAlertController()
    func close()
    func setupViews()
    func presentToSearchBookViewController()
    func updateViews(title: String, imageURL: URL?)
}

final class ReviewWritePresenter {
    private let viewController: ReviewWriteProtocol

    private let userDefaultsManager: UserDefaultsManagerProtocol
    
    var book: Book? // private면 좋으나, test작성에서 optional이면 확인을 못하는데, 그렇다면 아이템을 주기 위해 접근이 필요하다. 그래서 private를 해지함
    
    let contentTextViewPlaceHolderText = "내용을 입력해주세요."
    
    init(
        viewController: ReviewWriteProtocol,
        userDefaultsManager: UserDefaultsManagerProtocol = UserDefaultsManager()
    ) {
        self.viewController = viewController
        self.userDefaultsManager = userDefaultsManager
    }
    
    func viewDidLoad() {
        viewController.setUpNavigationBar()
        viewController.setupViews()
    }
    
    func didTapLeftBarButton() {
        viewController.showCloseAlertController()
    }
    
    func didTapRightBarButton(contentsText: String?) {
        // TODO: UserDefault에 유저가 작성한 도서리뷰 저장하기
        
        guard
            let book = book,
            let contentsText = contentsText,
            contentsText != contentTextViewPlaceHolderText
        else { return }
        
        let bookReview = BookReview(
            title: book.title,
            contents: contentsText,
            imageURL: book.imageURL
        )
        
        userDefaultsManager.setReview(bookReview)
        viewController.close()
    }
    
    func didTapBookTitleButton() {
        viewController.presentToSearchBookViewController()
    }
}

extension ReviewWritePresenter: SearchBookDelegate {
    func selectBook(_ book: Book) {
        self.book = book
        
        viewController.updateViews(title: book.title, imageURL: book.imageURL)
        print("🟠 \(book.title)")
    }
}

 

🟠 SearchBookController.swift

여긴 delegate를 사용해야해서 조금 더 복잡한 느낌..?

//
//  SearchViewController.swift
//  BookReview
//
//  Created by Hamlit Jason on 2022/01/07.
//

import Foundation
import UIKit

import SnapKit

final class SearchBookViewContoller: UIViewController {
    
    private lazy var presenter = SearchBookPresenter(
        viewContoller: self,
        delegate: searchBookDelegate
    )
    
    private let searchBookDelegate: SearchBookDelegate
    
    private lazy var tableView: UITableView = {
        let tableView = UITableView()
        tableView.delegate = presenter
        tableView.dataSource = presenter

        return tableView
    }()
    
    init(searchBookDelegate: SearchBookDelegate) {
        self.searchBookDelegate = searchBookDelegate
        
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        presenter.viewDidLoad()
    }
}

extension SearchBookViewContoller: SearchBookProtocol {
    func setupViews() {
        view.backgroundColor = .systemBackground
        
        let searchController = UISearchController()
        searchController.obscuresBackgroundDuringPresentation = false // 서치바 활성화되면 서치바 외에 화면 어두워지는거 false처리
        searchController.searchBar.delegate = presenter // delegate를 self가 아닌 presenter로 하는 이유는 데이터를 가지고 그리는 것은 결과적으로 presenter가 하기 때문에 presenter에 위임해야 한다.
        
        navigationItem.searchController = searchController
        
        view.addSubview(tableView)
        tableView.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
    }
    
    func dismiss() {
        navigationItem.searchController?.dismiss(animated: true) // 서치바를 닫고
        dismiss(animated: true) // 화면을 내려서 터치 한번으로 끝내기.
    }
    
    func reloadView() {
        tableView.reloadData()
    }
}

특별하게 위에 볼 코드는 없었고 그나마 SeachBar.delegate = presenter로 위임하는 코드가 약간 주의깊게 봐야하는 코드!

 

🟠 SearchBookPresenter.swift

//
//  SearchBookPresenter.swift
//  BookReview
//
//  Created by Hamlit Jason on 2022/01/07.
//

import Foundation
import UIKit

protocol SearchBookProtocol {
    func setupViews()
    func dismiss()
    func reloadView()
}

protocol SearchBookDelegate {
    func selectBook(_ book: Book)
}

final class SearchBookPresenter: NSObject {
    private let viewContoller: SearchBookProtocol
    private let bookSearchManager = BookSearchManager()
    private let delegate: SearchBookDelegate
    
    private var books: [Book] = []
    
    init(viewContoller: SearchBookViewContoller,
         delegate: SearchBookDelegate
    ) {
        self.viewContoller = viewContoller
        self.delegate = delegate
    }
    
    func viewDidLoad() {
        viewContoller.setupViews()
    }
}

extension SearchBookPresenter: UISearchBarDelegate {
    func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
        guard let searchText = searchBar.text else { return }
        
        bookSearchManager.request(from: searchText) { [weak self] newBooks in
            self?.books = newBooks
            self?.viewContoller.reloadView()
        }
    }
}

extension SearchBookPresenter: UITableViewDelegate {
    
}

extension SearchBookPresenter: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        books.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = UITableViewCell()
        cell.textLabel?.text = books[indexPath.row].title
        
        return cell
    }
    
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let selectedBook = books[indexPath.row]
        delegate.selectBook(selectedBook)
        
        viewContoller.dismiss() 
    }
}

 여기서는 BookManager를 통해서 query요청하는 로직을 꼼꼼하게보자

 

🟠 Book.swift 

//
//  Book.swift
//  BookReview
//
//  Created by Hamlit Jason on 2022/01/08.
//

import Foundation

struct Book: Codable {
    let title: String
    private let image: String?
    
    var imageURL: URL? { URL(string: image ?? "" ) }
    
    init(title: String, imageURL: String?) {
        self.title = title
        self.image = imageURL
    }
}

여긴 좀 유심히 봐야하겠다 싶었음. 왜나하면 Model에 init키워드를 사용했다는 점인데, 예전에는 이런 경운 EMPTY 변수를 하나 선언해서 사용했었음. 하지만 더욱 좋은 코드를 위해서는 EMPTY가 아니라 그냥 init을 구현해서 사용하는 것이겠지?

그 이유는 유닛 테스트 과정에서 이렇게 사용해야 접근해서 사용할 수 있다는 것임.

옵셔널 값을 넣어주기 위해서!!

 

🟠 BookSearchManager.swift

//
//  BookSearchManager.swift
//  BookReview
//
//  Created by Hamlit Jason on 2022/01/08.
//

import Foundation

import Alamofire

struct BookSearchManager {
    func request(from keyword: String, completionHandler: @escaping (([Book]) -> Void)) {
        guard let url = URL(string: "https://openapi.naver.com/v1/search/book.json") else { return }

        let parameters = BookSearchRequestModel(query: keyword)
        let headers: HTTPHeaders = [
            "X-Naver-Client-Id": "{YOUR API KEY}",
            "X-Naver-Client-Secret": "{YOUR API KEY}"
        ]

        AF
            .request(url, method: .get, parameters: parameters, headers: headers)
            .responseDecodable(of: BookSearchResponseModel.self) { response in // decodable로 받은 데이터를 파싱. 타입은 of안에 있는 모델로
                switch response.result {
                case .success(let result):
                    completionHandler(result.items) // complectionHandler가 있어야 받는 쪽에서 바로 사용할 수 있도록 하기 위함 -> 테이블 뷰를 바로 그릴 수 있게끔
                case .failure(let error):
                    print(error.localizedDescription)
                }
            }
            .resume()
    }
}

매니저 부분이 조금 신기했는데, 이렇게 작성하면 디코딩이랑 핸들러 처리까지 너무 깔끔해서 배워가는 코드이다..!

 

🟠 UserDefaultsManager.swift

//
//  UserDefaultsManager.swift
//  BookReview
//
//  Created by Hamlit Jason on 2022/01/09.
//


import Foundation

protocol UserDefaultsManagerProtocol {
    func getReviews() -> [BookReview]
    func setReview(_ newValue: BookReview)
}

struct UserDefaultsManager: UserDefaultsManagerProtocol {
    enum Key: String {
        case review
    }

    func getReviews() -> [BookReview] {
        guard let data = UserDefaults.standard.data(forKey: Key.review.rawValue) else { return [] }

        return (try? PropertyListDecoder().decode([BookReview].self, from: data)) ?? []
    }

    func setReview(_ newValue: BookReview) {
        var currentReviews: [BookReview] = getReviews()
        currentReviews.insert(newValue, at: 0)

        UserDefaults.standard.setValue(
            try? PropertyListEncoder().encode(currentReviews),
            forKey: Key.review.rawValue
        )
    }
}

이 코드도 상당히 유심히 보게 되었는데...! 그 이유는 바로..! 값이 인코딩이랑 디코딩하는 부분도 처음봐서 신기했고, insert하는 것도 그렇고 배울점이 많은 코드라서..!