#はじめに
MVVMを勉強していくなかでのアウトプットです!
#MVVM
#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層
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で返す
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を返す
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を表示するために必要なアプトプットを出力する
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の見た目に反映させるアウトプットをする
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層
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ページへ画面遷移する
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()
}
}
#おわりに
次回
おわりです。