Archive/패캠(초격차)

part5 (ch6). MovieReview 코드리뷰

lgvv 2022. 1. 31. 02:10

✅MovieReview 코드리뷰 고고

 

MVP방식으로 작성되었고 쉽진 않았지만 그래도 두번째라 그런지 잘 이해할 수 있었음.

 

다만 강의의 코드에서 약간의 문제점이 발생하였는데, 컬렉션 뷰에서 didSelect를 통해서 Detail로 이동시 rightBarButton이 그대로 노출되어 있어서 중복 구독이 가능해진다는 점이었는데, 생각해보니까 강의에서 remove를 불완전하게 구현하신 듯 하다.

 

✅폴더 구조

폴더 구조

우선 폴더 구조가 이렇게 되어 있어서 이에 맞게끔 코드 리뷰를 진행해볼 예정

 

✅Scene - MovieList

 

🟠 MovieListViewController.swift

//
//  ViewController.swift
//  MovieReview
//
//  Created by Hamlit Jason on 2022/01/17.
//

import UIKit
import Then
import SnapKit

class MovieListViewController: UIViewController {

    private lazy var presenter = MovieListPresenter(viewController: self)
    
    private let searchController = UISearchController()
    
    private lazy var collectionView: UICollectionView = {
        let collectionViewLayout = UICollectionViewFlowLayout()

        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout)
        collectionView.delegate = presenter
        collectionView.dataSource = presenter

        collectionView.register(
            MovieListCollectionViewCell.self,
            forCellWithReuseIdentifier: MovieListCollectionViewCell.id
        )
        return collectionView
    }()

    private lazy var searchResultTableView: UITableView = {
        let tableView = UITableView()
        tableView.delegate = presenter
        tableView.dataSource = presenter
        
        return tableView
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        presenter.viewDidLoad()
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        presenter.viewWillAppear()
    }
}

extension MovieListViewController: MovieListProtocol {
    func setupNavigationBar() {
        navigationItem.title = "영화 평점"
        navigationController?.navigationBar.prefersLargeTitles = true
        navigationItem.largeTitleDisplayMode = .always
    }

    func setupSearchBar() {
        searchController.obscuresBackgroundDuringPresentation = false
        searchController.searchBar.delegate = presenter

        navigationItem.searchController = searchController
    }

    func setupViews() {
        [collectionView, searchResultTableView].forEach {
            view.addSubview($0)
        }

        collectionView.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }

        searchResultTableView.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }

        searchResultTableView.isHidden = true
    }

    func updateSearchTableView(isHidden: Bool) {
        print("updateSearchTableView \(isHidden) ")
        searchResultTableView.isHidden = isHidden
        searchResultTableView.reloadData()
    }
    
    func pushToMovieViewController(with movie: Movie) {
        let movieDetailViewController = MovieDetailViewController(movie: movie)
        navigationController?.pushViewController(movieDetailViewController, animated: true)
    }
    
    func updateCollectionView() {
        collectionView.reloadData()
    }
}

컨트롤러 에서는 크게 어려운 점이 없었다..!

 

🟠MovieListPresenter.swift

//
//  MovieListPresenter.swift
//  MovieReview
//
//  Created by Hamlit Jason on 2022/01/20.
//

import UIKit

protocol MovieListProtocol: AnyObject {
    func setupNavigationBar()
    func setupSearchBar()
    func setupViews()
    func updateSearchTableView(isHidden: Bool)
    func pushToMovieViewController(with movie: Movie)
    func updateCollectionView()
}

// MSObject는 서치바의 딜리게이트를 따르기 위해서 필요함.
final class MovieListPresenter: NSObject {
    // weak var의 경우에는 화면 사라짐이 빈번한 경우 메모리 낭비를 막기 위해 조금 더 안전하게 사용하는 방법
    private weak var viewController: MovieListProtocol?

    private let userDefaultsManager: UserDefaultsManagerProtocol
    private let movieSearchManager: MovieSearchManagerProtocol

    private var likedMovie: [Movie] = []

    private lazy var currentMovieSearchResult: [Movie] = []

    init(
        viewController: MovieListProtocol,
        movieSearchManager: MovieSearchManagerProtocol = MovieSearchManager(),
        userDefaultsManager: UserDefaultsManagerProtocol = UserDefaultsManager()
    ) {
        self.viewController = viewController
        self.movieSearchManager = movieSearchManager
        self.userDefaultsManager = userDefaultsManager
    }

    func viewDidLoad() {
        viewController?.setupNavigationBar()
        viewController?.setupSearchBar()
        viewController?.setupViews()
    }
    
