【Swift】MVVM勉強してみたPart1

#はじめに
MVVMを勉強していくなかでのアウトプットです！

#MVVM

![スクリーンショット 2021-04-04 21.58.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/954451/8ece4d0d-257d-ebd2-84e6-e5532328ca7e.png)

#GitHub
https://github.com/0429oonishi/MVVMGitHubAPI

###Model層の役割
MVCのModelの役割と同じ

###ViewModel層の役割
MVVMにおいて中心的な役割を持つクラス
ModelとView（ViewController）層の仲介役
・Modelからデータを受け取り、それらをUIに反映できるような形で出力する
・View, ViewControllerからユーザーのアクションを受け取り、Modelに伝え、Modelからデータを受け取りUIに反映できるような形で出力する

###View, ViewController層の役割
Viewの役割はMVCのViewの役割と同じ
ViewControllerの役割はViewとViewModelの仲介役を行うこと
・ViewModelから受け取った出力をViewに反映させてUIを更新する
・ユーザーのアクションをViewModelに伝え、ViewModelから新しい出力を受け取り、Viewに反映させてUIを更新する

#実装
今回はMVVMを用いてGitHubクライアントアプリを作ります。
RxSwiftなどのフレームワークを導入した方がいいのですが、今回は使わずにシンプルなものでやっていきたいと思います。
以下のような役割分担で実装していきましょう。
Model
・User
・API
・ImageDownloader
ViewModel
・UserListViewModel
・UserCellViewModel
View/ViewController
・TimeLineCell
・TimeLineCellController

###Model層

```swift:User
struct User {
    let id: Int
    let name: String
    let iconUrl: String
    let webUrl: String
    init(attributes: [String: Any]) {
        self.id = attributes["id"] as! Int
        self.name = attributes["login"] as! String
        self.iconUrl = attributes["avatar_url"] as! String
        self.webUrl = attributes["html_url"] as! String 
    }
}
```

APIクラスは以下のような役割です。
・ https://api.github.com/users にリクエストを送る
・受け取ったjsonからUserの配列を作成してClosureで返す
・ErrorがあったらErrorをClosureで返す

```swift:API
typealias ResultHandler<T> = (Result<T, Error>) -> Void

enum APIError: Error, CustomStringConvertible  {
    case unknown
    case invalidURL
    case invalidResponse
    var description: String {
        switch self {
        case .unknown: return "不明なエラーです"
        case .invalidURL: return "無効なURLです"
        case .invalidResponse: return "フォーマットが無効なレスポンスを受け取りました"
        }
    }
}

final class API {
    
    func getUsers(handler: @escaping ResultHandler<[User]>) {
        let requestUrl = URL(string: "https://api.github.com/users")
        guard let url = requestUrl else {
            handler(.failure(APIError.invalidURL))
            return
        }
        var request = URLRequest(url: url)
        request.httpMethod = "GET"
        request.timeoutInterval = 10
        let task = URLSession.shared.dataTask(with: request) { data, response, error in
            if let error = error {
                DispatchQueue.main.async {
                    handler(.failure(error))
                }
                return
            }
            guard let data = data else {
                DispatchQueue.main.async {
                    handler(.failure(APIError.unknown))
                }
                return
            }
            guard let jsonOptional = try? JSONSerialization.jsonObject(with: data, options: []),
                  let jsons = jsonOptional as? [[String: Any]] else {
                DispatchQueue.main.async {
                    handler(.failure(APIError.invalidResponse))
                }
                return
            }
            var users = [User]()
            jsons.forEach { json in
                let user = User(attributes: json)
                users.append(user)
            }
            DispatchQueue.main.async {
                handler(.success(users))
            }
        }
        task.resume()
    }
    
}
```

ImageDownloaderクラスは以下のような役割です。
・画像をダウンロードしたら一時的にキャッシュし、再度ダウンロードしようとした時にキャッシュがあればキャッシュされたUIImageを返す。
・画像をダウンロード成功したらUIImageを返す
・Errorがあったら、Errorを返す

