31
19

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

ライブラリを使わずにMV*の話(iOS)~ViewとModelの役割〜

Last updated at Posted at 2017-09-29

前置き

MVC, MVP などいろいろ表現あるのでまとめて MV* と書きます。
(「MVW = Model-View-Whatever」でも良いですがMV*の方が親しみがあるので)

MVに関して、「Modelがなにをするか」、「Viewがなにをするか」、「MVがどう連結しているか」という認識が、各々微妙に異なることがあります。
異なったままでは説明に困るので、この記事では Model と View の話をします。

話すこと

  1. Viewとは
    • 画面の構築/表示を行う
    • (内部表現をUI要素へ変換する)
    • (ユーザー操作の受付を行う)
  2. Modelとは
    • 状態・値を持つ
    • Modelの外から指示を受け処理を行う
    • 状態・値の変化をModelの外へ 間接的に 知らせる機能持つ
  3. 間接的な通知方法として、Observerパターンを利用する
    • 理由
      • Modelを「Model単独で成り立つクラス」として作れる
      • Modelの状態の変更を、複数Viewに同タイミングで渡せる
      • Modelで行なった処理の結果を、Viewは受動的に知ることができる
  4. 具体的な実装例

この記事では話さないこと

UIとUI以外に分ける

アプリの構成をざっくり「UI」と「UI以外」の2つに分けます。

UI

  • 画面の構築/表示を行う
    • おおよそ StoryBoard, xib, UIKit が行なってくれていること
  • 内部表現をUI要素へ変換する
    • 通信中(内部表現)であればインジケーター(UI要素)を表示する など
  • ユーザー操作の受付
    • @ IBAction, UIControl#addTarget(_:action:for:) など

UI以外

  • UIに関与しないアプリ固有の処理
  • DBやAPIとの通信
  • etc...

Viewとは

この記事では、下記の役割のいずれかを担うクラスを View とします。

  • 画面の構築/表示を行う
  • (内部表現をUI要素へ変換する)
  • (ユーザー操作の受付)

以降、Viewと表現する場合は上記の役割を担うクラスということであり、「UIViewのサブクラス」という意味 ではありません。
(View ∈ UIViewのサブクラス)

「内部表現をUI要素へ変換する」「ユーザー操作の受付」はViewの外におく場合もありますが、
まずはViewとModelのみで話を展開するためViewの役割としています。

Modelとは

Modelは「UI以外」の層に属します。
「UI以外」の仕事には、Modelが担う仕事と担わない仕事があります。

Modelが担う仕事

この記事では、下記の役割を満たすクラスを Model とします。

  1. 状態・値を持つ
    • 値: サーバから取得した値、ユーザーが入力した値など
    • 状態: 待機中, 通信中, 通信終了など
  2. Modelの外(UI層など)から指示を受け処理を行う
  3. 処理の結果を間接的な方法で外へ伝える

Modelが担わない仕事

Modelのイメージを固めるためにModelの_役割ではない_部分を考えてみます。

  • API通信実行部分

    • いわゆるデータ層 = Modelではない部分
    • 「あるAPIを叩いてレスポンスを取得する」という処理はModelの の話である
    • Modelが持つのは、「今APIのレスポンス待ちであるという状態」や「APIのレスポンス結果」である
  • APIのレスポンスのデシリアライズ

    • Modelはデシリアライズした結果の保持を担当する

図にするとこんな感じ。
MV-Whatever.002.jpg

Modelが仕事を担う理由

では、Modelの担う仕事がなぜModelの担当にとしたのかの話をします。

1. 状態・値を持つ

状態・値をViewに持たせず、Modelに持たせることで、 Viewを単純にする という目的があります。
例えば、Viewがただの表示と操作受付だけの場合を考えてみます。

■ Viewが表示と操作受付だけの場合
- 表示: ボタンを表示する
- 操作受付: ボタンを押されたらサーバーと通信を開始する

ボタンを押されたら決められた処理を開始するだけなので、Viewの動作は単純です。
単純であるということは、下記の利点があります。

  • 作りやすい
  • 動作確認するパターンが減る
  • バグが仕込まれにくい

では、単純ではない場合はどうでしょうか?
Viewが表示と操作受付と、状態を持つ場合を考えてみます。

■ Viewが状態を持つ場合
- 表示: ボタンを表示する
- 状態: 通信中かどうか
- 操作受付: ボタンを押されたらサーバーと通信を開始する。ただし、既に通信中である場合は何も行わない

状態が増えると分岐が増え、単純さからは離れていきます。
そうすると、「動作確認するパターンが増える」、「バグを生みやすい」などの難点が出てきます。

Viewを表示・操作受付に専念させるために、状態・値をModelに持たせます。

■ Viewが表示と操作受付だけの場合
- 表示: ボタンを表示する
- 操作受付: ボタンを押されたらModelへ指示を行う

■ Modelが状態を持つ場合
- 状態: 通信中かどうか
- Viewからの指示: サーバーと通信を開始する。ただし、既に通信中である場合は何も行わない

Modelに状態を持たせ、分岐を行わせることで、Viewの単純さを保ちます。

2. Modelの外から指示を受け処理を行う

ModelはModel外から指示を受け処理を開始します。
Model#doSomething() のようなメソッドを公開しているということです。
ユーザー動作の受付や処理を開始するタイミング管理などはModelの外で行うことで、Modelが複雑になるのを避けます。

3. 処理の結果を間接的な方法で外へ伝える

外から指示を受け処理を行なったあと、その処理の結果(もしくは途中経過)を、指示元へ伝える方法が必要です。
しかし、例えばModelがViewのプロパティを変更するなどの直接的な伝え方をしてしまうと、Viewの実装にModelが大きく影響を受けてしまいます。
影響を避けるため、Modelは他のクラスの実装と関係なく単独で動けるクラスとして定義します。
そのためには、処理の結果は間接的な方法で外へ伝える必要があります。

間接的な通知方法として、Observerパターンを利用する

以上がModelの役割と、その理由の説明でした。
次に、 Modelの処理の結果(もしくは途中経過)を伝える間接的な方法 について話をします。
ModelとModelの外(etc. View)を繋ぐ方法として、考えられるのはおおよそ下記の4つになるかと思います。

  1. ModelがプロパティとしてViewを持ち、Viewへ直接値を渡す
  2. Modelで行なう処理 Model#doSomething() の戻り値をViewが受け取るようにする
  3. Modelが持つ状態 Model#state をViewが参照する
  4. GoFのObserverパターンを使い、ModelをObservable、ViewをObserverとする

結論から言うと、この記事ではObserverパターンを利用します。

1. ModelがプロパティとしてViewを持ち、Viewへ直接値を渡す -> ❌

わざわざViewとModelに役割を分けたのですから、Viewの変更にModelは影響されないように作りたいものです。
Modelを Model単独で成り立つクラス として作れば、ModelがViewに引きづられることはありません。
そのため理想の関係は、「ViewからはModelを利用できるけども、ModelはViewを利用できない」という関係です。
ModelがViewを持つとその関係を作れないので、この方法は好ましくありません。

2. Modelで行なう処理 Model#doSomething() の戻り値をViewが受け取るようにする -> ❌

Modelに指示を行うViewと、指示の結果を受け取るViewが同じとは限りません。
また、結果を受け取りたいViewが1つだけとも限りません。複数のViewで状態を連動させたい場合もあります。
Modelの状態の変更を、複数Viewに同タイミングで渡す 機能が必要ですので、戻り値は好ましくありません。

3. Modelが持つ状態 Model#state をViewが参照する -> ❓

この場合Viewは、なんらかの処理が終わるたびに Model#state を参照して画面へ反映する、という形になるでしょう。
もしくは、ViewController#viewWillApper のたびに Model#state を参照する、などの、ViewControllerのライフサイクルに頼った更新になるかもしれません。
その方法でも可能ですが、Modelで行なった処理の結果を受動的に知ることができれば、 Viewはより単純になります。

4. GoFのObserverパターンを使い、ModelをObservable、ViewをObserverとする -> ⭕️

Observerパターンを利用すれば、下記の要望が満たせます。

  • Modelを「Model単独で成り立つクラス」として作れる
  • Modelの状態の変更を、複数Viewに同タイミングで渡せる
  • Modelで行なった処理の結果を受動的に知ることができる

具体的な実装を書いてみる

以降は、ここまでで説明したView、Modelをどのように記述するかという具体的な実装例になります。

全体像はGithubにあげています。

以下の順番で話を進めます。

  • 要件の説明
  • 要件から、ViewとModelに必要な機能を決める
  • Observerパターンを用いてViewとModelを接続する
  • 補足

要件の説明

mvw_sample_onlymv.gif
こういうものを作ります。
(話の簡略化のため通信系処理がない要件にしています。)

■ 要件

  • Main画面と、Sub画面の2画面ある
  • Main画面
    • Sub画面へ遷移するためのボタンがある
    • Starボタンがある
    • Starボタンは、初期状態では"☆"を表示し、タップされると"★"になる
    • Starボタンは、"★"の時にタップされると"☆"になる
  • Sub画面
    • Starボタンがある
    • Starボタンの仕様はMain画面と同じである
    • Starボタンの"☆"の表示はMain画面と連動している

ViewとModelに必要な機能を決める

Starボタンの状態

「今、Starボタンの状態は"☆/★"である」という状態を、Model(= 状態・値を持っているクラス)に持たせます。
Main画面とSub画面で同インスタンスのModelを使用することで、"☆/★"の表示を連動させることが可能になります。

ユーザー動作の受付/Modelへの指示

Starボタンがタップされた時の動作(= ユーザー操作の受付)をViewで決めます。
Viewは、「"☆/★"の状態を変更してください」という指示をModelへ送ります。

また、ViewはModelへ指示した結果の状態("☆/★")を知り、画面へ反映する(= 内部表現をUI要素へ変換する)役割があります。
このため、ModelはViewへ 結果を知らせる機能 が必要ですし、ViewはModelから 結果を取得する機能 が必要です。

必要な Model, View

以上の話をまとめると、少なくとも1つのModelと2つのViewが必要だということがわかります。

  • Starボタンの状態を持つModel
    1. 状態("☆/★")を持つ
    2. Viewから指示を受け、状態("☆/★")を変更する
    3. 指示の結果、状態("☆/★")が何であるかをViewへ知らせる
  • Main画面を表すView
    1. 遷移ボタンを持ち、タップされた時にSub画面へ遷移する
    2. Starボタンを持ち、タップされた時にModelへ指示を出す
    3. ModelからStarボタンの状態("☆/★")を取得し、表示へ反映する
  • Sub画面を表すView
    1. Starボタンを持ち、タップされた時にModelへ指示を出す
    2. ModelからStarボタンの状態("☆/★")を取得し、表示へ反映する

それでは実装に移ります。

Model, View の土台を書く

まずは、「Starボタンの状態を持つModel」として StarModel を定義します。

StarModel
/// Starボタンの状態を持つModel
class StarModel {

    // 1. 状態("☆/★")を持つ
    private var isStar: Bool

    init(initialStar: Bool) {
        self.isStar = initialStar
    }

    // 2. Viewから指示を受け、状態("☆/★")を変更する
    func toggleStar() {
        self.isStar = !self.isStar
    }

    // TODO: 3. 指示の結果、状態("☆/★")が何であるかをViewへ知らせる

次に、「Main画面を表すView」を定義します。
Viewの役割のうち、

  • 画面の構築/表示を行う

という役割は Main.storyboard に任せます。

  • 内部表現をUI要素へ変換する
  • ユーザー操作の受付を行う

という役割は MainViewHandler に任せます。
(UIViewのサブクラスと名前がかぶるのを避けるため、 MainView ではなく MainViewHandler としました。)

ちなみに MainViewController は「ModelとViewを紐づける役割」に留めています。
ViewControllerには画面のライフサイクルの管理など既に大きな責務があるため、これ以上仕事を増やしたくないという意図でこの形をとっています。

MainViewHandler
/// Main画面を表すView
/// UIViewのサブクラスと名前がかぶるのを避けるため、 `MainView` ではなく `MainViewHandler` とする
class MainViewHandler {

    private let navigateToSubViewButton: UIButton
    private let starButton: UIButton
    private let model: StarModel

    init(
        handle: (
            starButton: UIButton,
            navigateToSubViewButton: UIButton
        ),
        notify model: StarModel
    ) {
        self.starButton = handle.starButton
        self.navigateToSubViewButton = handle.navigateToSubViewButton
        self.model = model

        // 1. 遷移ボタンを持ち、タップされた時にSub画面へ遷移する
        self.navigateToSubViewButton.addTarget(
            self,
            action: #selector(MainViewHandler.didTapNavigateButton),
            for: .touchUpInside
        )

        // 2. Starボタンを持ち、タップされた時にModelへ指示を出す
        self.starButton.addTarget(
            self,
            action: #selector(MainViewHandler.didTapStarButton),
            for: .touchUpInside
        )
    }

    @objc private func didTapNavigateButton() {
        // TODO: Sub画面へ遷移する
    }

    @objc private func didTapStarButton() {
        self.model.toggleStar()
    }

    // TODO: 3. ModelからStarボタンの状態("☆/★")を取得し、表示へ反映する

}
MainViewController
import UIKit

class MainViewController: UIViewController {

    @IBOutlet var starButton: UIButton!
    @IBOutlet var navigateToSubViewButton: UIButton!

    private var viewHandler: MainViewHandler?

    override func viewDidLoad() {
        super.viewDidLoad()

        self.viewHandler = MainViewHandler(
            handle: (
                starButton: starButton,
                navigateToSubViewButton: navigateToSubViewButton
            ),
            notify: StarModel(initialStar: false)
        )
    }

}

未実装(TODO)の部分にはObserverパターンを利用します。

Observerパターンを用いてModelからViewへ通知を送る

ViewをObserver, ModelをObservableとする関係を作ります。

循環参照を避けるため、 NSHashTable を利用しています。
補足: @objc を避けてSwiftのみでModelを書く場合

StarModel

// - 変更点1: Observerを表すプロトコルを定義する。
//           ※ NSHashTable を利用するため、 @objc を付ける必要がある
@objc protocol StarModelReciever {
    func receive(isStar: Bool)
}

class StarModel {

    // - 変更点2: Observerを内部で持つ。
    //           StarModelReciever というプロトコルの状態で持つことが大事。
    //           実際の実装(View)と切り離すことで、Modelを「Model単独で成り立つクラス」にできる。
    private var receiveers = NSHashTable<StarModelReceiver>.weakObjects()
    private var isStar: Bool

    init(initialStar: Bool) {
        self.isStar = initialStar
    }

    // 2. Viewから指示を受け、状態("☆/★")を変更する
    func toggleStar() {
        self.isStar = !self.isStar

        // - 変更点5: TODOだった部分
        //           3. 指示の結果、状態("☆/★")が何であるかをViewへ知らせる
        self.notify()
    }

    // - 変更点3: ModelへObserverを追加する機能。
    func append(receiver: StarModelReceiver) {
        self.receiveers.add(receiver)
        receiver.receive(status: self.isStar)
    }

    // - 変更点4: Observerへ通知を行う機能。
    //           Modelから全Observerへ、引数経由でStarボタンの状態("☆/★")を知らせる。
    //           「Modelの状態の変更を、複数Viewに同タイミングで渡す」ことが可能になる。
    private func notify() {
        self.receiveers.allObjects.forEach { receiver in
            receiver.receive(isStar: self.isStar)
        }
    }
}
MainViewHandler
class MainViewHandler {

    // ... 中略 ...

    init(
        handle: (
            starButton: UIButton,
            navigateToSubViewButton: UIButton
        ),
        notify model: StarModel
    ) {
        self.starButton = handle.starButton
        self.navigateToSubViewButton = handle.navigateToSubViewButton
        self.model = model

        // - 変更点3: Modelの監視を開始する
        self.model.append(receiver: self)

        // ... 中略 ...

    }

}

// - 変更点1: ModelのObserverとなる
extension MainViewHandler: StarModelReciever {

    // - 変更点2: TODOだった部分
    //           3. ModelからStarボタンの状態("☆/★")を取得し、表示する
    func receive(isStar: Bool) {
        let title = isStar ? "★": "☆"
        self.starButton.setTitle(title, for: .normal)
    }
}

Modelと、Main画面のだいたいの実装が完了しました。

2つの画面を連動させる

残る要件としてSub画面があります。
Main画面とSub画面の状態("☆/★")連動させるために、2つの画面で同じインスタンスのModelを使います。
ここでは、static func でSub画面のViewControllerを初期化することにより、Sub画面にModelを渡します。

補足: ViewControllerをinitで初期化しModelを渡す場合

Sub画面を作成する

Sub画面を作成します。

SubViewController
import UIKit

class SubViewController: UIViewController {

    @IBOutlet var starButton: UIButton!
    private var viewHandler: SubViewHandler?
    private var model: StarModel?

    // modelの受け渡し
    static func create(model: StarModel) -> SubViewController? {
        let storyboard = UIStoryboard(name: "Sub", bundle: nil)
        let vc = storyboard.instantiateInitialViewController() as? SubViewController
        vc?.model = model

        return vc
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        guard let model = self.model else {
            return
        }

        self.viewHandler = SubViewHandler(
            handle: self.starButton,
            notify: model
        )
    }

}
SubViewHandler
import UIKit

class SubViewHandler {

    private let starButton: UIButton
    private let model: StarModel

    init(
        handle starButton: UIButton,
        notify model: StarModel
    ) {
        self.starButton = starButton
        self.model = model

        self.model.append(receiver: self)

        // 1. Starボタンを持ち、タップされた時にModelへ指示を出す
        self.starButton.addTarget(
            self,
            action: #selector(SubViewHandler.didTapStarButton),
            for: .touchUpInside
        )
    }

    @objc private func didTapStarButton() {
        self.model.toggleStar()
    }

}

extension SubViewHandler: StarModelReceiver {

    // 2. ModelからStarボタンの状態("☆/★")を取得し、表示へ反映する
    func receive(isStar: Bool) {
        let title = isStar ? "★": "☆"
        self.starButton.setTitle(title, for: .normal)
    }
}

最後に、 MainViewHandlerMainViewController を渡し、遷移を行います。
補足: ViewControllerをそのまま渡すのを避ける場合

MainViewHandler
class MainViewHandler {

    // ... 中略 ...
    private weak var navigator: UIViewController?

    init(
        // ... 中略 ...
        navigateBy navigator: UIViewController
    ) {
        // ... 中略 ...
        self.navigator = navigator

        // ... 中略 ...
    }

    @objc private func didTapNavigateButton() {
        guard let subVC = SubViewController.create(model: self.model) else {
            return
        }

        self.viewController?.navigationController?.pushViewController(subVC, animated: true)
    }
}

以上で、具体的な実装方法の説明を終わります。

まとめ

  1. Viewとは
    • 画面の構築/表示を行う
    • (内部表現をUI要素へ変換する)
    • (ユーザー操作の受付を行う)
  2. Modelとは
    • 状態・値を持つ
    • Modelの外から指示を受け処理を行う
    • 状態・値の変化をModelの外へ 間接的に 知らせる機能持つ
  3. 間接的な通知方法として、Observerパターンを利用する
    • 理由
      • Modelを「Viewの存在がなくても成り立つクラス」として作れる
      • Modelの状態の変更を、複数Viewに同タイミングで渡せる
      • Modelで行なった処理の結果を、Viewは受動的に知ることができる

今回はViewに「内部表現をUI要素へ変換する」「ユーザー操作の受付」という役割も含めました。
ViewとModelの接続を変えることで、Viewをより単純にすることができます。
次 -> ライブラリを使わずにMV*の話(iOS)〜MVC, MVP〜

補足: @objc を避けてSwiftのみでModelを書く

StarModel2 として定義しました。
(TypeErasure を使っています。)

補足: ViewControllerをinitで初期化する

Sub2ViewController として定義しました。
(StoryBoardを利用していません。xibは利用しています。)

補足: ViewControllerをそのまま渡すのを避ける

NavigatorProtocolを定義し、
NavigatorContract の中に ViewController を入れて渡すことで、Viewと ViewController との間に直接の関わりが無いようにします。

MainViewHandler
class MainViewHandler {

    // ... 中略 ...
    private let navigator: NavigatorContract

    init(
        // ... 中略 ...
        navigateBy navigator: NavigatorContract
    ) {
        // ... 中略 ...

        self.navigator = navigator

        // ... 略 ...
    }

    @objc private func didTapNavigateButton() {
        guard let subVC = SubViewController.create(model: self.model) else {
            return
        }

        self.navigator.navigate(to: subVC)
    }
}
31
19
6

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
31
19

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?