📝

[Swift] 単方向なViewModelの書き方

2021/04/17に公開

ViewModelと単方向データフロー

ViewModelとは、View(≒UIKitに所属するクラス、主にUIViewとUIViewControllerのサブクラス)を直接操作するコードと、表示に関するロジックを分離することにより、ロジックを書きやすくまたテストしやすくする設計パターンです。

「単方向データフロー」は多分Fluxの登場と同時に流行り始めた言葉だと思いますが、プログラムへの入力と出力を1方向にのみ制限して行う、という考え方です。ここで、プログラムへの入力とはボタンのタップやテキストフィールドへの入力などを指します。出力とは画面表示や画面遷移などを指します。単方向の概念自体は別段新しいものではなく古のMVCの時代からあったと思いますが、言葉としてわかりやすさがあります。

ここでは、単方向というキーワードを使って良いViewModelの書き方について考えてみます。この記事ではRedux系ライブラリやRx系ライブラリは使用しません。使用しなくても単方向データフローは実現可能ということを示します。ライブラリに頼らない基本を理解した上でライブラリを使えば強力なツールになりますが、基本を理解せずに使っても得られるものは多くありません。

典型的なiOSアプリでは、入力や出力はViewControllerが媒介することになります。つまり、入力に対しては、

入力イベント(View) → ViewController → ViewModel

となります。出力に対しては

ViewModel → ViewController → View

となります。基本的には全ての入力と出力をこのように取り扱うようにします。

例題

概念だけを説明しても伝わらないと思いますから、例を挙げて考えていきましょう。このサンプルアプリはテキストフィールドとボタンとラベルがあり、テキストフィールドに名前を入力してボタンを押すと、ラベルに大文字化した名前と共に挨拶を表示します。

まず何も考えずに実装した場合です。コードはこうなります。

class ViewController: UIViewController {
    @IBOutlet var label: UILabel!
    @IBOutlet var textField: UITextField!

    @IBAction func buttonPressed(_ sender: Any) {
        let name = textField.text?.uppercased() ?? ""
        label.text = "Hello, \(name)!"
    }
}

ここでの入力と出力は以下の通りです。

  • 入力
    • ボタンのタップ
    • テキストの入力
  • 出力
    • ラベルへの表示

ViewModelの導入

このアプリにおける重要なロジックの一つは、「入力された文字列を大文字にする」ことです。ロジックですからViewModel内に実装されるべきです。

class ViewModel {
    func greetings(name: String?) -> String {
        let name = name?.uppercased() ?? ""
        return "Hello, \(name)!"
    }
}

class ViewController: UIViewController {
    let viewModel = ViewModel()
    @IBOutlet var label: UILabel!
    @IBOutlet var textField: UITextField!

    @IBAction func buttonPressed(_ sender: Any) {
        label.text = viewModel.greetings(name: textField.text)
    }
}

少し進歩しました。もし「名前の前後にスペースが入っていたら取り除く」などの仕様追加があったとしてもViewModelの変更だけで済みますし、それに対するテストコードも簡単に書くことができます。

これでは例が少々単純すぎますから、「入力テキストが空のときはボタンをdisabledにする」仕様を追加してみます。出力が一つ増えますので入出力リストはこのようになります。

  • 入力
    • ボタンのタップ
    • テキストの入力
  • 出力
    • ラベルへの表示
    • ボタンのisEnabled状態
class ViewModel: NSObject {
    var inputText: String?
    
    func greetings() -> String {
        let name = inputText?.uppercased() ?? ""
        return "Hello, \(name)!"
    }
    
    func buttonIsEnabled() -> Bool {
        return !(inputText ?? "").isEmpty
    }
}

class ViewController: UIViewController {
    let viewModel = ViewModel()
    @IBOutlet var label: UILabel!
    @IBOutlet var textField: UITextField!
    @IBOutlet var button: UIButton!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        textDidChange(nil)
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(textDidChange(_:)),
            name: UITextField.textDidChangeNotification,
            object: textField)
    }

    @IBAction func buttonPressed(_ sender: Any) {
        label.text = viewModel.greetings()
    }
    
    @objc func textDidChange(_ notification: Notification?) {
        viewModel.inputText = textField.text
        button.isEnabled = viewModel.buttonIsEnabled()
    }
}

さて、ViewModelのようなものができました。実際にこのようなスタイルのViewModelを使っているチームもあると思います。

しかしまだ単方向にはなっていません。単方向データフローを作るということは、具体的かつざっくり言うとViewController内の一つのメソッドでは入力と出力のどちらかしか行わないということです。textDidChangeメソッドではテキストフィールドから入力を受け取り、かつボタンの状態を変更していますから、このルールに違反しています。buttonPressedメソッドは、メソッド内では出力しか行なっていませんが、buttonPressedメソッド自身がボタンからの入力を受け取りますからやはりルール違反です。

それに、「ボタンを押された時挨拶を表示する」の「ボタンを押された時」の部分もロジックですから、ViewModelに実装したいです。最初の節での矢印の向きを思い出してください。全ての入力はViewModelに到達し、全ての出力はViewModelから発生するようにします。

ViewModelへの入力と出力

