285
215

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 3 years have passed since last update.

このFat View Controller、あなたはリファクタリングできますか?

Posted at

screenshot.png

iOS アプリ開発において、 Fat View Controller はよく知られたアンチパターンです。 iOS アプリ開発では View Controller が大元にあるので、 View Controller になんでもかんでも実装していると、どんどん View Controller が肥大化してしまいます。

Fat View Controller には、たとえば次のような問題があります。

  • UI とロジックが分離されておらずテストしづらい。
  • コードの見通しが悪く、可読性が悪い。
  • 状態管理が複雑になり、修正時の影響範囲を見通しづらい。
  • みんなで同じファイルを触ることになり、コンフリクトが起こりやすい。

そんな Fat View Controller との戦い方の知見を共有し合うために、たくさんのiOSエンジニアで同じ Fat View Controller のリファクタリングに取り組んでみました

題材としたのは、リバーシ(オセロ1)のアプリです。

狙い

そのようなことをやってみようと思った理由は次の通りです。

  • Fat View Controller との戦い方は色々なところで語られているが、勉強会等では時間の関係で単純な例で説明化されがち。
  • 一方で、現実のプロダクトのような巨大なコードベースは、学習の題材としては複雑過ぎる。
  • 適切なボリュームと複雑さを持った題材に、実際に手を動かして取り組むことができれば、もっと多くのことを学べるのではないか。
  • 同じ題材にみんなで取り組むことで、設計について語り合うときの共通言語ができる。
    • 例: 「これはあのリバーシアプリの○○と同じパターンだね。」
  • iOSエンジニアはみんなそれぞれ何かしら Fat View Controller と戦う技を持っているはずで、それを披露し合うとおもしろそう。

なぜリバーシか

リファクタリングの題材としてリバーシアプリを選びましたが、その理由は↓です。

  • 誰もがルールを知っていて仕様の理解が簡単である。
  • リバーシのルールという、明確なドメインロジックが存在する。
    • 例: このマスに黒のディスクを置くとどのディスクがひっくり返るか
  • ボリュームが大きすぎない(熟練したエンジニアが数時間〜 1 日程度あれば扱える)。
  • 最低限の複雑さを持っており、現実のアプリで問題になりがちなケースを扱える。
    • 例: ディスクをひっくり返すアニメーション待ちの非同期処理
    • 例: アニメーション途中でリセットボタンを押すなどの非同期処理のキャンセル
    • 例: 盤の状態を表すデータ構造とビューの同期
    • 例: アプリが落ちても続きからゲームを再開するための永続化

短時間で説明できる単純な例の場合、単純すぎてドメインロジックが空っぽになってしまうことも多い印象です。リバーシのディスクをひっくり返す処理は適度に書き応えがあり、ドメインロジックを分離して単体テストを記述する体験として理解しやすそうです。

その他にも、アニメーションなどの UI 上のオペレーションや、ファイルや DB の I/O など、テストしづらいコードの分離も重要です。そのような、考慮すべき様々な点をコンパクトに盛り込めるという意味で、リバーシは良い題材なのではないかと考えています。

なぜ Fat View Controller になってしまうのか

今回、 Fat View Controller をリファクタリングしてみようというイベントを開催するために、まずは課題となる Fat View Controller を用意する必要がありました。普段、 iOS アプリ開発をしていて、あえて Fat View Controller を作ろうとする機会は多くありません。しかし、実際に実装してみると、「なるほど、こうやって Fat View Controller は生まれるんだな。」と改めて理解できることもありました。

Fat View Controller が生まれがちな状況を把握しておくことは、 Fat View Controller と戦う上で有用だと思います。なぜ Fat View Controller になってしまうのか、実際に実装してみて感じたことを三つ紹介します。

前提

Fat View Controller をリファクタリングすると言っても、課題となるコードがぐちゃぐちゃなスパゲッティコードではいけません。できるだけクリーンに作ろうとしたけど、 Fat View Controller にならざるを得ない理由があってそうなってしまったというコードでこそ、どうやってその問題を解消するのかというチャレンジに意味が生まれます。実際のアプリ開発で起こりがちな問題を抱えているのが望ましいです。そのため、 Fat View Controller ではあるけれども、極力クリーンなコードを目指しました。

