19
13

More than 3 years have passed since last update.

【Swift】MVVM勉強してみたPart1

Last updated at Posted at 2021-04-04

はじめに

MVVMを勉強していくなかでのアウトプットです!

MVVM

スクリーンショット 2021-04-04 21.58.49.png

GitHub

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層

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で返す

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を返す

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を表示するために必要なアプトプットを出力する

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の見た目に反映させるアウトプットをする

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層

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ページへ画面遷移する

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

}

おわりに

次回
おわりです。

19
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
13