ViewController内の一つのメソッドでは入力と出力のどちらかしか行わないというルールを紹介しました。Viewやシステムからのイベントは入力で、Viewの変更は出力です。ではViewModelにとっては何が入力で何が出力なのか、具体的な考え方を挙げてみます。

  • 入力
    • プロパティへのset
    • 戻り値もコールバックもないメソッドの呼び出し
  • 出力
    • プロパティのget
    • 引数と副作用がなく、戻り値のあるメソッドの呼び出し(引数については例外あり)
    • 状態監視のコールバック

引数は入力であり、戻り値やコールバックは出力ですから、単方向データフローを守る限り基本的にはどちらかしか使えないことに注意してください。

以下の例はルール違反です。

func example() {
    viewModel.someState = "" // 入力
    label.text = viewModel.labelText() // 出力
}

以下も違反です。引数と戻り値を同時に使用しています。

func example() {
    label.text = viewModel.labelText(self.someViewControllerState) // 入力かつ出力
}

以下の例も違反です。doSomeRequestの呼び出しは入力で、label.textの変更は出力です。

func example() {
    viewModel.doSomeRequest() { [weak self] result in // 入力
        self?.label.text = result?.getText() // 出力
    }
}

これはどうでしょう。

func example1() {
    viewModel.someState = "" // 入力
    example2()
}

func example2() {
    label.text = viewModel.labelText() // 出力
}

もちろんこのexample1は違反です。example2は出力を行なっていますからexample2の呼び出しは出力を行なっているのと同じです。

入力フローと出力フローの分離

「入力と出力を単方向に制限する」ことと同じくらい重要なのが、「入力フローと出力フローを分離する」ことです。そのためには監視可能(Observable)なデータが必要になります。ここでは手軽に使えるKVOを使用しますが、他にもいろいろな実装方法があります。

class ViewModel: NSObject {
    @objc dynamic private(set) var labelText: String?
    @objc dynamic private(set) var buttonIsEnabled: Bool = false
    
    var inputText: String? {
        didSet {
            buttonIsEnabled = !(inputText ?? "").isEmpty
        }
    }

    func buttonPressed() {
        let name = inputText?.uppercased() ?? ""
        labelText = "Hello, \(name)!"
    }
}

class ViewController: UIViewController {
    let viewModel = ViewModel()
    @IBOutlet var label: UILabel!
    @IBOutlet var textField: UITextField!
    @IBOutlet var button: UIButton!
    var observers: [NSKeyValueObservation] = []
    
    override func viewDidLoad() {
        super.viewDidLoad()
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(textDidChange(_:)),
            name: UITextField.textDidChangeNotification,
            object: textField)
	    
        let observer1 = viewModel.observe(\.buttonIsEnabled) { [weak self] (viewModel, _) in
            self?.button.isEnabled = viewModel.buttonIsEnabled
        }
        let observer2 = viewModel.observe(\.labelText) { [weak self] (viewModel, _) in
            self?.label.text = viewModel.labelText
        }
	
        observers = [observer1, observer2]
        textDidChange(nil)
    }

    @IBAction func buttonPressed(_ sender: Any) {
        viewModel.buttonPressed()
    }
    
    @objc func textDidChange(_ notification: Notification?) {
        viewModel.inputText = textField.text
    }
}

これで単方向になりました。buttonPressedはもはやボタンがタップされたタイミングをviewModelに伝えるだけです。textDidChangeはtextField.textを受け取ってviewModelに流すだけです。どちらでも出力を行なっていません。

出力はviewModel.observeのコールバック内だけで行われるようになりました。もちろんコールバック内で行うのは出力だけで、入力は行わないようにします。

考察

入力と出力を分離したおかげで、ViewModelの変更だけでかなりのことができるようになっています。例えば、ボタンを押したときにラベルに表示するテキストをサーバーサイドのAPIから取得するような変更もViewControllerを触らずに行うことができます。APIリクエスト中はボタンをdisabledにすることもできますし、その処理に対するテストコードも簡単に書くことができます。

関心の分離がうまくいっているコードでは、仕様変更や修正の際に1つのクラスを触るだけでできることが多くなります。したがって、ViewModelが上手く書けているとUIの仕様変更が容易になります。逆に分離ができていないコードでは、簡単なことをするのにも複数のクラスを触らなくてはならなくなり、変更が難しく、壊れやすくなります。

viewDidLoadあたりのコードは一見複雑に見えるかもしれません。ただし、内容としては非常に単純なデータの受け渡ししか行なっていないことに注目してください。よく書けているViewModelパターンではViewControllerはViewとViewModelの橋渡ししかしなくなります。ロジックはViewModelに移動し、ViewControllerからは消えていきます。雑にわかりやすく言うとif文が少ないコードになります。それに対して、問題のあるViewModelパターンではロジックがViewControllerとViewModelの両方に跨って実装され、密結合になり、読みにくく、変更しにくく、テストしにくくなります。

ここまで、非常に簡単な例ですが単方向データフローを使ったViewModelについて基本的な考え方と得られるメリットをなるべく具体的に紹介してみました。ここで紹介したのはあくまで基本ですから、現実的には色々な例外が発生するかもしれません。実際のアプリケーションにはもっと多くの要素があり(画面遷移、CollectionView、非同期処理、アニメーション、エラー、ActionSheet、etc...)、良いViewModelのための実装テクニックがありますから、気が向いたらまた書いてみるかもしれません。

Discussion