```swift:ImageDownloader 
final class ImageDownloader {
    
    var catchImage: UIImage?
    
    func downloadImge(imageURL: String, handler: @escaping ResultHandler<UIImage>) {
        if let catchImage = catchImage {
            handler(.success(catchImage))
        }
        var request = URLRequest(url: URL(string: imageURL)!)
        request.httpMethod = "GET"
        let task = URLSession.shared.dataTask(with: request) { data, response, error in
            if let error = error {
                DispatchQueue.main.async {
                    handler(.failure(error))
                }
                return
            }
            guard let data = data else {
                DispatchQueue.main.async {
                    handler(.failure(APIError.unknown))
                }
                return
            }
            guard let imageFromData = UIImage(data: data) else {
                DispatchQueue.main.async {
                    handler(.failure(APIError.unknown))
                }
                return
            }
            DispatchQueue.main.async {
                handler(.success(imageFromData))
            }
            self.catchImage = imageFromData
        }
        task.resume()
    }
    
}
```

###ViewModel層

UserListViewModelはtableView全体に対して通知を送り、UserCellViewModelは一つ一つのtableViewCellに対して通知を送ります。
UserListViewModelは以下のような役割を持っています。
・APIクラスからuserの配列を受け取る
・userの配列分だけUserCellViewModelを作成して保持する
・現在通信中か通信が成功したのか、失敗したのかの状態を持ち、その状態をViewControllerに伝える
・tableViewを表示するために必要なアプトプットを出力する

```swift:UserListViewModel 
enum ViewModelState {
    case loading
    case finish
    case error(Error)
}

final class UserListViewModel {
    
    var stateDidUpdate: ((ViewModelState) -> Void)?
    private var users = [User]()
    var cellViewModels = [UserCellViewModel]()
    let api = API()
    
    func getUsers() {
        stateDidUpdate?(.loading)
        users.removeAll()
        api.getUsers { result in
            switch result {
            case .success(let users):
                self.users.append(contentsOf: users)
                users.forEach { user in
                    let cellViewModel = UserCellViewModel(user: user) 
                    self.cellViewModels.append(cellViewModel)
                    self.stateDidUpdate?(.finish)
                }
            case .failure(let error):
                self.stateDidUpdate?(.error(error))
            }
        }
    }
    
    func usersCount() -> Int {
        return users.count
    }
    
}
```

UserCellViewModelは以下のような役割を持っています。
・ImageDownloaderからユーザーのiconをダウンロードする
・Imageダウンロード中か、ダウンロード終了か、エラーかの状態をもち、通知を送る
・cellの見た目に反映させるアウトプットをする

```swift:UserCellViewModel
enum ImageDownloadProgress {
    case loading(UIImage)
    case finish(UIImage)
    case error
}

final class UserCellViewModel {
    
    private var user: User
    private let imageDownloader = ImageDownloader()
    private var isLoading = false
    var nickName: String {
        return user.name
    }
    var webUrl: URL {
        return URL(string: user.webUrl)!
    }
    init(user: User) {
        self.user = user
    }
    
    func downloadImage(progress: @escaping (ImageDownloadProgress) -> Void) {
        if isLoading {
            return
        } else {
            isLoading.toggle()
        }
        let loadingImage = UIImage(color: .gray, size: CGSize(width: 45, height: 45))!
        progress(.loading(loadingImage))
        imageDownloader.downloadImge(imageURL: user.iconUrl) { result in
            switch result {
            case .success(let image):
                progress(.finish(image))
                self.isLoading = false
            case .failure(_):
                progress(.error)
                self.isLoading = false
            }
        }
    }
}
````

###View層

```swift:TimeLineCell
final class TimeLineCell: UITableViewCell {
    
    static var toString: String {
        return String(describing: self)
    }
    static let id = TimeLineCell.toString
    static func nib() -> UINib {
        return UINib(nibName: TimeLineCell.toString, bundle: nil)
    }
    private var iconView: UIImageView!
    private var nickNameLabel: UILabel!
    
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        
        iconView = UIImageView()
        iconView.clipsToBounds = true
        contentView.addSubview(iconView)
        nickNameLabel = UILabel()
        nickNameLabel.font = .systemFont(ofSize: 15)
        contentView.addSubview(nickNameLabel)
        
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
  
