【Swift】MVCC(Massive View Controller Challenge)

これは個人的な勉強用として使っているものですが
(悪い例として)何かのネタで活用できることがあったら良いなと思い公開してみました。

# MVCとは？

プレゼンテーションアーキテクチャーの１つに
**MVC**があります。

本来は

**Model View Controller**

の略ですが

iOS界隈では

**Massive View Controller**

と言われることの方が多いかもしれません。

これは
MVCのC(Controller)の実装を

**１つのクラスに全ての機能を詰め込む**

ような形にしてしまうことで
Controllerが巨大化してしまっている状態のことを揶揄した表現です。
※iOSですとUIViewControllerのことを指します。

# 何が良くないのか？

こうなってしまうと

- 他のプロジェクトでも再利用できそうな機能が再利用できない
- コードが長くなるので見つけたいメソッドや変数などが探しづらい(スクロール量が増える)
- ある機能の修正のせいで他の修正のない機能の再ビルドやリリースが必要になる
- チームで開発をしている場合などはコンフリクトがよく起きる
- モジュールの分割がしづらく大規模になるとビルド時間やテストの時間が長くなる

などなど色々困ったことが生じます。

# ではどうすれば良いのか？

簡単に言ってしまえば

**適切な単位に分割しましょう**

となります。

# でもどうやるのか適切なのか・・・

とは言うものの
実際にやってみるとやり方はたくさんあり
何が適切で何が適切でないのかという判断を下すのはすごい難しいことだと思います。

# じゃあ検証してみよう

↓のような設計パターンの良書もあり
これを読むだけでも充分学べるのですが
https://peaks.cc/books/iOS_architecture

その上で実際に自分で書いてみて試行錯誤しないと
理解や納得感が得られない性格のため
あれこれと実装してみようと思いました。

# そもそもMassive View Controllerってどういうもの？

この試みを行う上で

「そもそもMassive View Controllerってなんだ？」

という疑問が最初に湧きました。

正直、Massive View Controllerというワードは前から知っていたため
実装の最初から分割できるものは分割していこうと考えているため

「これがMassive View Controllerだ」といったイメージを持っていませんでした。
(結果的にMassive View Controllerになってしまったと自覚することは多々あります:fire::innocent::smiling_imp::fire:)

# Massive View Controllerを作ってみた

そこで
まず今回のベース教材として
Massive View Controllerを作成してみました。

機能としては下記のようなことをしています。

```md
- 初期処理(DelegateやGesture の登録など)
- 画面の状態管理
- 通信時のローディング表示
- エラー時の画面表示
- データがなかった場合の画面表示
- ユーザ情報取得 API 呼び出し機能(現状はダミーでローカルのjsonファイルから取得)
- 一覧を画面に表示
- リモートからユーザプロフィール画像取得(現状はダミーでドキュメントディレクトリから取得)
- 取得したユーザプロフィール画像のキャッシュ機能
- ユーザの会員種別でのフィルター機能(ポップアップを表示してAPI通信を行う)
- テキスト入力名前検索機能
- ユーザ新規登録機能(ポップアップ表示から一覧に行追加)
- ユーザ削除機能(セルスワイプから行削除)
- ページング
- リフレッシュ
- 画面遷移機能(現状はセルタップしてconsoleに表示するのみ)
- ユーザプロフィール画像変更のための画像選択機能(アクションシートでカメラかフォトライブラリを選択)
- カメラ権限チェック機能
- フォトライブラリ権限チェック機能
- ユーザプロフィール画像保存機能(現状はダミーでドキュメントディレクトリへ保存)
```

実装はViewControllerの部分だけ示します。

全体は下記のリポジトリにあります。
https://github.com/stzn/MassiveViewController