    func viewWillAppear() {
        likedMovie = userDefaultsManager.getMovies()
        viewController?.updateCollectionView()
    }
}

extension MovieListPresenter: UISearchBarDelegate {

    func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
        viewController?.updateSearchTableView(isHidden: false)
    }

    func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
        currentMovieSearchResult = []
        viewController?.updateSearchTableView(isHidden: true)

    }

    func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
        movieSearchManager.request(from: searchText, completionHandler: { [weak self] movies in
            print(movies)
            self?.currentMovieSearchResult = movies
            self?.viewController?.updateSearchTableView(isHidden: false)
        })
    }
}

extension MovieListPresenter: UICollectionViewDelegateFlowLayout {
    func collectionView(
        _ collectionView: UICollectionView,
        layout collectionViewLayout: UICollectionViewLayout,
        sizeForItemAt indexPath: IndexPath) -> CGSize {
            let spacing: CGFloat = 16
            let width: CGFloat = (collectionView.frame.width - spacing * 3) / 2

            return CGSize(width: width, height: 210.0)
        }

    func collectionView(
        _ collectionView: UICollectionView,
        layout collectionViewLayout: UICollectionViewLayout,
        insetForSectionAt section: Int) -> UIEdgeInsets {
            let inset: CGFloat = 16.0
            return UIEdgeInsets(top: inset, left: inset, bottom: inset, right: inset)
        }
    
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        let movie = likedMovie[indexPath.item]
        viewController?.pushToMovieViewController(with: movie)
    }
}

extension MovieListPresenter: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return likedMovie.count
    }

    func collectionView(
        _ collectionView: UICollectionView,
        cellForItemAt indexPath: IndexPath
    ) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(
            withReuseIdentifier: MovieListCollectionViewCell.id,
            for: indexPath
        ) as? MovieListCollectionViewCell

        let movie = likedMovie[indexPath.item]
        cell?.update(movie)

        return cell ?? UICollectionViewCell()
    }
}

extension MovieListPresenter: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let movie = currentMovieSearchResult[indexPath.row]
        viewController?.pushToMovieViewController(with: movie)
    }
}

extension MovieListPresenter: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        print("tableView_numberOfRowsInSection")
        return currentMovieSearchResult.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = UITableViewCell()
        cell.textLabel?.text = currentMovieSearchResult[indexPath.row].title

        return cell
    }
}

// weak var와 unowned let 면접에 단골이니 꼭 알아보기

여기서는 init 부분에서 초기값을 넣어주는 방식이 신선했다. 사실 이해가는 함수 작성방식이긴 하지만, init 부분이 약해서 따로 공부해야할 필요가 있겠다.

 

🟠 MovieListCollectionViewCell.swift

//
//  MovieListCollectionViewCell.swift
//  MovieReview
//
//  Created by Hamlit Jason on 2022/01/21.
//

import UIKit
import SnapKit
import Then
import Kingfisher

final class MovieListCollectionViewCell: UICollectionViewCell {
    static let id = "MovieListCollectionView"

    private lazy var imageView = UIImageView().then {
        $0.clipsToBounds = true // 이미지가 넘치지 않게끔
        $0.contentMode = .scaleAspectFill
        $0.backgroundColor = .secondarySystemBackground
    }

    private lazy var titleLabel = UILabel().then {
        $0.font = .systemFont(ofSize: 14.0, weight: .semibold)
    }

    private lazy var userRatingLabel = UILabel().then {
        $0.font = .systemFont(ofSize: 13.0, weight: .medium)
    }

    func update(_ movie: Movie) {
        setupView()
        setupLayout()
    
        imageView.kf.setImage(with: movie.imageURL)
        titleLabel.text = movie.title
        userRatingLabel.text = "⭐️ \(movie.userRating)"
    
    }
    
}

private extension MovieListCollectionViewCell {
    func setupView() {
        layer.cornerRadius = 12.0
        layer.shadowColor = UIColor.black.cgColor
        layer.shadowOpacity = 0.1 // 투명도
        layer.shadowRadius = 8.0
        
        backgroundColor = .systemBackground // 이게 설정되어야 그림자 잘보임
        
    }
    