また、盤のレイアウトやディスクをひっくり返すアニメーションの実装などは課題の本質と関係がありません。そのため、それらのコードは課題側が提供し、挑戦者が実装しなくて良いようにしました。具体的には、 BoardView という、 UIView を継承したビュークラスを提供し、そこに盤の表示やアニメーションを実装しています。

BoardView は 8 × 8 のセルを持ったビューです。

BoardView

次のように、 UIKit と似た API で操作をすることができます。

// 3 列目・ 4 行目のセルの状態を取得
let disk: Disk? = boardView.diskAt(x: 3, y: 4)

// 3 列目・ 4 行目のセルを黒のディスクが置かれている状態に変更![flip-disks.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47085/d7e49e22-9f56-c74a-3e74-575cb0673885.gif)

boardView.setDisk(.dark, atX: 3, y: 4, animated: false)

なお、 Disk は黒か白かを表す次のような enum です。

enum Disk {
    case dark  // 黒
    case light // 白
}

BoardViewdiskAt(x:y:)setDisk(_:atX:y:animated:)UISwitchisOn 2setOn(_:animated:) 3に似せて作ってあります。 animatedtrue を設定することで自動的に必要なアニメーション(例: 白→黒ならひっくり返るアニメーション、ディスクがないセルにディスクを置くならフェードイン)が実行されます。

UIKit を使うコードを書く場合に、 UISwitch のコードを修正しようとは思いません(そもそもそんなことはできないのですが)。 BoardView も同じように、 BoardView というビュークラスがある前提でコードを考えてもらえるようになっています。

また、その他の画面のレイアウトも Storyboard で提供されており、挑戦者が自分でレイアウトする必要はありません。 View Controller に書かれたコードの設計をどのように見直すかという、本質的な課題に集中してもらえるようにしました。

なお、「 Fat View Controller ではあるけれども、極力クリーンなコードを目指しました」が、あまり型を作り込んでしまうと課題として取り組む余地を狭めてしまいます。そのため、 BoardViewDisk 以外の独自の型はできるだけ作らないようにしました。たとえば、黒か白のどちらのターンかを表すプロパティは次のように宣言されており、

// どちらの色のプレイヤーのターンかを表します。ゲーム終了時は `nil` です。
var turn: Disk? = .dark

勝敗が付いたときには nil になるという、ややわかりづらい仕様になっています。このあたりはきちんと型を付けてコードの可読性を向上させるべきでしょう。

まとめると、↓を前提として Fat View Controller を実装しました。

  • Fat View Controller とはいえ、できるだけクリーンに書かれている
  • とはいえ、型を付け過ぎると課題の範囲を狭めてしまうので最低限の型しか用意していない
  • ビューのレイアウトやアニメーションの実装などは課題と関係ないので用意してある

それでは、 Fat View Controller が生まれる要因について見ていきます。

状態を二重に管理しなければならない

前述の通り、盤は BoardView クラスで表されます。 BoardView は 8 × 8 = 64 個のセルの状態を保持しており、そのインスタンスは盤の状態を表していると言えます。

しかし、ビューは単体テストで扱いづらいです。リバーシのロジック(どのディスクがひっくり返るかなど)をビューとは独立に記述するには、純粋に盤の状態を表すデータ構造がほしいところです。たとえば、↓のような感じでしょうか。

// リバーシの盤を表すデータ構造
struct Board {
    let width: Int
    let height: Int
    ...
    // `x` 列目・ `y` 行目のセルの状態。
    // ディスクが置かれてないときは `nil` 。
    subscript(x: Int, y: Int) -> Disk? {
        get { ... }
        set { ... }
    }
}
var board: Board = ...
// 2列目・3行目のセルの状態を取得
let disk: Disk? = board[2, 3]
// 2列目・3行目のセルに黒のディスクを置く
board[2, 3] = .dark

この Board を使えばリバーシのロジックをビューから分離してきれいに記述することができそうです。しかし、それは BoardBoardView の間で状態を二重に管理しなければならないということです。片方が変更されたら、もう片方にそれを反映しなければなりません。

