仕事や生活で得た知見をなんとかアウトプットする場です

ころがるおもち

【iOS開発】Storyboardで手軽に肥大化ViewControllerを解消する方法【Swift】

こんにちは。普段は仕事でiOSアプリの開発をしているomochikawaです。

最近、アプリの開発では MVC × リーダブルコード品質 × ドメイン駆動 × レイヤードアーキテクチャが十分保守性高く、人が入れ替わっても大丈夫な仕組みかなと感じる今日この頃です。

だいぶ今さら感ありますが、iOSアプリ開発をしていて最もよく話題になる1つにViewControllerの肥大化があります。

この記事では、すでに肥大化してしまったVIewControllerに対し、Viewに関する処理をStoryboardで使って分離できる方法を紹介します。

Storyboard objects library の『Object』を利用してViewに関する処理を分離する

XcodeのStoryboard objects libraryからObjectを選択する

結論から言うと、XcodeのStoryboard objects library の『Object』にViewController上の配置したUILabel, UIButtonなどUIコンポーネントを寄せることができます。

これを使うと、1画面上に大量に配置されたUIコンポーネントの処理を分離することができます。

サンプル画面動画を元にObjectの使い方を見てみる

具体的に以下のサンプル動画をもとに解説してますね。
このサンプル動画では、各ボタンタップでラベル名を更新する、画面の背景色を変える、取得したデータを表示しています。

このサンプル動画での登場人物は以下のとおりです。

  • ViewController
    • ViewController.swift
  • View
    • UIButton
      • ラベル名を変えるボタン
      • 背景色を変えるボタン
      • データを取得する
    • UILabel
      • ラベル名
    • UITableView
      • 取得したデータを表示します
  • Object(本記事のメイン)
    • PresenterControl.swift
  • Model
    • SampleModel.swift
      • 取得したデータを保持するモデルです

ViewContoller.swift

ViewControllerでは、subViewであるUILabelやUIButtonのイベントをキャッチして適宜処理を実行しています。
通信やアラート処理などだらだら書いてますが、これは本筋とはあまり関係ないので無視してください。

ここで注目したいのが、subViewに関するのIBOutletが全くないことです。
あるのは、IBOutlet ~ presenterControl のみ!これが本記事のメインであるObjectです。
実態としては、NSObjectクラスを継承しているカスタムクラスです。

//  ViewController.swift
//  storboard_object

import UIKit

class ViewController: UIViewController {
    // インジケーター
    private lazy var indicator: UIActivityIndicatorView = {
        let indicator = UIActivityIndicatorView()
        indicator.center = view.center
        indicator.style = .large
        indicator.color = .green
        view.addSubview(indicator)
        return indicator
    }()
    
    @IBOutlet private var presenterControl: PresenterControl!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        presenterControl.delegate = self
    }
}

extension ViewController: PresenterControlDelegate {
    func fetchData() {
        indicator.startAnimating()
        // データ取得
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
            self?.indicator.stopAnimating()
            if Bool.random() {
                // 成功
                self?.presenterControl.list = SampleModel.sampleList()
            } else {
                // 失敗
                self?.showErrorAlert()
            }
        }
    }
    
    func showErrorAlert() {
        let alert = UIAlertController(title: "Alert", message: "Failed!", preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "閉じる", style: .cancel))
        present(alert, animated: true, completion: nil)
    }
}

PresenterControl.swift

PresenterControl.swiftは、Storyboard上に追加されたNSObjectを継承したカスタムオブジェクトです。
実際のコードを見ると、各UIコンポーネントがTarget-Actionによってこのオブジェクトに紐付けられていることが分かります。
(実際は、ViewControllerのviewがこれらのUIコンポーネントを強参照で保持しています)

//  PresenterControl.swift
//  storboard_object

import UIKit

protocol PresenterControlDelegate: class {
    func fetchData()
}

class PresenterControl: NSObject {
    
    weak var delegate: PresenterControlDelegate!
    