    func setupLayout() {
        [imageView, titleLabel, userRatingLabel].forEach{ self.addSubview($0) }
        
        imageView.snp.makeConstraints {
            $0.leading.equalToSuperview().inset(16.0)
            $0.trailing.equalToSuperview().inset(16.0)
            $0.top.equalToSuperview().inset(16.0)
            $0.height.equalTo(imageView.snp.width)
        }
        
        titleLabel.snp.makeConstraints {
            $0.top.equalTo(imageView.snp.bottom).offset(8.0)
            $0.leading.equalTo(imageView.snp.leading)
            $0.trailing.equalTo(imageView.snp.trailing)
        }
        
        userRatingLabel.snp.makeConstraints {
            $0.leading.equalTo(imageView.snp.leading)
            $0.trailing.equalTo(imageView.snp.trailing)
            $0.top.equalTo(titleLabel.snp.bottom).offset(4.0)
            $0.bottom.equalToSuperview().inset(8.0)
        }
    }
}

그림자를 주는 코드를 유심히 볼것..! setupViews() 에서 구현되었는데, 셀 하나하나가 정말 예쁘게 나오니까 작은 디테일까지 더 신경쓰는 개발자가 되자! 

그림자를 넣읍시다:)

 

✅Scene - MovieDetail

 

🟠MovieDetailViewController.swift

//
//  MovieDetailViewController.swift
//  MovieReview
//
//  Created by Hamlit Jason on 2022/01/30.
//

import Foundation
import UIKit
import Then
import SnapKit
import Kingfisher

class MovieDetailViewController: UIViewController {
    
    private var presenter: MovieDetailPresenter!
    
    private lazy var rightBarButtonItem = UIBarButtonItem(
        image: UIImage(systemName: "star"),
        style: .plain,
        target: self, // self는 본인이 초기화될 때 본인을 못넣기 때문에 lazy를 사용해야 한다.
        action: #selector(didTapRightBarButtonItem)
    )
    
    private lazy var imageView = UIImageView().then {
        $0.clipsToBounds = true
        $0.contentMode = .scaleAspectFit
        $0.backgroundColor = .secondarySystemBackground
    }
    
    init(movie: Movie) {
        super.init(nibName: nil, bundle: nil)
        
        presenter = MovieDetailPresenter(
            viewController: self,
            movie: movie
        )
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()

        presenter.viewDidLoad()
    }
}

extension MovieDetailViewController: MovieDetailProtocol {
    
    func setViews(with movie: Movie) {
        view.backgroundColor = .systemBackground
        
        navigationItem.title = movie.title
        navigationItem.rightBarButtonItem = rightBarButtonItem
        
        let userRatingContentsStackView = MovieContentStackView(title: "평점", contents: movie.userRating)
        let actorContentsStackView = MovieContentStackView(title: "배우", contents: movie.actor)
        let directorContentsStackView = MovieContentStackView(title: "감독", contents: movie.director)
        let pubDateContentsStackView = MovieContentStackView(title: "제작년도", contents: movie.pubDate)
        
        let contentsStackView = UIStackView()
        contentsStackView.axis = .vertical
        contentsStackView.spacing = 8.0
        
        [
            userRatingContentsStackView,
            actorContentsStackView,
            directorContentsStackView,
            pubDateContentsStackView
        ].forEach {
            contentsStackView.addArrangedSubview($0)
        }
        
        
        [imageView, contentsStackView].forEach { view.addSubview($0) }
        
        let inset: CGFloat = 16.0
        
        imageView.snp.makeConstraints {
            $0.top.equalTo(view.safeAreaLayoutGuide.snp.topMargin).inset(inset)
            $0.leading.equalToSuperview().inset(inset)
            $0.trailing.equalToSuperview().inset(inset)
            $0.height.equalTo(imageView.snp.width)
        }
        
        contentsStackView.snp.makeConstraints {
            $0.leading.equalTo(imageView.snp.leading)
            $0.trailing.equalTo(imageView.snp.trailing)
            $0.top.equalTo(imageView.snp.bottom).offset(inset)
        }
        
        // 이미지 URL이 존재할때만 실행
        if let imageURL = movie.imageURL {
            imageView.kf.setImage(with: movie.imageURL)
        }
    }
    
    func setRightbarButton(with isLiked: Bool) {
        let imageName = isLiked ? "star.fill" : "star"
        rightBarButtonItem.image = UIImage(systemName: imageName)
    }
}

private extension MovieDetailViewController {
    @objc func didTapRightBarButtonItem() {
        presenter.didTapRightBarButtonItme()
    }
}

여기서 눈여겨 볼만한 점은 바로 stackView 자체를 따로 하나의 파일로 빼서 작성했다는 점이고, 이를 재사용한다는 아이디어이다. 스택뷰는 마지막에 나오니까 차차 살펴보기로 하고, 이번 회사 플젝에서도 UI가 View로 따로 빠져 있었는데, 이게 무슨 말이냐면 VC를 구성하는 컴포넌트들을 따로 모아서 작성했다는 말이다!  아무튼 아이디어가 신선하다.

 

