今更なんですが、Protocolの話です。
サンプルとして「カスタムViewを疎結合に組む方法の話」をします。
なおSwiftUIは無視します。
1.カスタムViewの例
カスタムViewを作る際に困ること
iOSにおいてカスタムViewを作ると言ったらざっくりこうなるでしょう。
違和感は無いと思いますが、実際に作ってみると案外上手く組めません。
悩みの原因はどこに書くべきか迷う処理が存在するからです。
カスタムViewをトリガーにしてViewControllerに何かさせたい時
カスタムViewをトリガーにしてViewControllerに何かさせたい時の処理を考えます。
例えば「Viewの中のボタンをタップしたときに画面遷移をする」のような処理です。
抽象的にはこうです。
実際にこれを実現するために、教科書的にはdelegateを使用します。
Objective-CでBlock、Swiftで言えばClosureが登場して以降は、delegateはしばしばclosureに置き換わるようになりました。
いつもどおりの普通の設計です。
モジュール化におけるdelegateにどういう問題があったか
これらの書き方自体に大きな問題があるわけではないのですが、プログラムを継続開発していくと、どうにもしっくり来なくなってきます。
- 毎回同じ処理を別のViewControllerに書いている
- そもそもView特有の処理なのにViewControllerに書いているのがしっくりこない
- 複数のカスタムViewを扱う場合、それぞれのdelegateがViewControllerに存在していて、混乱する
これらのモヤモヤは気の所為ではなく、実際にカスタムViewを改修する時に作業コストに跳ね返ってきます。
これを解消しようとして、カスタムViewに対する共通クラスを作ってしまい、更に混乱するというのもよくあります(ViewModelもその一つです)
どういう仕組みがあればうまくいきそうか?
- delegateの共通処理を書きたい
- カスタムViewがViewControllerに対して行うお決まりの処理を、カスタムView側に書いてしまいたい
これらがあれば上手くいきそうですよね。
そしてこれらはprotocolで実現することができます。
Protocol Extensions(デフォルトの処理)
swiftのprotocolにはデフォルトの処理が書けます。
////// protocolの定義
protocol CustomViewProtocol {
/// ボタンがタップされたときに呼ばれる
func buttonDidTap(button: UIButton)
}
extension CustomViewProtocol {
/// デフォルトの処理をこのように書ける
func buttonDidTap(button: UIButton) {
print("ボタンが押された")
}
}
////// CustomView内
class CustomView: UIView {
var delegate: CustomViewProtocol!
@IBAction func buttonTouchUpInside(_ sender: UIButton) {
delegate.buttonDidTap(sender)
}
}
////// 利用側UIViewController
class HogeViewController: UIViewController {
@IBOutlet weak var customView: CustomView!
override func viewDidLoad() {
super.viewDidLoad()
// customViewの設定は済んでいる前提
customView.delegate = self
}
}
extension HogeViewController: CustomViewProtocol {
// これでCustomViewProtocolをHogeViewControllerに適用したことになる
}
これだけでかなりパワフルです。
Protocolを適用した先で毎回同じ処理を書かなくてすみます。
でも、共通化出来るとは言ってもProtocol ExtensionであるbuttonDidTapから画面遷移はできなさそうに見えますね?
これができます。
Class-Only Protocols
ドキュメントにはちょろっとしか書いてない項目ですが、これが非常に強力です。
////// protocolの定義
protocol CustomViewProtocol: UIViewController { // ❗️
/// ボタンがタップされたときに呼ばれる
func buttonDidTap(button: UIButton)
}
extension CustomViewProtocol {
/// デフォルトの処理をこのように書ける
func buttonDidTap(button: UIButton) {
print("ボタンが押された")
self.show(nextViewController, sender: nil) // ❗️
}
}
////// CustomView内
class CustomView: UIView {
var delegate: CustomViewProtocol!
@IBAction func buttonTouchUpInside(_ sender: UIButton) {
delegate.buttonDidTap(sender)
}
}
////// 利用側UIViewController
class HogeViewController: UIViewController {
@IBOutlet weak var customView: CustomView!
override func viewDidLoad() {
super.viewDidLoad()
// customViewの設定は済んでいる前提
customView.delegate = self
}
}
extension HogeViewController: CustomViewProtocol {
// これでCustomViewProtocolをHogeViewControllerに適用したことになる
}
❗️の行が変更箇所です。
protocolは特定のClassに固定することができて、その場合、ProtocolExtensionでその特性したClassの機能を使うことができます。
つまり、共通化された処理の中で画面遷移も書くことができます
どのように設計が変わるか
この設計の良いところは、CustomViewを画面が使いたいとなったときに、CustomViewProtocolを適用するだけであとは全部CustomView側でやってくれることです。
UIViewController(画面)はそのCustomViewについて特に知らなくてよいです。
これまでは
- CustomViewを作る
- 画面を定義するときに設定してほしい諸々をdelegateとして定義する
だったので、カスタムViewの仕様を利用側に深く理解させなければなりませんでした
これが、
- CustomViewを作る
- 画面が行うべきタスクをdelegateとして定義し、共通処理を書く
に変わります。
利用側はカスタムViewについてかなり浅い知識でよくなります。
もちろんProtocolExtensionsで記載するのはあくまでデフォルトの定義ですので、拡張することも可能です。
「基本的にこのように動かす」というのをProtocolExtensionsで定義し、例外的な処理は従来どおりViewController側に書くことができます。
注意点1
この設計がを実際に使ってみると、1個の特定のモジュールを分離するだけだとオーバーヘッドが大きいです。可読性があまり上がりません。
最低限2回以上使い回すモジュールに適用するのが良いと思います。
注意点2
カスタムView側がある程度画面のことを知っている前提になります。
モジュール側の責務が増え、改修する際にコストになるので、慎重に設計するべきでしょう。
注意点3
例えばUIViewControllerに縛ったClass-Only Protocolsを通してUIViewControllerの機能が使えてしまいます。
この状態は、例えるならCustomView内にUIViewControllerの参照を持ってる状態です。何でもできてしまい、利用側のViewControllerに思わぬ影響が出る可能性があるため積極的には使わないほうが良いと思います。
実は1章では回りくどい書き方をしましたが、普通にこのように書けてしまうわけです。
////// protocolの定義
protocol CustomViewProtocol: UIViewController {
}
////// CustomView内
class CustomView: UIView {
var delegate: CustomViewProtocol!
@IBAction func buttonTouchUpInside(_ sender: UIButton) {
delegate.show(nextViewController, sender: nil) // ❗️
}
}
////// 利用側UIViewController
class HogeViewController: UIViewController {
@IBOutlet weak var customView: CustomView!
override func viewDidLoad() {
super.viewDidLoad()
// customViewの設定は済んでいる前提
customView.delegate = self
}
}
extension HogeViewController: CustomViewProtocol {
// これでCustomViewProtocolをHogeViewControllerに適用したことになる
}
もちろん、CustomView側にとってViewControllerに変更してほしくない処理もあるでしょうから、そういうシーンでは使うことになるでしょうけど。
2.他の設計と比較する
継承とどこが違うか
今、CustomViewを持つ画面を5つ作ろうと思ったとします。
一番オーソドックスで行儀の良い方法は、CustomViewを作り、それを各画面が持つことです。
その延長線上に、1章で書いたような設計方法があります。
それとは違い、CustomViewを持つCustomViewControllerを作り、それを5つの画面が継承する、といった設計も考えられます。
では、継承する場合とこのプロトコルを使う場合とでは何が違うのでしょうか?
・・・と書きたいのですが、これテーマが重すぎるので別の記事で書きます(宿題)
「継承で起こる問題が起こらない」なのですが、その説明をサッと出来る自信がありません。
ViewControllerの参照を持ってるのとは何が違うのか?
注意点3で書いたとおり、そもそもdelegateの仕組み自体、参照を持ってるのと大差ないように見えます。
(循環参照のことはややこしくなるので一旦忘れます)
delegateという仕組みは、あくまで一旦委譲しているところが違うと思います。
委譲した上で、「いや任せるよ、共通処理でお願い」と言われたときに共通処理を実行します。
あくまで主体は画面側にあり、自分自身で処理することも可能なわけです。
UITableViewDelegate,UITableViewDataSourceと似た状態
iOS,Swiftを勉強していて、UITableVIewDelegateやUITableViewDataSourceを再現しようと思ったことはないでしょうか。
あれと同じ状態です。
つまりdlegateなんだけどれもデフォルトの挙動でいいものは書かないみたいなことができます。
3.View以外に拡張してどう使うか考える
以上を踏まえると、他のモジュールも抽象度高く疎結合に設計できると思います。
また、凝集度があまり高くなく、モジュールにしづらいものでも、割と気軽に共通化ができるのではないかと思います。
5画面で同じような処理がある場合、継承は明らかに重たいですし、extensionはUIViewController全体に及んでしまうため適していません。そういったときはProtocolExtension+ClassOnlyProtocolsで共通化してしまえば良いわけです。
もちろんその発展として、例えばUIViewControllerにあるワンセットの機能を追加するためにこの方法を使うことができます。が、そこまでいくとextensionの領分になりますね。
ちなみにこの考え方は別に新しいものでもなく、JavaのInterfaceやMixinに近いものみたいです(やや自信薄)
4.Protocolは他のProtocolを多重継承できる
多くのプロジェクトにとって一番美味しい部分は3章までだと思います。
ここから先の話はちょっとスパゲティコードになる可能性があります。
注意してください。
一旦、できることできないことをざっと書きます。
/// UIViewControllerに縛ったAAAProtocolを作成
protocol AAAProtocol: UIViewController {
func hoge()
}
extension AAAProtocol {
func hoge() {
print("これはAAAです")
}
}
/// BBBProtocolは、AAAProtocolを継承できる
protocol BBBProtocol: AAAProtocol {
}
extension BBBProtocol {
// 上書きできる
func hoge() {
print("これはBBBです")
}
}
protocol CCCProtocol: AnyObject {
func fuge()
}
/// DDDProtocolは、AAAProtocolとCCCProtocolを継承できる
protocol DDDProtocol: AAAProtocol, CCCProtocol {
}
extension DDDProtocol {
func fuge() {
// もちろんAAAProtocolはUIViewControllerに縛ってるので、DDDProtocol内ではUIViewControllerとして振る舞える
self.show(vc: nextViewController, sender: nil)
}
}
できること、できないこと
/// ❌ 非プロトコル、非クラス型からの継承
/// Inheritance from non-protocol, non-class type 'String'
/// Type 'Self' constrained to non-protocol, non-class type 'String'
protocol StringProtocol: String {
}
/// ❌上と同様
protocol IntProtocol: Int {
}
/// ❌もちろんできない
protocol ArrayProtocol: Array {
}
/// ⭕️可能
protocol UIViewProtocol: UIView {
}
/// ⭕️可能
protocol UIViewControllerProtocol: UIViewController {
}
/// ❌2つのサブクラスにはらなない
/// Protocol 'ZZZProtocol' cannot be a subclass of both 'UIViewController' and 'UIView'
protocol ZZZProtocol: UIViewProtocol, UIViewControllerProtocol {
}
/// ⭕️可能
protocol UIViewControllerProtocol1: UIViewController {
}
/// ⭕️可能
protocol UIViewControllerProtocol2: UIViewController {
}
/// ⭕️これも可能(両方UIViewControllerを継承)
protocol YYYProtocol: UIViewControllerProtocol1, UIViewControllerProtocol2 {
}
/// 🔺これは警告 UIViewControllerの記述が要らない
/// Redundant superclass constraint 'Self' : 'UIViewController'
protocol XXXProtocol: UIViewController, UIViewControllerProtocol {
}
/// ⭕️可能
protocol UIViewControllerProtocol1: UIViewController {
}
/// ⭕️可能
protocol UIViewControllerProtocol2: UIViewController {
}
/// ⭕️可能
protocol UIViewControllerProtocol3: UIViewControllerProtocol2 {
}
/// ⭕️これも可能
protocol UIViewControllerProtocol4: UIViewControllerProtocol1, UIViewControllerProtocol3 {
}
#5. このパラダイムを突き詰めるとどうなるか?
このプロトコル増し増しの設計を突き詰めていくと、おそらく性質を拡張していくようなコーディングスタイルになっていくと思います。
例えばこのようなProtocolが作れます。
/// 色を持っているというProtocol
protocol HaveColor: AnyObject {
var color: UIColor { get set }
}
/// 色を持っているという性質をViewControllerに拡張する
class HogeViewController: UIViewController {
}
extension HogeViewController: HaveColor {
}
「colorを持っているViewController」が作れたら、「colorを持ったViewControllerに対する共通処理」というものが作れます。
こうやってどんどん抽象的なプログラミングが可能になっていきます。
#6.残念ながら今はまだできないもの
非情に残念ながら、このコードは動きません。
このコードは、UITableViewDelegateとUITableViewDataSourceを継承したprotocolのデフォルトの処理を定義しています。
つまり、これを適用したらtableView関係のいつものコードすら書かずにTableViewを組めてしまいます。画面はデータを渡すだけでよくなります。
「ある特定のTableViewを表示する画面という性質」を拡張した状態ですね。
/// ❌ エラーになる
protocol TableViewProtocol: UITableViewDelegate, UITableViewDataSource {
}
extension tableViewProtocol {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 10
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
return UITableViewCell(style: .default, reuseIdentifier: "_")
}
}
class TTTViewController: UIViewController {
}
extension TTTViewController: tableViewProtocol {
}
しかし動きません。
原因はこちらの方がまとめてくださっています。
https://stackoverflow.com/questions/39487168/non-objc-method-does-not-satisfy-optional-requirement-of-objc-protocol
どうやらObjective-Cが混ざっていると上手く動かないようで、UITableViewはObjective-Cで書かれているのでどうしようもないのです。
2019年時点でも修正はできていなくて「SwiftUIのパーツなら使えるからそっち使って」と言った結論になりそうです。
しかしSwiftUIがバリバリ使えるのはまだ少し先なので、もう少し我慢が必要そうです。諦めましょう。
#まとめ
ProtocolExtension+Class-OnlyProtocolを使えばいい感じに設計できるよ!という話でした。
多重継承については、やりすぎると火傷する未来しか見えませんね。私もまだ火傷してないのでどこまで攻めて良いのか探り探りです。
あと少し不満なのは、「ProtocolExtensionに書けばデフォルト処理になる」という仕様が直感的ではないことですね。
この仕様を知らない人が見たら悩ませてしまいそうです。
(なのでこれもっと一般的になってほしいです)