これをサボると楽ちんです。 BoardView そのもので盤の状態を表せば、そのような二重管理を考える必要はありません。課題用の Fat View Controller では、盤の状態を表す専用のデータ構造を用意せずに、 BoardView を使って盤の状態を表しています。そのため、二重管理問題はありません。一方で、リバーシのロジックはビューと一体化してしまいます。たとえば、課題用の Fat View Controller は、盤上のディスクの枚数を数える次のようなメソッドを持っています。

// `side` で指定された色のディスクが盤上に置かれている枚数を返す
func countDisks(of side: Disk) -> Int {
    var count = 0
    
    for y in boardView.yRange {
        for x in boardView.xRange {
            if boardView.diskAt(x: x, y: y) == side {
                count +=  1
            }
        }
    }
    
    return count
}

↑のメソッドのようなドメインロジックを View Controller に実装し始めると、 View Controller はどんどん太ってしまいます。

上記のメソッドのケースでは、 boardView 以外への依存が存在しないので、 BoardView のメソッドにしても良さそうです。 Swift には extension があるので、このメソッドを BoardView に後付することもできるでしょう。そうすれば View Controller が肥大化するのは防げます。しかし、それでもビューとロジックが一体化していることには変わりありません。それらを分離しようとすると、二重管理の問題からは逃れられません。

二重管理に対抗する代表的な手法として、データバインディングが挙げられます。データバインディングなんていつもやっているし、二重管理なんて簡単だと思うかもしれません。しかし、後述するように、リバーシのような小さなアプリでも、データバインディングを単純に使っただけでは解決できない問題があります。また、アプリの種類によってデータの分離や二重管理の難易度は上がります。たとえば、複雑な 3D シーンを扱うアプリなど( SceneKit で作られたゲームなどをイメージして下さい)では、ノードの状態をそのままシーンの状態として扱うことも多いでしょう。そのような難しさが二重管理を「サボる」ことにつながり、 Fat View Contoller の一因になっているのではないでしょうか。

ロジックが UI と密接に関係している

リバーシのアプリでは、ディスクをひっくり返すときはきれいなアニメーションで見せたいところです。このアニメーションは、 1 枚ずつ順番に行われる必要があります。

ディスクをひっくり返すアニメーション

しかし、データ構造である Board はビューから切り離された存在です。適切に分離されていれば、 Board のメソッドを実装するときにビューのことを気にしなくて良いはずです。たとえば、プレイヤーがディスクを置いたときに挟まれたディスクがひっくり返されるメソッドは、次のようなシグネチャを持つでしょう。

// `x`, `y` で指定されたセルに `disk` を置き、挟まれたディスクをひっくり返す。
mutating func place(_ disk: Disk, atX x: Int, y: Int) {
    ...
}

このメソッドは純粋にデータの変更だけを行い、アニメーションの事情など一切考えません。単純にデータバインディングしただけでは、すべてのディスクが一度にひっくり返されてしまうでしょう。

さらに、ゲームの進行管理上、ディスクをひっくり返すアニメーションが完了してから次のプレイヤーのターンを始めなければなりません。ディスクをひっくり返している途中で次のプレイヤーがディスクを置いてしまうと混乱してしまいます。

そのような進行管理の処理を View Controller から剥がそうと思うと、アニメーションとうまく連携をとらなければならないわけです。アニメーションは UI の領域なのでビューと View Controller 側でコントロールされます。しかし、進行管理(次のプレイヤーに打てる手があるかを判断して、そのプレイヤーのターンとするか、パスとするかを決めて状態を遷移させる)は UI からは切り話されたロジックとして扱いたいところです。 UI から切り離しながらも、どうやってアニメーションと連携するのかを考えなければなりません。

Fat View Controller にしてしまえば簡単です。

  1. ディスクをひっくり返す。
  2. アニメーションの完了を待つ。
  3. 次のディスクをひっくり返す。
  4. ...
  5. すべてのアニメーションが完了する。
  6. パスかどうかを判定し、次のプレイヤーのターンを開始する。

すべてが View Controller の中に書かれているので、↑のような処理を簡単に記述できます。