    override func layoutSubviews() {
        super.layoutSubviews()
        
        iconView.frame = CGRect(x: 15,
                                y: 15,
                                width: 45,
                                height: 45)
        iconView.layer.cornerRadius = iconView.frame.size.width / 2
        nickNameLabel.frame = CGRect(x: iconView.frame.maxX + 15,
                                     y: iconView.frame.origin.y,
                                     width: contentView.frame.width - iconView.frame.maxX - 15 * 2,
                                     height: 15)
        
    }
    
    func setNickName(nickName: String) {
        nickNameLabel.text = nickName
    }
    
    func setIcon(icon: UIImage) {
        iconView.image = icon
    }
    
}
```

###ViewController層

TimeLineViewControllerは以下のような役割を持っています。
・UserListViewModelから通知を受け取り、tableViewを更新する
・UserListViewModelの保持するUserCellViewModelから通知を受け取り、画像を更新するまたcellに必要なUserCellViewModelのアウトプットをCellにセットする
・Cellを選択したらそのユーザーのGitHubページへ画面遷移する

```swift:TimeLineViewController
import UIKit
import SafariServices

final class TimeLineViewController: UIViewController {
    
    private var viewModel: UserListViewModel!
    private var tableView: UITableView!
    private var refreshControl: UIRefreshControl!

    override func viewDidLoad() {
        super.viewDidLoad()
        
        tableView = UITableView(frame: view.bounds, style: .plain)
        tableView.delegate = self
        tableView.dataSource = self
        tableView.register(TimeLineCell.self, forCellReuseIdentifier: TimeLineCell.id)
        self.view.addSubview(tableView)
        
        refreshControl = UIRefreshControl()
        refreshControl.addTarget(self, action: #selector(refreshControlValueDidChange(sender: )), for: .valueChanged)
        tableView.refreshControl = refreshControl
        
        viewModel = UserListViewModel()
        viewModel.stateDidUpdate = { [weak self] state in
            switch state {
            case .loading:
                self?.tableView.isUserInteractionEnabled = false
            case .finish:
                self?.tableView.isUserInteractionEnabled = true
                self?.tableView.reloadData()
            case .error(let error):
                self?.tableView.isUserInteractionEnabled = true
                self?.refreshControl.endRefreshing()
                let alert = UIAlertController(title: error.localizedDescription, message: nil, preferredStyle: .alert)
                alert.addAction(UIAlertAction(title: "OK", style: .cancel, handler: nil))
                self?.present(alert, animated: true, completion: nil)
            }
        }
        viewModel.getUsers()

    }

    @objc private func refreshControlValueDidChange(sender: UIRefreshControl) {
        viewModel.getUsers()
    }

}

extension TimeLineViewController: UITableViewDelegate {
    
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return 75
    }
    
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: false)
        let cellViewModel = viewModel.cellViewModels[indexPath.row]
        let webUrl = cellViewModel.webUrl
        let webViewController = SFSafariViewController(url: webUrl)
        present(webViewController, animated: true)
    }
    
}

extension TimeLineViewController: UITableViewDataSource {
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return viewModel.usersCount()
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        if let timeLineCell = tableView.dequeueReusableCell(withIdentifier: TimeLineCell.id) as? TimeLineCell {
            let cellViewModel = viewModel.cellViewModels[indexPath.row]
            timeLineCell.setNickName(nickName: cellViewModel.nickName)
            cellViewModel.downloadImage { progress in
                switch progress {
                case .loading(let image):
                    timeLineCell.setIcon(icon: image)
                case .finish(let image):
                    timeLineCell.setIcon(icon: image)
                case .error:
                    break
                }
            }
            return timeLineCell
        }
        fatalError()
    }
    
}
```


#おわりに
[次回](https://qiita.com/REON/items/421c1290139257d6ae14)
おわりです。