🟠MovieDetailPresenter.swift

//
//  MoviewDetailPresenter.swift
//  MovieReview
//
//  Created by Hamlit Jason on 2022/01/30.
//

import Foundation

protocol MovieDetailProtocol: AnyObject {
    func setViews(with movie: Movie)
    func setRightbarButton(with isLiked: Bool)
}

final class MovieDetailPresenter {
    
    private weak var viewCotnroller: MovieDetailProtocol?

    private let userDefaultsManager: UserDefaultsManagerProtocol
    
    private var movie: Movie
    
    init(
        viewController: MovieDetailProtocol,
        movie: Movie,
        userDefaultsManager: UserDefaultsManagerProtocol = UserDefaultsManager()
    ) {
        self.viewCotnroller = viewController
        self.movie = movie
        self.userDefaultsManager = userDefaultsManager
    }
    
    func viewDidLoad() {
        viewCotnroller?.setViews(with: movie)
        viewCotnroller?.setRightbarButton(with: movie.isLiked)
    }
    
    func didTapRightBarButtonItem() {
        
        movie.isLiked.toggle() // Boolean 타입에서 값을 반대로 바꿔줌
        if movie.isLiked {
            userDefaultsManager.addMovie(movie)
        } else {
            userDefaultsManager.removeMovie(movie)
        }
        
        viewCotnroller?.setRightbarButton(with: movie.isLiked)
    }
}

여기서는 조금 볼게 많은데, movie를 초기화 해줌으로써, VC에서 사용할 수 있게끔 만들었다. 또한 toggle은 Boolean 타입에 지원되는 메소드로 활용하면 좋을거 같고, didTapRightBarButtonItems 부분의 코드가 정말 깔끔하게 정리되어 있다.

 

 

🟠MovieContentStackView.swift

//
//  MovieContentStackView.swift
//  MovieReview
//
//  Created by Hamlit Jason on 2022/01/30.
//

import Foundation
import SnapKit
import Then
import UIKit
import SwiftUI

final class MovieContentStackView: UIStackView {
    
    private let title: String
    private let contents: String
    
    private lazy var titleLabel = UILabel().then {
        $0.font = .systemFont(ofSize: 14.0, weight: .semibold)
        $0.text = title
    }
    
    private lazy var contentsLabel = UILabel().then {
        $0.font = .systemFont(ofSize: 14.0, weight: .medium)
        $0.text = contents
    }
    
    init(title: String, contents: String) {
        self.title = title
        self.contents = contents
        
        super.init(frame: .zero)
        
        // super.init 다음에 이 코드를 넣어야 함. 
        axis = .horizontal
        [titleLabel, contentsLabel].forEach {
            addArrangedSubview($0) // self 생략가능
        }
        
        // 스택뷰라서 타이틀만 잡으면 컨텐츠는 알아서 결정이 된다.
        titleLabel.snp.makeConstraints {
            $0.width.equalTo(80.0)
        }
    }
    
