概要
ios開発でどうしてもFat Controllerになってしまう場合の一つの対処方法です。
よく、MVCSS,MVVM,DDDなどで責務を分けましょうという話は聞くと思います。
で、それらのアーキテクチャでControllerにロジックを書かないようにするというのはよく聞く話でしょう。
今回はそういう話でありません。
そもそも論としてControllerの役割が元々多すぎるから、そこを分割しましょう。という話です。
以下がControllerの役割だと思います。
- 画面遷移
- 画面のライフサイクル
- イベントの処理
- Viewの管理
画面遷移に関しては、ios8対応が必要なくなった時には完全にStoryBoard Referenceがその役割を持つようになるでしょう。
が、まだまだ対応するし、StoryBoardもUIViewControllerの持ち物なので素直にControllerに残します。
「画面のライフサイクル」もどうしてもUIViewControllerを継承していないと実現できないので残します。
残りの「イベントの処理」の大半と「Viewの管理」はUIViewControllerを継承していなくてもできることなので、そこを抜き出しましょう。
やり方
インターフェイスビルダー
ライブラリペインにあるObjectを使うだけです。
- Interface BuilderでViewControlerをおきます。
- 部品のObject を 以下の画像のように配置します。
- Objectを継承したクラス(この記事ではPresenterと呼びます)を作成して、それをCustom classに記述します。
- いつも通りにStoryboard上のControllerにView部品を配置します。
- outletでPresenterとViewControllerをつなげます。
- 同様にPresenterとView部品をつなげます。
これで、ViewControllerにはPresentorの参照だけになり、ViewはPresentorが持つことになります。
コード
以下は、Bondを利用したソースの例です。
Controllerでは、画面のライフサイクルと画面遷移を行います。
Viewは持たずにNSObjectを継承したPresentorを保持します。
Viewへのアクセスが必要な場合は、Presentor経由で呼び出します。
このクラスでは、ほとんどobserveNewを使った画面遷移のコードとライフサイクルに関係している処理を呼び出すだけになると思います。
class FirstController: UIViewController {
@IBOutlet var firstPresentor: FirstPresentor!
required init(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)!
}
override func viewDidLoad() {
super.viewDidLoad()
firstPresentor.setup()
Weather().transitionState.observeNew { state in
switch state {
case .Start:
self.performSegueWithIdentifier("toSecond", sender: self)
break
default:
break
}
}
}
}
Controllerから分割したクラスをここではPresentorとしています。ポイントはNSObjectを継承することです。
具体的なViewはこのクラスが管理します。
このクラスでは、Bondとの繋ぎこみぐらいが役割になると思います。
class FirstPresentor: NSObject {
@IBOutlet weak var cityCode: UITextField!
@IBOutlet weak var cityCodeLabel: UILabel!
func setup() {
Weather().cityCode.bidirectionalBindTo(cityCode.bnd_text)
Weather().cityCode.bindTo(cityCodeLabel.bnd_text)
}
}
UITableを利用する時でも同様にできます。
コードは以下の通りです。
Controllerでは、UITableViewDelegateを適応します。
class SecondController: UIViewController, UITableViewDelegate {
@IBOutlet var secondPresentor: SecondPresentor!
override func viewDidLoad() {
super.viewDidLoad()
self.secondPresentor.tableView.delegate = self
secondPresentor.setup()
Weather().errorState.observeNew { state in
switch state {
case .NotFound:
let alertController = UIAlertController(title: "検索結果がありません", message: "", preferredStyle: .Alert)
let okAction = UIAlertAction(title: "OK", style: .Cancel) { alert in
print(alert)
}
alertController.addAction(okAction)
self.presentViewController(alertController, animated: true, completion: nil)
break
default:
break
}
}
}
override func viewWillAppear(animated: Bool) {
super.viewWillAppear(animated)
if let indexPath = self.secondPresentor.tableView.indexPathForSelectedRow {
self.secondPresentor.tableView.deselectRowAtIndexPath(indexPath, animated: true)
}
}
// MARK: - UITableViewDelegate
func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
let name = Weather().dataSource[indexPath.section][indexPath.row]
print(name.observableType().telop.value + " selected!")
}
}
PresentorではUITableViewCellの作成とBondとの繋ぎこみをするぐらいです。
また具体的な処理はUI層の下のレイヤー(アプリケーション層やドメイン層など)に委譲するだけにします。
class SecondPresentor: NSObject {
@IBOutlet weak var tableView: UITableView!
func setup() {
let service = WeatherService()
service.fetchWeather()
Weather().dataSource.bindTo(self.tableView) { (indexPath, dataSource, tableView) -> UITableViewCell in
let cell = tableView.dequeueReusableCellWithIdentifier("MyUITableViewCell", forIndexPath: indexPath)
let forecast = dataSource[indexPath.section][indexPath.row]
forecast.observableType().telop.bindTo(cell.textLabel!.bnd_text).disposeIn(cell.bnd_bag)
return cell
}
}
}
class MyUITableViewCell: UITableViewCell {
override func prepareForReuse() {
super.prepareForReuse()
bnd_bag.dispose()
}
}
まとめ
この方法はBondとの相性がいいので使いましたが、Bondを使わなくても同じようにControllerの役割を分割できます。
そして、Presentorに関して言えば、ViewControlerに依存しないので単体テストもしやすくなります。
簡単な画面だと面倒なだけですが、複雑な画面でFat Controllerを解決したい場合の一つの方法として覚えておいても損はないでしょう。