    var list: [SampleModel] = [] {
        didSet {
            tableView.reloadData()
        }
    }
    
    @IBOutlet private weak var vcView: UIView!
    @IBOutlet private weak var sampleLabel: UILabel!
    @IBOutlet private weak var tableView: UITableView!
    
    @IBAction private func configureLabel() {
        sampleLabel.text = "サンプルです"
    }
    
    @IBAction private func changeBackGroundColor(_ sender: UIButton) {
        // 画面の色を変える
        vcView.backgroundColor = Bool.random() ? .red : .blue
    }
    @IBAction private func fetchData(_ sender: UIButton) {
        delegate.fetchData()
    }
}

extension PresenterControl: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return list.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = UITableViewCell(style: .value1, reuseIdentifier: "cell")
        let model = list[indexPath.row]
        cell.textLabel?.text = model.title
        cell.detailTextLabel?.text = model.subTitle
        return cell
    }
}

では、Storyboardにオブジェクトを追加する方法を見ていきます。
といっても何も難しくありません。これだけです。

  1. 通常のUIコンポーネントのように「Storyboard objects library」から「Object」をStoryboard上のViewControllerの上のバー(なんていうのか不明)にドラッグ&ドロップ
  2. オブジェクトのクラス名をファイルとして追加したxxxx.swiftのファイル名に合わせる

SampleModel.swift

このモデルは、テーブルビューに表示する情報です。
正直、ビジネスロジックも何もないのですが、ただの文字列(String)として扱うよりはオブジェクトとして扱われる方が現実的なので説明の都合上この程度のモデルにしています。

//  SampleModel.swift
//  storboard_object

import Foundation

struct SampleModel {
    let title: String
    let subTitle: String
    
    static func sampleList() -> [SampleModel] {
        return [SampleModel(title: "A_title", subTitle: "A_subTitle"),
                SampleModel(title: "B_title", subTitle: "B_subTitle"),
                SampleModel(title: "C_title", subTitle: "C_subTitle")]
    }
}

MVVMのViewModelと似てる?違いはUIKitを知っているということ

PreseterControlではそれに対応したモデルを用意していませんが、ぱっと見MVVMのViewModelと似ています。
違いは、PresentorControlはUIKitを知っているということです。

PresenterControlが直接モデルを扱っているので、できればPresenterControlとモデルをデータバインディングするViewModelが間に入ったほうが良さそうですが、ここでは割愛します。

メリット①:ViewControllerのIBOutlet, IBAction関連処理が減ってスッキリする

UIコンポーネントで発生するイベントをすべてPresenterControlに移動したので、ViewControllerがスッキリしましたね!

ただ、実際は、UIコンポーネントからのイベントをViewControllerが受けると事が多いと思います。

なので、現状ViewControllerにデリゲート処理が多く書かれているのであれば、あまりメリット享受できないかもしれません。

メリット②:MVPやMVVMを気にしなくても肥大化ViewControllerの負担を軽減できる

MVC以外のアーキテクチャーも普及しつつありますが、まだまだMVC構造のプロジェクトが多いと思います。

プロジェクトのアーキテクチャーを考えると壮大な工数がかかると思いますが、肥大化したViewControllerの改善策として、Storyboardの「Object」を手軽に導入できます。

部分的に肥大化したViewControllerの改善には試す価値はあります。

肥大化ViewControllerの基準って?個人的には、500行超えたら考える

行数で決めるのはおかしいぞって思いましが、目安としては良いかなと。

過去に10,000行近いビューコントローラーのメンテ(というかその都度理解するしかない)したことありますが、そこまで巨大なビューコントローラーでなければ、あまり気にせず基本的なMVCでやったら良いと思います。

100-200行程度のビューコントローラーに上記のアプローチや堅牢すぎるClean Architectureなど適用しても修正や保守コストのほうが効いてきてなんのためのアーキテクチャーなのってなると思うので。

それより、ビューコントローラーにビジネスロジックやビューの設定を書いたりしていたら、モデルやビューに処理を移動するのが優先だと思います。