前書き
何かと最近 iOS 界隈では設計の話が流行ってるようで、乗るしかない、このビッグウェーブに自分が今まで開発してきた経験と今使っている設計を一回整理してまとめるチャンスでもあると思って、この記事を執筆させていただきました。長々と駄文を垂れ流してますがどうか温かい目で見守っていただけると幸いです。
また、あらかじめ断っておきますと、筆者が今使ってる設計は一昨年書いたこの振り返り記事に基づいた MVC モデルから派生したものです。
なぜ未だに MVC
「未だに」という言葉には語弊を感じる方もいるかもしれません、が、最近の iOS 界隈ではやはり Clean Architecture とか VIPER とかの設計がとても話題になっているのも事実なんじゃないかと思います。そうでなくても、MVVM や MVP もじわじわと着実に浸透していると感じております。少なくともハッカソンをいくつか参加してきて、一緒に iOS 開発やってる他の方のコード読んでみると割と MVC を率先的に使う人は筆者自身含めてかなりの少数派のように見えます。
確かに MVC ではいわゆる Fat Controller や、Controller に View のライフサイクルが介入しておりユニットテストがしにくいなどの問題があります(そもそも iOS の場合 ViewController って名前だしな)、しかしなにせ Cocoa と UIKit 自体がそもそも MVC で作られているから、どうしても MVC で開発した方が気持ちいいと感じており、やはり MVVM などよりも MVC で開発したいと思ってます。
また、勉強会をいくつか参加して思ったのは、どうも複数人で一つのプロジェクトを開発していくのが大多数のようですが、筆者自身はハッカソンを除けば仕事個人含め全ての開発は一人だけでやっています。仕様設計から実装のコーディングまで全て一人です。おまけにたまにはグラフィックの仕事もやってます。そう言った意味では Clean Architecture のような複数人による大規模な開発のための設計は向いてないと思われます。
しかし当然ながら「設計」というのは絶対正義がありません。様々な設計はそれぞれのメリットがあり、逆にデメリットもあります。設計の存在理由は最速のスピードでアプリを開発/保守し、なおかつ最大限のパフォーマンスを引き出すための道具に過ぎず、実際の運用ではそういったメリットとデメリットのトレードオフをしっかりと考えた上で、当事者同士で相談し、決めるべきことです。そういった意味で、本記事は別に何も「この設計が最強だ」なんてことを言うつもりがありません。タイトルも「俺の考えた最強の iOS 開発の設計」なんて物騒なものではありません。あくまで「俺の俺による俺のための設計」です。しかしいま開発してて「どんな設計を採用すべきかわからない」と言った疑問を考える際に、何か突破口的なヒントになれれば幸いだと考えております。
TL;DR
一つ「PresentationDelegate」という部品が増えましたが、これは Controller の下位部品であるいわゆる一般的な「Presenter」ではなく、画面遷移を司る、Controller の上位部品です。一般的な呼び名はないので、UIViewController
の present(_:animated:completion:)
メソッドから命名しました。特徴としては「入力と出力の分離」、「一方通行なデーターフロー」と「厳格なヒエラルキー」だと考えております。
MVC 改の誕生
既存の MVC は前述通り様々な問題点を抱えているため、それらをカバーするために様々な改良が世の中にはたくさんありますが、筆者の中では一番改善したいと思っているのは下記のものです:
- Model と Controller、Controller と View の役割が曖昧
- Controller に Child Controller がある場合、Child View の所属が曖昧(Child View は Child Controller のものなのか Parent View のものなのか)
- データーフローが一直線ではない
また、上記以外にも、MVC 自体の問題ではありませんが作り方によってありそうなアンチパターンとしては
- 親と子の所属関係(ヒエラルキー)が曖昧
と挙げられると思います。なので、筆者が今使っている設計は主にこれらの問題を解決すべく、考え出した設計です。
ちなみに、お気付きの方がいるかもしれませんが、実は上記のリストに、一番よく言われている「Fat Controller」が入っていません。これはなぜかと言うと、筆者の中では「Fat Controller」自体は悪だとはそれほど思っていません(もちろん Fat すぎるのは流石にちょっと…とは思うが)、問題は責任所在の曖昧さです。つまり、役割さえ明確にしっかりしていれば、Controller が多少 Fat になってもそれほど問題にはならないと考えているのです。また、筆者は基本一人で開発をやっているので、部品部品をそこまで細かく細分化しなくても大丈夫だ(むしろ細分化しすぎると開発スピードが落ちる)と考えています。もちろんこれは人それぞれですし、この設計自体が万人向けではないことも自覚しております。
Model と Controller、Controller と View の役割が曖昧
Cocoa、そして UIKit が築き上げた MVC は、そもそも「Controller」と言う名前ではなく、「ViewController」と言う名前です。そのため Controller が View のライフサイクルを大きく介在しており、Fat Controller の原因の一つでもあると言われています。また、Cocoa の典型的な設計では ViewController が View の架け橋ともなっているため、非常に大量のデータフローが Controller に押し付けされ、結果的に Controller が本来 Model が為すべく仕事もやる羽目になりかねません。
そのため、筆者の中では Model、Controller と View の役割をこんな風に分けております:
- Controller:ユーザからの操作やデータの入力受付、及び Model と View の制御(まあ「Controller」って名前だし「Control」の仕事はこなしてなんぼだな)
- Model:Controller から制御命令を受けて、具体的な何かの処理をし、その出力を View に
DataSource protocol
を介して渡す - View:Controller からの命令を受けて、
DataSource protocol
を介して Model に出力データを求めてユーザに見せ、その際見せ方(レイアウトやビジュアル)も決める
と言った感じです。
MVVM を馴染んだ開発者ならこう言うかもしれません。「入力を Controller、出力を View に役割分担してるようだけど、そもそも入力と出力が分離しにくい」と。そもそも MVVM はそう言った経緯があるからあえて入力と出力を無理に分離せずに ViewModel とデータバインディングをうまく駆使して作られた設計ですからね。
しかし筆者は部品自体が分離できなくても、部品のパーツ(?)ならうまく分けることはできると思います。
例えばボタン一つ作るとします。確かにボタン作るのに UIButton
を作る必要があります。しかし UIButton
は入力部品でありながらも、出力である UIView
のサブクラスではあります。しかし入力側として欲しい情報は UIButton
そのものではなく、この UIButton
はどんなやつ(名前)か、そして押された時、どう言った処理を行いたいか、です。そのため、Controller 側が用意すべくものは UIButton
そのものではなく、UIButton
の title
と target
だけです。あとは View
にこれらの情報渡して View
に Button
を作ってもらって表示してもらえばいいです。その場合、この Button
はどんな大きさで、どの位置で表示するのかと言った情報は「出力」ですので、View
に決めてもらえばいいです。Controller
が介在する必要がないのです。
つまり、ソースコードにすると、このような感じです:
class View: UIView {
func appendButton(title: String, onTappedTarget target: Any, action: Selector) {
let button = UIButton(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
button.setTitle(title, for: .normal)
button.addTarget(target, action: action, for: .touchUpInside)
self.addSubview(button)
}
}
class Controller: UIViewController {
override func loadView() {
let view = View(frame: UIScreen.main.bounds)
self.view = view
}
override func viewDidLoad() {
super.viewDidLoad()
(self.view as? View)?.appendButton(title: "Tweet", onTappedTarget: self, action: #selector(self.onTweetButtonTapped(sender:)))
}
@objc private func onTweetButtonTapped(sender: UIButton) {
print("Tweet button tapped")
}
}
もちろん実際、UIButton
が不都合だってわかってる場合は、View ではなく Controller が直接ボタン作って View に渡すこともありだと思います、が、肝心なのは Controller はあくまで View にボタンを渡すだけです。View 側でどのようにこのボタンを表示するのかと言ったビジュアル面な仕事は Controller は介入しないことです。そしてボタンだけでなく、何か表示を更新しなければならない時も、Controller はあくまで View に「更新して」の命令を渡すだけで、実際どのように更新するのか、更新したデータをどのように表示するのかはすべて View 側の仕事で、Controller は深く介入しません。
ちなみに、UIKit
をそのまま利用する時、実際レイアウト情報を完全に View 側に把握させるのは難しいです。UIView
に自分自身の frame
プロパティーはあるのですが、動的に変わることはできないため、Auto Layout の必要な constriants
をその都度変更するか、layoutSubviews()
をオーバーライドして直接子ビューの frame
を書き換えるしかありません。それを解決するために開発したのが NotAutoLayout というフレームワークです。これを利用すれば、特定な View に特定なレイアウト情報を結びつけることができるだけでなく、例えば先ほど言ったような Controller が直接ボタンを作って View に渡したいと言った場合、通常なら View 側があらかじめこの渡されるボタンのプロパティーを保持しておかないと、レイアウトする際にボタンの特定ができないためレイアウト情報を作るのは難しいですが、NotAutoLayout を使えば View 側にボタン用のレイアウト作ってもらって、それを一緒に View に渡すことによって、Controller はビジュアル面の仕事を介入せずにボタンを作ることができます。
以上は直接コードでビューを作る場合の話ですが、Storyboard で画面を作る場合は少し話が変わります。なにせ Storyboard が作るのは ViewController
ですが、レイアウトなどのビジュアルな面の仕事ももたらせています(だから筆者は Storyboard が大っ嫌いですがまあこれはまた別の話です)。なのでどうすれば一番いいかと言うと、おそらく自作 View
を Storyboard に敷いて、@IBOutlet
部品はすべて View
側に持たせ、Controller
側は @IBAction
のみを持たせた方が役割分担ができそうです。(ただし筆者自身はそもそも Storyboard を使わないので保証はできません)
また、Controller と Model の分け方、つまり「どこまでが Controller、どこからが Model」かについては、筆者の中では基本「操作」をするのは「Controller」、「処理」をするのが「Model」、と言う風に分けています。この場合、「操作」と言うのは何もユーザから直接受けた入力だけを指すわけではありません、例えば Model や UNUserNotificationCenter
から何か通知を受けて、それに対して何かしなければならない、と言った場合も「操作」と見なしています。つまり、「何か状態の変化が発生するかもしれない」と言った外部命令はすべて「操作」と見なしています。そのため、NSNotificationCenter
にオブザーバーを追加する対象も、原則的に Controller となります。逆に、それらの操作を受けて実際どんな風に処理するのか、を具体的に実装するのが Model の仕事にしています。
このような作り方していれば、当然 Controller の仕事はそこそこ多いので、Fat Controller になりかねません。しかし前述通り、筆者の場合は問題点は Fat Controller ではなく、役割の不明確だと考えているので、逆に Controller が多少 Fat でも、役割が明確しているため何か仕様変更が発生するときもどこを確認すべきかはすぐに特定できるため、ある意味仕様変更に強いと言えるかもしれません。
Controller に Child Controller がある場合、Child View の所属が曖昧
UIKit はそもそも Controller と View はある意味「密結合」をしています。だから UIKit の Controller はそもそも「UIViewController
」って名前してますし、そのプロパティーに「view
」というプロパティーが存在します。
この設計によってたくさんの問題点をもたらしています。このチャプターのサブタイもまさにその一つです。要するに、画面の機能が豊富になると、ある程度画面を細かく分割して、それを現在の画面の Main Controller の Child Controllers として追加した際、UIViewController
が持つ view
を実際の画面表示に使わせることになるのですが、この Child Controller が持つ自分の view
は当然ながらその Child Controller の子供ですが、しかしその view
を Parent Controller の view
を追加して表示しないといけないため、じゃあこの Child Controller の view
は Parent Controller の view
の子供と見なしていいのか。また見なさない場合、レイアウトはどうするのか。
筆者自身はこの場合、やはり UIKit の作り方をなるべく尊重(という名の妥協)して、Child Controller の view
は Child Controller の子供だけだと見なし、Parent Controller の view
の子供だと見なさないようにしています。この際一番難しかったレイアウトの問題は、先ほど紹介した NotAutoLayout を利用して、Parent Controller の view
はレイアウト情報のみを作り、このレイアウト情報と実際の Child Controller の view
との結びつけは Parent Controller が行うようにしています。こうすることによって、綺麗に Controller と View の所属関係をはっきりした上でそれぞれの仕事に専念させることもできました。
データーフローが一直線ではない
いわゆる Cocoa MVC の図は、多くの開発者はすでにご存じでしょう。
この設計の最大のメリットは構造が非常に単純なため、イメージがしやすいので習得ハードルが低く、とにかく作りやすいことにあると思います。しかしデメリットはデータフローは一方通行ではなく、View から Controller にデータも流れれば、逆に Controller から View にもデータが流れます。
データフローが一方通行でないことの最大の問題点は、入力に対し想定した出力が得られなかった場合、どこでデータの処理がバグったのかという問題点の特定が一直線なデータフローと比べるとしにくいことがあると思われます。
そのため、筆者自身は基本データが一方向で流れるよう作ることを心がけています。Controller は入力、View は出力を管理するため、データは基本全て Controller から View に流れます。唯一の分かれ道は途中で Model を経由するかどうかですが、これも非常に単純で Model を介する意味がない場合以外、原則的にデータは Controller → Model → View の流れです。先ほどの図を流用するとこんな感じです:
具体的にどうするかというと、「Model と Controller、Controller と View の役割が曖昧」の章に説明したように、Controller はボタンなどの入力パーツを作り、直接 View に渡します。この場合 Controller はボタンの入力制御に必要なタイトルやターゲットのみを設定します。ターゲットは Model に対する操作です。そして、View と Model の間は DataSource protocol
を介して、Model が View の DataSource
に準拠させ、View が DataSource
を weak var
として保持します。View が Controller から更新しての操作命令をもらったら、DataSource
を介して必要なデータを引っ張ってきます。Controller からの更新命令は具体的に操作の複雑さにもよりますが、例えば単純なものでしたら Controller が Model に命令したらすぐ次に View を更新させてもいいですし、逆に時間がかかりそうなものでしたら Model は Delegate
を介して処理が終わったことを通知します。もちろんこの場合 Delegate
先は Controller になります。
このように、Model と Controller を Delegate
、そして View と Model を DataSource
でつなぐ最大のメリットは、以前の記事にも書いた Controller の肥大化をある程度防げる以外にも、参照関係のヒエラルキーを壊すことなくデータフローを一方向に保つこともできます。
親と子の所属関係(ヒエラルキー)が曖昧
これは別に何も MVC の問題ではないと思います。そもそも MVC 自体は「パーツのヒエラルキー」なんてことを明確に定めていませんし、こんなのを定めた設計はあんまりないじゃないかと思います(Clean Architecture はあるように感じますが…詳しく勉強したことがないので確信はありません、詳しい方ツッコミお願いします)。
しかし「MVC 改の誕生」の章に述べた通り、設計、特に保守において一番重要なのは責任所在の曖昧さをなくすことだと筆者個人は思います。そのため、責任の所在を明確にするためにも、ヒエラルキーを明確にするのも非常に重要だと考えております。
また、そもそも「オブジェクト指向」に置いて、外部から情報を隠蔽しカプセル化することによって、部品の再利用性とメンテナンス性の向上を目指すことは重要ですので、そういった意味でも「ヒエラルキーの明確化」は、「必要最低限のことだけを知らせる」ことでもあり、それによって再利用性とメンテナンス性の向上も図れます。
具体的に、個人的には「AppDelegate(滅多に使うことはないが)」>「Presentation Delegate」>「Controller」>「Model」=「View」のヒエラルキーにしています。
AppDelegate はそもそもほとんど使うことがない(バックグラウンド処理には活用することも多いが)ので詳しい説明は割愛します。
PresentationDelegate は「TL;DR」に説明した通り、基本画面遷移を司るものです。基本 Controller は presentationDelegate
を介し、遷移先の Controller を取得して、present
するようにしています。ただこれはあくまで同じヒエラルキーにある Controller に遷移する場合です。明らかに自分より下位な Controller、例えば UIAlertController
に遷移する場合は自分が直接 UIAlertController
作って遷移することも可能です。
ちなみに present
ではなく navigationController.push
を使う場合は、似たような NavigationDelegate を作ることもあります。ただしこれは基本一連の流れ作業の Controller を順番に遷移する場合です。明らかに自分の下位な Controller、例えば「設定」アプリのような、テーブルビューから色々セルがあってそれをタップすることで指定した Controller に移動する場合は特に NavigationController を介して必要な Controller を取得することは必要ないです。あくまで「自分と同じヒエラルキーにある Controller」に遷移する場合のみです
Controller は操作を受け付ける部品ですので、Model と View より一つ上のヒエラルキーにあります。Controller は Model を保持しますが、Model は Controller を保持しません。View に関しても同じです。Model が Controller に何か操作を依頼したいときは基本 Delegate protocol
を介して行います。しかし View は基本出力のみを行うため Controller に何か操作を依頼することはないと想定します。
同じヒエラルキーにある Model と View ですが、お互いは直接参照することなく、View だけは DataSource protocol
を介して Model から出力に必要なデータを取得します。
と、上記のように、ヒエラルキー上位にあるものがヒエラルキー下位のものを保持するだけのように作ります。自分の責任で処理できないものはヒエラルキー上位にあるものに対しては Delegate protocol
で処理を依頼します。
以上は Controller、Model と View の間のヒエラルキーですが、じゃあそれぞれ自分の子供を持つとき、例えば Controller が Child Controller、Model が Child Model を持つ場合はどうでしょうか?
ヒエラルキーだけを見ると、Child Controller は Model と同じヒエラルキーにあるように思えますが、お互い無関係です。つまり Child Controller と Model の間が何か直接に参照することは絶対にありえないのでどっちが上かという問題は無意味かと思います。ただし Child Model と Model、Child Controller と Controller の間は、Model と Controller と同じように基本 Delegate protocol
で処理を依頼するように作ります。自分の責任外のものは責任を持つものに処理を依頼します。ただ View だけは先ほど View と Controller と同じように、基本出力するだけなので親に対して何か依頼することはないと考えています。
と、このようにヒエラルキーを徹底して意識し、自分の責務のことは自分で行い、子供の責務は子供に行わせ、親の責務なら親に処理を依頼するように作ることで、メインテナンスするときどの処理はどの部品が行なっているのかを明確にすることを図っています。
なぜヒエラルキーが重要かというと、以前の記事にも書いたが子が親を把握してはいけないと思います。子が親を把握していると、何か操作を行なった際、その動作は一体どこが行なっているのかという責任の所在が不明確になりますのでメンテナンス性が低下します。
ちなみにヒエラルキーは重要ですが、例えば途中の階層を飛ばして直接孫とかに仕事をさせることは筆者はないようにしています。孫の仕事だとわかっても子に仕事を行わせ、子がまたその仕事を自分の子、つまり孫に流すように作っています。なぜこのように作るかというと、もし将来何か仕様変更があって、この処理が子供にもちょっと手続きをやってもらいたい場合は、子供のそのメソッドを修正するだけで済むからです。もちろん逆にいうと子供の仕事を押し付けることになるので、ソースコード量も増えればしコールスタックも当然増えます。
番外:「音」の階層?
ほとんどの場合、そしておそらく大多数の開発者もそもそもアプリに「音」が入ることがなかったかもしれません。しかしゲームとかを作る場合、音がないとやはりどうしても寂しいですね。じゃあ音はどの階層にあるべきでしょうか?
筆者自身の場合は「音」は「画面」と同じ「出力」だと見なしています。なので基本「View」と同じ階層に置いています。また、View と同じ出力なので、基本 Model とは DataSource protocol
でつなぐようにしています。
まとめ
- Controller、Model と View はそれぞれの責任を明確化
- Controller は入力で、View は出力なので、データフローは Controller → Model → View の一方通行
- ヒエラルキーを重視し、自分の責務なら自分で行い、子供の責務なら子供にやらせ、親の責務なら親に依頼する
あとがき
最初にも書いた通り、これは別に「俺が考えた最強の iOS 設計」ではありません。他の設計と同じようにメリットもあればデメリットもあります。しかし設計を考えることはすなわち、どうすれば目的の機能をなるべくバグなしに実装できるかを考えることでもあります。コーディングのスキルが向上してきたら、そろそろ次のステップ:設計を考えてみましょう。優雅なコードから、優雅なアーキテクチャへ。そして「設計を考える」に当たって、たくさんの方法がありますが、一つ個人的にオススメしたいのは好きなアプリや、何かのアプリの中の好きな機能を見つけ出し、作者はそのアプリもしくはその機能をどうやって実装したのかを考えてみて、できればそれをコードで再現してみることです。もちろん同じ設計になるとは限りませんが、そうすることによって設計を考えられるだけでなく、iOS 開発に(ほぼ)必要不可欠な UIKit をより知るチャンスでもあります。優れたアーティストは真似る。偉大なアーティストは盗む。