    required init(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

스택 뷰 부분도 엄청나게 특이하다...! 어떤 점이 특이하냐면 super.init 다음에 코드를 작성해야 한다고 하는데, 이 순서가 라이프사이클에 영향을 미치는거 같다. 하지만 ㅠ 이 부분을 제대로 학습하지 않아서 다음에 꼭 제대로 학습해서 포스팅을 해봐야겠다. 또한 스택뷰라서 하나만 잡은 아이디어도 신선했다.

 

✅Models 파일

 

🟠Movie

//
//  Movie.swift
//  MovieReview
//
//  Created by Eunyeong Kim on 2021/08/23.
//

import Foundation

struct Movie: Codable {
    let title: String
    private let image: String
    let userRating: String
    let actor: String
    let director: String
    let pubDate: String

    var isLiked: Bool

    var imageURL: URL? { URL(string: image) }

    
    // isLiked를 사용하기 위함인데, 이렇게 하지 않으면 네이버 API에서 받은 값을 사용할 수 없기 때문에
    private enum CodingKeys: String, CodingKey {
        case title, image, userRating, actor, director, pubDate
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)

        title = try container.decodeIfPresent(String.self, forKey: .title) ?? "-"
        userRating = try container.decodeIfPresent(String.self, forKey: .userRating) ?? "-"
        actor = try container.decodeIfPresent(String.self, forKey: .actor) ?? "-"
        director = try container.decodeIfPresent(String.self, forKey: .director) ?? "-"
        pubDate = try container.decodeIfPresent(String.self, forKey: .pubDate) ?? "-"
        image = try container.decodeIfPresent(String.self, forKey: .image) ?? ""

        isLiked = false
    }

    init(
        title: String,
        imageURL: String,
        userRating: String,
        actor: String,
        director: String,
        pubDate: String,
        isLiked: Bool = false
    ) {
        self.title = title
        self.image = imageURL
        self.userRating = userRating
        self.actor = actor
        self.director = director
        self.pubDate = pubDate
        self.isLiked = isLiked
    }
}

와 이 부분은 진짜 레전드였다... 기존에는 네이버 API에서 내려온 파일을 가져다가 쓰려고 작성되었으나, 이후에 isLiked 변수를 추가하면서 파일구조가 바뀌었는데, 네이버 API파싱과 isLiked사용을 위해 모델을 바꾸는 과정이 진짜 어렵게 느껴졌다. 우선 모델을 이렇게 안바꾸면 API파싱에서 에러가 난다고 하셨다. 이건 눈에 익게 해두고 코딩키 부분을 다시 한번 보도록하자.

 

🟠나머지 모델 파일들

//
//  MovieSearchRequestModel.swift
//  MovieReview
//
//  Created by Hamlit Jason on 2022/01/20.
//

import Foundation

struct MovieSearchRequestModel: Codable {
    let query: String
}

⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️ ⭐️⭐️

//
//  MovieSearchResponseModel.swift
//  MovieReview
//
//  Created by Hamlit Jason on 2022/01/20.
//

import Foundation

struct MovieSearchResponseModel: Codable {
    var items: [Movie] = []
}

나머지 두 모델파일을 이렇게 작성되어 있다.

 

✅Manager

 

🟠MovieSearchManager.swift

//
//  MovieSearchManager.swift
//  MovieReview
//
//  Created by Hamlit Jason on 2022/01/20.
//

import Foundation
import UIKit
import Alamofire

protocol MovieSearchManagerProtocol {
    func request(from keyword: String, completionHandler: @escaping ([Movie]) -> Void)
}

struct MovieSearchManager: MovieSearchManagerProtocol {
    func request(from keyword: String, completionHandler: @escaping ([Movie]) -> Void) {
        guard let url = URL(string:"https://openapi.naver.com/v1/search/movie.json") else {
            return
        }
        
        let parameters = MovieSearchRequestModel(query: keyword)
        let header: HTTPHeaders = [
            "X-Naver-Client-Id": "{YOUR API KEY}",
            "X-Naver-Client-Secret": "{YOUR APU KEY}"
        ]
        
        AF.request(
            url,
            method: .get,
            parameters: parameters,
            headers: header
        )
            .responseDecodable(of: MovieSearchResponseModel.self) { response in
                switch response.result {
                case .success(let result):
                    completionHandler(result.items)
                case .failure(let error):
                    print(error)
                }
            }
            .resume()
    }
}

이건 뭐 AF이용해서 작성하는거라 그다지 어렵지 않았음!

 

🟠UserDefaultsManager.swift

//
//  UserDefaultsManager.swift
//  MovieReview
//
//  Created by Hamlit Jason on 2022/01/31.
//

import Foundation

protocol UserDefaultsManagerProtocol {
    func getMovies() -> [Movie]
    func addMovie(_ newValue: Movie)
    func removeMovie(_ value: Movie)
}

struct UserDefaultsManager: UserDefaultsManagerProtocol {
    enum Key: String {
        case movie
    }
    
    func getMovies() -> [Movie] {
        guard let data = UserDefaults.standard.data(forKey: Key.movie.rawValue) else { return [] }
        
        return (try? PropertyListDecoder().decode([Movie].self, from: data)) ?? []
    }
    
    func addMovie(_ newValue: Movie) {
        var currentMovies: [Movie] = getMovies()
        currentMovies.insert(newValue, at: 0)
        
        saveMovie(currentMovies)
    }
    
    func removeMovie(_ value: Movie) {
        let currentMovies: [Movie] = getMovies()
        let newValue = currentMovies.filter {
            $0.title != value.title
        }
        
        saveMovie(newValue)
    }
    
    private func saveMovie(_ newValue: [Movie]) {
        UserDefaults.standard.set(
            try? PropertyListEncoder().encode(newValue),
            forKey: Key.movie.rawValue
        )
    }
}

여기도 인코딩이랑 디코딩하는 것 빼면은 그렇게 어렵지는 않았다.