```swift

import UIKit
import AVFoundation
import Photos

final class UserListViewController: UIViewController, UISearchBarDelegate,
    UITableViewDataSource, UITableViewDelegate, UIImagePickerControllerDelegate,
    UINavigationControllerDelegate, UIScrollViewDelegate {
    
    // MARK: typealias
    
    typealias ImageSaveCompletion = Completion<ImageError?>
    typealias ImageDeleteCompletion = Completion<ImageError?>
    typealias ImageFetchCompletion = Completion<UIImage>
    typealias UserFetChCompletion = Completion<Result<[User], APIError>>
    
    // MARK: IBOutlet
    
    @IBOutlet private var tableView: UITableView!
    @IBOutlet private var searchBar: UISearchBar!
    @IBOutlet private var filterButton: UIButton!
    
    // MARK: ページ表示情報
    
    private var users: [User] = []
    private var pageStatus = PageStatus.initail
    
    private final class PageStatus {
        var pageNo: Int
        var hasNext: Bool
        var searchKeyword: String?
        var filterUserType: UserType?
        
        init(pageNo: Int, hasNext: Bool,
             serachKeyword: String?,
             filterCategory: UserType?) {
            
            self.pageNo = pageNo
            self.hasNext = hasNext
            self.searchKeyword = serachKeyword
            self.filterUserType = filterCategory
        }
        
        static let initail = PageStatus(pageNo: 1, hasNext: false,
                                        serachKeyword: nil, filterCategory: nil)
    }
    
    private func resetPage() {
        pageStatus.pageNo = 1
        users = []
    }
    
    // MARK: 画面状態管理
    
    private var state: DisplayState<[User]> = .empty {
        didSet {
            
            hideAll()
            
            switch state {
            case .loading:
                showLoading(self.tableView)
            case .showingData(let users):
                hideLoading()
                pageStatus.hasNext = true
                self.users.append(contentsOf: users)
                tableView.reloadData()
                tableView.isHidden = false
            case .empty:
                hideLoading()
                pageStatus.hasNext = false
                if self.users.isEmpty {
                    emptyView.isHidden = false
                }
            case .error(let error):
                hideLoading()
                pageStatus.hasNext = false
                errorMessageLabel.text = error.localizedDescription
                errorMessageLabel.sizeToFit()
                errorView.isHidden = false
            }
        }
    }
    
    private func hideAll() {
        tableView.isHidden = true
        emptyView.isHidden = true
        errorView.isHidden = true
        hideLoading()
    }
    
    private func changeState(by result: Result<[User], APIError>) {
        switch result {
        case .success(let users):
            if users.isEmpty {
                state = .empty
                return
            }
            state = .showingData(users)
        case .failure(let error):
            state = .error(error)
        }
    }
    
    // MARK: ローディング表示
    
    private let indicatorViewTag = 9999
    
    private func showLoading(_ view: UIView) {
        let indicator = makeIndicatorView()
        self.view.addSubview(indicator)
        NSLayoutConstraint.activate([
            indicator.topAnchor.constraint(equalTo: view.topAnchor),
            indicator.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            indicator.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            indicator.trailingAnchor.constraint(equalTo: view.trailingAnchor)
            ])
    }
    
    private func hideLoading() {
        guard let indicator = view.viewWithTag(indicatorViewTag) else {
            return
        }
        indicator.removeFromSuperview()
    }
    
    private func makeIndicatorView() -> UIView {
        if let view = self.view.viewWithTag(indicatorViewTag) {
            return view
        }
        
        let view = UIView()
        view.backgroundColor = .white
        view.translatesAutoresizingMaskIntoConstraints = false
        view.tag = indicatorViewTag
        let indicator = UIActivityIndicatorView(style: .gray)
        indicator.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(indicator)
        NSLayoutConstraint.activate([
            indicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            indicator.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            ])
        indicator.startAnimating()
        return view
    }
    
    // MARK: データなし表示
    
    private lazy var emptyView: UIView = {
        let view = UIView(frame: self.view.bounds)
        let label = UILabel()
        label.text = "データがありません。"
        view.addSubview(label)
        label.sizeToFit()
        label.center = view.center
        self.view.addSubview(view)
        return view
    }()
    
    // MARK: エラー表示
    
    private var errorMessageLabel: UILabel!
    private lazy var errorView: UIStackView = {
        
        let stackView = UIStackView()
        stackView.axis = .vertical
        stackView.distribution = .fillEqually
        stackView.spacing = 10
        
        do {
            let label = UILabel()
            label.numberOfLines = 0
            label.textAlignment = .center
            stackView.addArrangedSubview(label)
            errorMessageLabel = label
        }
        
        do {
            let button = UIButton()
            button.setTitle("リトライ", for: .normal)
            button.backgroundColor = .lightGray
            button.addTarget(self, action: #selector(retry), for: .touchUpInside)
            stackView.addArrangedSubview(button)
        }
        view.addSubview(stackView)
        stackView.translatesAutoresizingMaskIntoConstraints = false
        
        NSLayoutConstraint.activate([
            stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            stackView.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.8, constant: 0)
            ])
        return stackView
    }()
    
    // MARK: リトライ機能
    
    @objc func retry() {
        load(completion: self.changeState)
    }
    
    // MARK: リフレッシュ機能
    
    private let refreshControl = UIRefreshControl()
    
    @objc func refresh() {
        resetPage()
        load(completion: changeState)
        refreshControl.endRefreshing()
    }
    
    // MARK: ライフサイクルイベント
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setTableView()
        setSearchBarDelegate()
        navigationItem.title = "ユーザー一覧"
        load(completion: self.changeState)
    }
    
    // MARK: View初期化
    
    private func setSearchBarDelegate() {
        searchBar.delegate = self
    }
    
    private func setTableView() {
        tableView.dataSource = self
        tableView.delegate = self
        refreshControl.addTarget(self, action: #selector(self.refresh), for: .valueChanged)
        tableView.refreshControl = refreshControl
        tableView.tableFooterView = UIView()
    }
    
    // MARK: ユーザー情報取得機能
    
    func load(completion: @escaping UserFetChCompletion) {
        state = .loading
        dummyFetchListAPI(completion: completion)
    }
    
    func loadNextPage(completion: @escaping UserFetChCompletion) {
        dummyFetchListAPI { [weak self] result in
            self?.isNextPageLoading = false
            completion(result)
        }
    }
    
    private func dummyFetchListAPI(completion: @escaping UserFetChCompletion) {
        let fileName = "UserPage\(pageStatus.pageNo)"
        guard let path = Bundle.main.path(forResource: fileName, ofType: "json") else {
            completion(.success([]))
            return
        }
        
        let url = URL(fileURLWithPath: path)
        do {
            let data = try Data(contentsOf: url)
            
            let decoder = JSONDecoder()
            decoder.keyDecodingStrategy = .convertFromSnakeCase
            
            var users = try decoder.decode([User].self, from: data)
            if let keyword = pageStatus.searchKeyword {
                users = users.filter { $0.name.contains(keyword) }
            }
            if let type = pageStatus.filterUserType {
                users = users.filter { $0.type == type }
            }
            asyncAfter {
                completion(.success(users))
            }
        } catch {
            asyncAfter {
                completion(.failure(.parseError(error)))
            }
        }
    }
    
    // MARK: ユーザープロフィール画像取得機能
    
    private lazy var imageFetchQueue: DispatchQueue = {
        let queue = DispatchQueue(label: "shiz.massiveViewControler.ImageFetch", attributes: .concurrent)
        return queue
    }()
    
    private func fetchImage(
        with userId: String, completion: @escaping ImageFetchCompletion) {
        
        if let image = cachedImages[userId] {
            DispatchQueue.main.async {
                completion(image.image)
            }
            return
        } else {
            cachedImages[userId] = nil
        }
        
        imageFetchQueue.async { [weak self] in
            self?.getProfileImage(with: userId) { [weak self] image in
                self?.handleFetchedImage(image, userId: userId, completion: completion)
            }
        }
    }
    
    private func handleFetchedImage(_ image: UIImage?, userId: String,
                                    completion: @escaping ImageFetchCompletion) {
        
        DispatchQueue.main.async { [weak self] in
            guard let image = image else {
                completion(UIImage(named: "default")!)
                return
            }
            self?.cache(id: userId, image: image)
            completion(image)
        }
    }
    
    // MARK: リモート画像取得機能(ダミーとしてドキュメントフォルダへ保存)
    
    private func getDocumentsURL() -> URL {
        return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
    }
    
    private func filePathInDocumentsDirectory(fileName: String) -> String {
        let fileNameWithExt = "\(fileName).png"
        return getDocumentsURL().appendingPathComponent(fileNameWithExt).path
    }
    
    private func getProfileImage(with fileName: String,
                                 completion: @escaping Completion<UIImage?>) {
        asyncAfter(1.0) { [weak self] in
            guard let strongself = self else {
                return
            }
            let path = strongself.filePathInDocumentsDirectory(fileName: fileName)
            completion(UIImage(contentsOfFile: path))
        }
    }
    
    private func saveImage(_ image: UIImage, name: String,
                           completion: @escaping ImageSaveCompletion) {
        let path = filePathInDocumentsDirectory(fileName: name)
        let url = URL(fileURLWithPath: path)
        
        do {
            guard let data = image.pngData() else {
                throw ImageError.invalidData
            }
            try data.write(to: url)
            
            asyncAfter {
                completion(nil)
            }
        } catch {
            asyncAfter {
                completion(.failure(error))
            }
        }
    }
    
    private func deleteImage(name: String, completion: @escaping ImageDeleteCompletion) {
        let path = filePathInDocumentsDirectory(fileName: name)
        
        let manager = FileManager.default
        do {
            if manager.fileExists(atPath: path) {
                try manager.removeItem(atPath: path)
            }
            asyncAfter {
                completion(nil)
            }
        } catch {
            asyncAfter {
                completion(.failure(error))
            }
        }
    }
    
    // MARK: 画像キャッシュ機能
    
    private struct CachedImage {
        let image: UIImage
        let cachedDate: Date
    }
    
    private var cachedImages: [String: CachedImage] = [:]
    
    private func cache(id: String, image: UIImage) {
        cachedImages[id] = nil
        cachedImages[id] = CachedImage(image: image, cachedDate: Date())
    }
    
    // MARK: フィルター機能
    
    @IBAction func filter(_ sender: Any) {
        showUserTypeFilterModal()
    }
    
    func showUserTypeFilterModal() {
        let controller = UIAlertController(title: "絞り込み",
                                           message: "選択してください", preferredStyle: .actionSheet)
        
        let normal = UIAlertAction(title: "通常会員", style: .default) { [weak self] _ in
            
            guard let strongSelf = self else { return }
            
            strongSelf.resetPage()
            strongSelf.pageStatus.filterUserType = .normal
            strongSelf.load(completion: strongSelf.changeState)
            controller.dismiss(animated: true)
        }
        controller.addAction(normal)
        
        let preminum = UIAlertAction(title: "プレミアム会員", style: .default) { [weak self] _ in
            
            guard let strongSelf = self else { return }
            
            strongSelf.resetPage()
            strongSelf.pageStatus.filterUserType = .preminum
            strongSelf.load(completion: strongSelf.changeState)
            controller.dismiss(animated: true)
        }
        controller.addAction(preminum)
        
        let none = UIAlertAction(title: "絞り込みしない", style: .default) { [weak self] _ in
            
            guard let strongSelf = self else { return }
            
            strongSelf.resetPage()
            strongSelf.pageStatus.filterUserType = nil
            strongSelf.load(completion: strongSelf.changeState)
            controller.dismiss(animated: true)
        }
        controller.addAction(none)
        
        let cancel = UIAlertAction(title: "キャンセル", style: .cancel) { _ in
            controller.dismiss(animated: true)
        }
        controller.addAction(cancel)
        self.present(controller, animated: true)
    }
    
    // MARK: 名前検索機能
    
    // MARK: UISearchBarDelegate textDidChange
    
    func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
        NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(self.search(_:)), object: searchBar)
        perform(#selector(self.search(_:)), with: searchBar, afterDelay: 0.75)
    }
    
    @objc func search(_ searchBar: UISearchBar) {
        
        var searchKeyword: String? = nil
        if let keyWord = searchBar.text,
            keyWord.trimmingCharacters(in: .whitespaces) != "" {
            searchKeyword = keyWord
        }
        resetPage()
        pageStatus.searchKeyword = searchKeyword
        load(completion: self.changeState)
    }
    
    // MARK:  新規ユーザー登録機能
    
    @IBAction func registerNewUser(_ sender: Any) {
        showRegistrationModal()
    }
    
    func showRegistrationModal() {
        let controller = UIAlertController(
            title: "新規登録",
            message: "入力して登録ボタンを押してください",
            preferredStyle: .alert)
        
        let register = UIAlertAction(title: "登録", style: .default) { [weak self] _ in
            
            guard let name = controller.textFields?[0].text else {
                self?.showErrorAlert("名前を入力してください")
                return
            }
            self?.users.insert(User(id: UUID().uuidString, name: name, type: .normal), at: 0)
            self?.insertNewUser()
            controller.dismiss(animated: true)
        }
        controller.addAction(register)
        
        let cancel = UIAlertAction(title: "キャンセル", style: .cancel) { _ in
            controller.dismiss(animated: true)
        }
        controller.addAction(cancel)
        
        controller.addTextField { (textField) in
            textField.placeholder = "名前を入力してください"
        }
        self.present(controller, animated: true)
    }
    
    private func insertNewUser() {
        let indexPath = IndexPath(row: 0, section: 0)
        tableView.insertRows(at: [indexPath], with: .none)
    }
    
    private func showErrorAlert(_ message: String) {
        let controller = UIAlertController(title: "エラー", message: message, preferredStyle: .alert)
        let ok = UIAlertAction(title: "OK", style: .default) { _ in
            controller.dismiss(animated: true)
        }
        controller.addAction(ok)
        present(controller, animated: true)
    }
    
    // MARK: ページング機能
    
    // MARK: UIScrollViewDelegate scrollViewDidScroll
    
    private var isNextPageLoading = false
    
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        
        let contentSizeHeight = scrollView.contentSize.height
        let offset = scrollView.contentOffset.y
        let height = scrollView.frame.size.height
        let didReachBottom = contentSizeHeight - 20 <= (offset + height)
        let needLoadNextPage = didReachBottom && pageStatus.hasNext && !isNextPageLoading
        
        if needLoadNextPage {
            isNextPageLoading = true
            pageStatus.pageNo += 1
            loadNextPage(completion: changeState)
        }
    }
    
    // MARK: 一覧表示機能
    
    // MARK: UITableViewDataSource
    
    func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return users.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard indexPath.row <= users.count - 1 else {
            return UITableViewCell()
        }
        return setUserCell(at: indexPath)
    }
    
    // MARK: UITableViewDelegate willDisplay
    
    func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
        
        guard let cell = cell as? UserCell else {
            return
        }
        let user = users[indexPath.row]
        setImage(to: cell, id: user.id)
    }
    
    // MARK: UITableViewDelegate heightForRowAt
    
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return 120
    }
    
    // MARK: セル設定
    
    @discardableResult
    private func setUserCell(at indexPath: IndexPath) -> UserCell {
        
        let cell = tableView.dequeueReusableCell(withIdentifier: UserCell.identifier) as! UserCell
        let user = users[indexPath.row]
        
        cell.configure(user: user)
        setTagToProfileImage(cell.profileImage, with: indexPath.row)
        setImageTapRecognizer(to: cell)
        return cell
    }
    
    private func setTagToProfileImage(_ image: UIImageView, with rowNumber: Int) {
        image.tag = rowNumber
    }
    
    private func setImageTapRecognizer(to cell: UserCell) {
        cell.profileImage.isUserInteractionEnabled = true
        let tap = UITapGestureRecognizer(target: self, action: #selector(selectImage(_:)))
        cell.profileImage.addGestureRecognizer(tap)
    }
    
    private func setImage(to cell: UserCell, id: String) {
        
        fetchImage(with: id) { [weak self] image in
            
            guard let strongSelf = self else { return }
            
            strongSelf.setImageIfCellVisible(to: cell, image: image)
        }
    }
    
    private func setImageIfCellVisible(to cell: UserCell, image: UIImage) {
        let visibleCells = tableView.visibleCells
        if visibleCells.contains(cell) {
            cell.setImage(image)
        }
    }
    
    @objc func selectImage(_ tapGesture: UITapGestureRecognizer) {
        guard let image = tapGesture.view as? UIImageView,
            let cell = self.tableView.cellForRow(at: IndexPath(row: image.tag, section: 0)) as? UserCell
            else {
                return
        }
        pickedCell = cell
        presentImageSelectActionSheet()
    }
    
    // MARK: 画面遷移
    
    // MARK: UITableViewDelegate didSelect
    
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: false)
        let user = users[indexPath.row]
        print("go to detail of \(user.name)")
    }
    
    // MARK: ユーザー削除
    
    // MARK: UITableViewDelegate willDisplay
    
    func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
        if editingStyle == .delete {
            let user = users.remove(at: indexPath.row)
            cachedImages[user.id] = nil
            deleteImage(name: user.id) { error in
                
                guard error == nil else { return }
                
                DispatchQueue.main.async { [weak self] in
                    self?.tableView.deleteRows(at: [indexPath], with: .fade)
                }
            }
        }
    }
    
    // MARK: 画像選択機能
    
    private func presentImageSelectActionSheet() {
        
        let controller = UIAlertController(title: "画像選択", message: "下記から画像を選択してください", preferredStyle: .actionSheet)
        let camera = UIAlertAction(title: "カメラ", style: .default) { [weak self] _ in
            self?.startCameraAction()
            controller.dismiss(animated: true)
        }
        controller.addAction(camera)
        let library = UIAlertAction(title: "ライブラリ", style: .default) { [weak self] _ in
            self?.startPhotoLibraryAction()
            controller.dismiss(animated: true)
        }
        controller.addAction(library)
        
        let cancel = UIAlertAction(title: "キャンセル", style: .cancel) { _ in
            controller.dismiss(animated: true)
        }
        controller.addAction(cancel)
        
        present(controller, animated: true)
    }
    
    
    private func startCameraAction() {
        checkCameraPermission()
    }
    
    private func startPhotoLibraryAction() {
        checkPhotoLibraryPermission()
    }
    
    // MARK: カメラ権限チェック
    
    private func checkCameraPermission() {
        let status = AVCaptureDevice.authorizationStatus(for: .video)
        switch status {
        case .notDetermined:
            AVCaptureDevice.requestAccess(for: .video) { success in
                if success {
                    self.presentImagePicker(type: .camera)
                }
            }
        case .restricted:
            showErrorAlert("カメラの利用が制限されています。")
        case .denied:
            showErrorAlert("カメラの利用が禁止されています。")
        case .authorized:
            presentImagePicker(type: .camera)
        @unknown default:
            fatalError()
        }
    }
    
    // MARK: フォトライブラリ権限チェック
    
    private func checkPhotoLibraryPermission() {
        switch PHPhotoLibrary.authorizationStatus() {
        case .notDetermined:
            PHPhotoLibrary.requestAuthorization { status in
                if case .authorized = status {
                    self.presentImagePicker(type: .photoLibrary)
                }
            }
        case .restricted:
            showErrorAlert("フォトライブラリの利用が制限されています。")
        case .denied:
            showErrorAlert("フォトライブラリの利用が禁止されています。")
        case .authorized:
            presentImagePicker(type: .photoLibrary)
        @unknown default:
            fatalError()
        }
    }
    
    // MARK: 画像選択ImagePicker表示
    
    private func presentImagePicker(type: UIImagePickerController.SourceType) {
        let picker = UIImagePickerController()
        picker.sourceType = type
        picker.delegate = self
        present(picker, animated: true)
    }
    
    // MARK: ImagePickerDelegate
    
    private var pickedCell: UserCell? = nil
    
    func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
        
        defer { dismiss(animated:true) }
        
        guard let cell = pickedCell else {
            return
        }
        
        let index = cell.profileImage.tag
        let user = users[index]
        let chosenImage = info[UIImagePickerController.InfoKey.originalImage] as! UIImage
        
        showLoading(self.tableView)
        
        saveImage(chosenImage, name: user.id) { [weak self] error in
            
            guard let strongSelf = self else { return }
            
            defer { strongSelf.hideLoading() }
            
            if let error = error {
                strongSelf.state = .error(error)
                return
            }
            strongSelf.cache(id: user.id, image: chosenImage)
            strongSelf.setImageIfCellVisible(to: cell, image: chosenImage)
        }
    }
    
    func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
        dismiss(animated: true)
    }
}

// MARK: Helper

private func asyncAfter(_ deadline: TimeInterval = 1.0, action: @escaping () -> Void) {
    DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + deadline, execute: action)
}
```

実装が乱暴になってしまっている部分もありますが
だいたい７５０行くらいになりました。

1人で実装したのでチーム開発で生じる問題を感じることはありませんでしたが
スクロール量が増え
メソッドや変数を探すのにXcodeのジャンプ機能などを使ったとしても
探しづらいなという印象を強く持ちました。


# MVCC(Massive View Controller Challenge)

名前をつけた方が記憶に残るかなと思い
こう名付けてみました。


これをベースに色々な実装方法を検討していき

- 何が良い実装で何が良くない実装なのかの判断基準をより明確にする
- 同じような問題に直面した時の実装方法の選択肢を増やす

といった部分を強化できたら良いなと思っています。

# 最後に

もっと良いMassive View Controllerの例や
こうした方がもっとMassiveになるなどございましたら
ぜひご意見ください:smiley:
(何を言っているのかよくわからないですねw)

そもそももっと良い勉強法があるなどの
ご意見もありましたら
教えていただけると嬉しいです:bow_tone1: