Edited at

MVC-With-DataSource アーキテクチャを提案してみる

More than 1 year has passed since last update.


前書き

みなさんご機嫌よう。深夜になってハイテンションになってしまったガルパンおじさんの筆者です。ガルパンはいいぞ(ry

まあ関係ないことは良しとして、今日はちょっと iOS 開発上みなさんお馴染みの MVC アーキテクチャのちょっとした改良?についてお話ししたいと思います。まあご存知の通り、典型的な MVC アーキテクチャでは Controller が肥大化しやすいとか、一直線の所属関係じゃないから上手く処理してあげないと循環参照も起こしかねないとか、いろいろとちょっとした残念なところもあります。そのため新しい MVVM アーキテクチャで置きかわるとかの手法も考案されたりしていますが、まあ個人的には MVVM は一直線な所属関係で非常にわかりやすいから結構好感も覚えますが、元は .NET フレームワークのアーキテクチャであり UIKit アーキテクチャでそのまま利用するのは多少難あったりもするから、MVC のままでどうにか出来ないかを考えてきました。


今までの MVC アーキテクチャの問題点

以前の投稿で簡単にまとめてみましたが、直接にお互い参照しないビューとモデルの連携の取り方は大まかに 3 通りあって、Callback を使うか、NSNotificationCenter を使うか、Delegation を使うかです。しかしそれぞれにメリット/デメリットがあって、Callback は実装がシンプルな反面複雑な処理には向いてなかったり、NSNotificationCenter は完全に疎結合ができるが通知をいちいち登録/解除をしなければならないのでメンテするときそれ気づかずミスってしまいやすかったり、Delegation はあらゆる仕事に対応できるが Controller に余計な仕事(データ転送)も押し付けてるので肥大化しやすかったりと、なかなか完璧な処理方はありませんでした。ならばもう直接 View に Model 参照させるべきだ!と言い出されるくらい強引な解決法まで考案されました(まあこの例は iOS 開発の例ではありませんが)。しかし Model は本来一まとまりのデータ処理をする部品であって、その性質ゆえ内部が非常に複雑な実装が入ったりいろんなメソッドやプロパティがアクセス可能になってたりしており、一つだけでなく複数の View クラスの必要なデータを対応してるのが一般的です。そのため View が直接 Model 参照するということは、すなわち View が自分が必要としないプロパティやメソッドのアクセスまでできてしまうということを意味し、ただでさえ一直線でないためわかりづらい MVC アーキテクチャの所属関係をより一層複雑にしてメンテ性を低下させてしまいます。やはり View に直接 Model 参照させるのは良くない。


UITableView からヒント

いきなりですが、初心者にとって UITableViewController はある意味鬼のような存在ではないでしょうか。まだ MVC もクソもわからない頃なのに、いきなり tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell とかいう謎な呪文みたいなメソッドが数多く出てくる。ワケガワカラナイヨ。

まあでもそこで挫折しては一人前のプログラマになるまい。なぜそうなっているのかを研究し、理解してこそプログラマとして成長する。というわけでもうズバリ言ってしまうけど、実は UITableView は、delegate の他に dataSource というプロパティがあって、普通の Xcode の自動生成のコードだと UITableViewController はその両方に対応しているため UITableViewdelegatedataSourceUITableViewController になっていますが、必要なら dataSource を他のクラスに持って行くこともできるようになっています。そして、tableView.reloadData() を呼び出すことによってテーブルが dataSource 経由で必要なデータを取ってきて表示する、という仕組みになります。

まあとはいえ、テーブルの場合は全て上位クラスの UITableViewUITableViewController が仕事をこなしており、実際表示させている UITableViewCell はそこまで仕事していない(本当にデータの表示レイアウトを処理するくらい)が…ただこの発想、通常の UIView にも持っていけるじゃないの!?と閃いた次第です


MVC-With-DataSource とは

というわけで、この仕組みを簡単に説明してみます。まず基本はやはり MVC なので、普通の MVC と同じように、Model、View、Controller とそれぞれあって、Controller が Model と View を両方参照しているが、Model と View はお互い参照しません。

そして、Model はやはり ModelDelegateprotocol があって、Controller はこの ModelDelegate を対応します。モデルが何か処理終わって、ビューの更新が必要だよ、ということをこの ModelDelegate 通じて Controller に通知します。ただここで肝になるのはこの通知自体にデータは含まれません。そのため、データの種類ごとにデリゲーションメソッドを作る必要がなく、全部まとめて一つだけの通知か、必要ならある程度カテゴライズして必要最小限の通知メソッドだけ作ればいいです。

では、Controller がその通知を受けて、View を更新させる時、View はどうやってデータを取得するか、というと、ここは ViewDataSource という protocol を作り、ViewdataSource という ViewDataSource プロパティを保持させます。ここで自分の更新に必要なデータを ViewDataSource のプロパティもしくはメソッドを通して取得させます。そしてこの ViewDataSource という protocolModel が対応します。これにより ViewModel はお互い直接参照してませんが、ViewDataSource という protocol が懸け橋となって View に必要なデータを転送できます。

というわけでこの実装により、モデル/ビュー/コントローラーの関係が下記のグラフになりました!


メリット?


  • まあ一番のメリットはやはりこれで Controller にデータ転送の仕事をなくしたから、普通の Delegation を使う方法と比較すると肥大化しにくくなりますね。

  • そして NSNotificationCenter と違ってきちんと定義がある分、実装し忘れたみたいなハプニングもメンテの時発生しにくくなると思われます。

  • さらに Model には複数の ViewDataSource に対応させることも可能ですので、それぞれの View に必要なものだけアクセスさせることができ、メンテ性と再利用性を両方確保できちゃいます。

  • プラス各 ViewDataSource をそれぞれ個別の extension で対応させればコードの読みやすさも向上され、一石三鳥です♪


デメリット?

まあ ViewDataSource という新しいものが出てきてしまってソースコードの記述量が増えたくらいかな?あとは一応描画通知とデータ取得の 2 ステッププロセスになるのでパフォーマンス的にはデリゲーションで一発でデータも転送してしまう方法より微妙に劣るかもしれません…とは言ってもこんな 1 フレーム以下のタイムロスはほぼ無視できるしそれ言ったら多分 NSNotificationCenter よりはまだパフォーマンスいい…はずです


理屈はわかった。で?どう作るの?

そんなあなたのために、GitHub でサンプルプロジェクト公開しましたよ!まあプロジェクト階層とか気にしないなら、下記のコードを見ても多分雰囲気は掴められるかと思います:


Model

import UIKit

protocol MyModelDelegate {
func dataHasUpdated()
}

class MyModel: NSObject {

var delegate: MyModelDelegate?

var title: String {
didSet {
self.delegate?.dataHasUpdated()
}
}

override init() {

self.title = "Hello, world!"

super.init()

}

func increaseTitle() {

self.title += "!"

}

}

extension MyModel: MyViewDataSource {
var titleString: String {
return self.title
}
}



View

import UIKit

protocol MyViewDataSource {
var titleString: String { get }
}

class MyView: UIView {

var dataSource: MyViewDataSource?

let titleLabel: UILabel

init() {

self.titleLabel = UILabel()

super.init(frame: .zero)

self.backgroundColor = .whiteColor()

self.titleLabel.frame = CGRect(origin: .zero, size: self.frame.size)
self.addSubview(self.titleLabel)
self.titleLabel.autoresizingMask = [.FlexibleWidth, .FlexibleHeight]

}

required init?(coder aDecoder: NSCoder) {

self.titleLabel = UILabel()

super.init(coder: aDecoder)

self.titleLabel.frame = CGRect(origin: .zero, size: self.frame.size)
self.addSubview(self.titleLabel)
self.titleLabel.autoresizingMask = [.FlexibleWidth, .FlexibleHeight]

}

override func didMoveToSuperview() {

super.didMoveToSuperview()

self.updateView()

}

func updateView() {

self.titleLabel.text = self.dataSource?.titleString

}

}



Controller

import UIKit

class MyController: UIViewController {

let myModel: MyModel
let myView: MyView

init() {

self.myModel = MyModel()
self.myView = MyView()

super.init(nibName: nil, bundle: nil)

}

required init?(coder aDecoder: NSCoder) {
self.myModel = MyModel()
self.myView = MyView()
super.init(nibName: nil, bundle: nil)
}

override func viewDidLoad() {

super.viewDidLoad()

self.myModel.delegate = self
self.myView.dataSource = self.myModel

self.myView.frame = CGRect(origin: .zero, size: self.view.frame.size)
self.view.addSubview(myView)
self.myView.autoresizingMask = [.FlexibleWidth, .FlexibleHeight]

}

override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}

override func touchesEnded(touches: Set<UITouch>, withEvent event: UIEvent?) {

super.touchesEnded(touches, withEvent: event)

self.myModel.increaseTitle()

}

}