このように、本質的にビューとロジックが関係し合っている場合に、それらを分離することを考えると高度な手術が必要になります。実際、この課題にチャレンジしてもらう中で、多くの人がここに苦戦していました。

他の処理に介入しなければならない

さらに、このアニメーションは中断される可能性があります。このリバーシアプリにはリセットボタンがあり、いつでも状態を初期化して最初からゲームを始めることができます。

その他にも、プレイヤーが "Manual" か "Computer" かを選ぶことができるのですが、 "Computer" を選んだ場合、非同期で AI の思考が始まります。そのような思考中にもリセットをすることができるのですが、そんなときは、 AI の思考に介入して処理を中断しなければなりません。

きれいに記述できた状態変化のコードに、リセットのような上位レイヤーからの介入を考えると、途端に破綻してしまうことがあります。僕は仕事で AR のアプリを書くことが多いですが、特定の対象(マーカーなど)を写したことをトリガーとして、何らかの処理が始まるコードを書くことがよくあります。たとえば、今まで見ていたコンテンツを終了して、新しいコンテンツをダウンロードし、それを表示するなどです。そのような、普通のフローに対していつでも介入できるようなトリガーをどのように扱うかは頭を悩ませます。

Fat View Controller でなら簡単です。モジュールに分かれておらず、すべてのコードがそこにあるわけですから、どのように介入するのも自由自在です。

このFat View Controller、あなたはリファクタリングできますか?

このようにして Fat View Controller は生まれます。実際に Fat View Controller を解禁してコードを書くと、考えないといけないことが少なくて素早くコードが書けました。僕自身もこの課題に取り組んでみましたが、きれいな設計で書こうとすると何倍も時間がかかりました。とりあえず動くものを作るだけなら、 Fat View Controller に利があるわけです。

しかし、やはり問題もあります。まず、単体テストが書けないので、このアプリが正しく動作しているのか自信が持てません。とりあえずモンキーテストで正しく動作していることを確認していますが、コーナーケースではわかりません。

実際、僕が課題に取り組んでテストを書いているときに、普通はしないような特殊な操作をすると、状態を破壊してアプリをクラッシュさせられることに気付きました。これが実際のプロダクトだと、( Fat View Controller で実装していたら)潜在バグとして眠り続けたことでしょう。そして、ある日突然その問題が顕在化し、原因究明が難航する様子が目に浮かびます。結局、きれいに設計してちゃんとテストを書いていた方が、トータルでは早かったというのはよくある話しです。

このバグはおもしろかったので、課題のアプリでは直さずにそのままにしています。課題に挑戦する中で、このバグを見つけて直せるかというのも一つのポイントになります。

さて、本投稿で見てきたように、このリバーシアプリは単純なアプリだけど、そのリファクタリングは一筋縄ではいきません。興味を持った方は、是非チャレンジしてみて下さい。挑戦方法は、↓のリポジトリを fork して xcodeproj を開き、 ViewController.swift をきれいにするだけです(さらに詳しいことは REAMDE を御覧下さい)。

すでに、たくさんの方が挑戦してくれています。また、 日本のiOS アプリ開発者では知らない者はいない(は言い過ぎかもしれませんが)『iOSアプリ設計パターン入門』という書籍がありますが、その著者陣から次の方々に本課題に取り組んでいただいているところです。

著者 担当章
@takasek 第1-4章 設計を知る
@lovee 第5章 MVC
@ktanaka117 第6章 MVP、第7章 MVVM
@marty-suzuki 第8章 Flux
@susieyy 第9章 Redux

後日、挑戦結果を発表し合う報告会を開催予定です。課題に挑戦の上、参加していただければ、自分の設計と何が違うのか、何を考えられていて何を考えられていないのかがわかっておもしろいと思います。このFat View Controller、あなたはリファクタリングできますか?是非チャレンジしてみて下さい。

  1. オセロは商標なので本投稿ではリバーシと呼びます。

  2. https://developer.apple.com/documentation/uikit/uiswitch/1623690-ison

  3. https://developer.apple.com/documentation/uikit/uiswitch/1623686-seton

285
215
0

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
285
215

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?