extension MyController: MyModelDelegate {
func dataHasUpdated() {
self.myView.updateView()
}
}



あとがき

気づいたらもう深夜 3 時だ。やはり深夜のテンションはおかしい。


2016/10/14 追伸

この記事を執筆当時はまだ class-only protocol について知らなかったまま書いてしまったが、このままでは循環参照によるメモリリークが発生してしまいますので、やはり修正が必要です:


Model

import UIKit

protocol MyModelDelegate: class {
func dataHasUpdated()
}

class MyModel: NSObject {

weak var delegate: MyModelDelegate?

var title: String {
didSet {
self.delegate?.dataHasUpdated()
}
}

override init() {

self.title = "Hello, world!"

super.init()

}

func increaseTitle() {

self.title += "!"

}

}

extension MyModel: MyViewDataSource {
var titleString: String {
return self.title
}
}



View

import UIKit

protocol MyViewDataSource: class {
var titleString: String { get }
}

class MyView: UIView {

weak var dataSource: MyViewDataSource?

let titleLabel: UILabel

init() {

self.titleLabel = UILabel()

super.init(frame: .zero)

self.backgroundColor = .whiteColor()

self.titleLabel.frame = CGRect(origin: .zero, size: self.frame.size)
self.addSubview(self.titleLabel)
self.titleLabel.autoresizingMask = [.FlexibleWidth, .FlexibleHeight]

}

required init?(coder aDecoder: NSCoder) {

self.titleLabel = UILabel()

super.init(coder: aDecoder)

self.titleLabel.frame = CGRect(origin: .zero, size: self.frame.size)
self.addSubview(self.titleLabel)
self.titleLabel.autoresizingMask = [.FlexibleWidth, .FlexibleHeight]

}

override func didMoveToSuperview() {

super.didMoveToSuperview()

self.updateView()

}

func updateView() {

self.titleLabel.text = self.dataSource?.titleString

}

}


こうすればメモリリークが防げます