iOSアプリケーションでコードベースのレイアウトを積極利用する

 海外事業向けのiOSアプリケーション開発を担当している西山(@yuseinishiyama)です。クックパッドは現在、海外複数カ国に向けてサービスを展開しています。

 XcodeにはInterface Builderと呼ばれる、リッチなGUIを持ったデザインツールが付属しており、これを用いて画面のレイアウトを構成することが主流となっています。弊社ブログでも、iOS開発でstoryboardとxibをうまく使い分けるプラクティス等の記事で、GUIベースのレイアウトについて触れています。しかし、現在私が担当しているプロジェクトでは、Interface Builderを用いずに、レイアウトの大半をコードで記述しています。

 今回は、コードベースのレイアウトを実装していく中で得た知見を、以下の3つの部分に分けて共有したいと思います。

  • Interface Builderを用いたレイアウトとコードベースのレイアウトの比較
  • コードベースのレイアウトのデメリットへの対処
  • コードベースのレイアウトの実装

 ちなみに、当該プロジェクトがサポートしているiOSバージョンはiOS7以上です。そのため、iOS8以降でしかサポートされない機能については触れません。また、既存のObjective-CアプリケーションをSwiftで書き換えた話でも触れましたが、殆どのコードをSwiftで記述しているため、今回掲載するサンプルコードも全てSwiftとしました。

Interface Builderを用いたレイアウトとコードベースのレイアウトの比較

 本節ではInterface Builderを用いたレイアウトとコードベースのレイアウトとを比較し、それぞれのメリットについて説明します。

Interface Builderを利用したレイアウトのメリット

 Interface Builderを利用してレイアウトを組むことには以下のメリットがあります。

  • 画面遷移をグラフィカルに確認できる
  • レイアウトの結果がひと目で分かる
  • プログラミング経験の無い人であっても容易にパラメータを調整できる

本項では、それぞれについて説明します。

画面遷移をグラフィカルに確認できる

 Storyboard上には複数のViewControllerを配置することが可能です。そのため、複数の画面間の遷移をコードを読まずとも把握することができます。

f:id:yuseinishiyama:20151104150929p:plain

レイアウトの結果がひと目で分かる

 GUIを利用してレイアウトを行うため、実装しながら実行時のレイアウトを確認することができます。そのため、レイアウトを変更する度に、アプリケーションをBuild&Runして結果を確認する必要がありません。

f:id:yuseinishiyama:20151104150931p:plain

プログラミング経験の無い人であっても容易にパラメータを調整できる

 マージンを変更したり、背景色を変更したりする際に、ソースコードを変更する必要がありません。GUI上のパラメータを調整するだけでこれらの値を変更できるので、プログラミング経験の無いデザイナー、ディレクターの人でもデザインを変更することが可能です。

f:id:yuseinishiyama:20151104150933p:plain

コードベースのレイアウトのメリット

 一方で、コードベースのレイアウトには以下のメリットがあります。

  • ViewControllerを適切に初期化できる
  • コードの変更をレビューしやすい
  • 定数を用いて、一貫したデザインポリシーを適用できる
  • 要素の変更に柔軟に対応できる
  • 要素の設定に関するコードが分散しない

本項では、それぞれについて説明します。

ViewControllerを適切に初期化できる

 StoryboardからViewControllerを初期化して、そのViewControllerへと画面遷移するケースを考えてみましょう。

// Storyboardから初期化されるViewController
class DetailViewControllerFromStoryboard: UIViewController {
    var recipe: Recipe!

    // (省略)
}

class MasterViewController: UIViewController {
    // (省略)

    // Storyboardのインスタンス化
    let storyboard = UIStoryboard(name: "Main", bundle: nil)

    // StoryboardからViewControllerをインスタンス化
    let vc =
      storyboard.instantiateViewControllerWithIdentifier("Detail") as! DetailViewControllerFromStoryboard

    // 遷移先のプロパティを設定
    vc.recipe = Recipe()

    // 画面遷移
    navigationController?.pushViewController(vc, animated: true)

    // (省略)
}

 このコードには2つの問題点があります。1つは遷移先のViewControllerのプロパティrecipeImplicitlyUnwrappedOptional<Wrapped>として宣言されている点にあります。

 上記のコードにおいて、recipeは必須の(常に値が存在することが期待されている)プロパティです。しかし、StoryboardからViewControllerをインスタンス化する場合、インスタンス化の時点では、ViewControllerの持つプロパティを初期化することができません。そのため、必須のプロパティであっても、インスタンス化した後に代入する必要があります。

let vc: DetailViewControllerFromStoryboard = ...
vc.recipe = Recipe()

 つまり、ここでは以下の2つを満たす必要があります。

  • recipeが一時的にnilになることを許容する
  • recipeが必須のプロパティであることを表現する

 そして、この両方を満たすには、プロパティをImplicitlyUnwrappedOptional<Wrapped>として宣言する必要があります。これは、IBOutletとして、Storyboard上の要素をコードと接続する際に自動生成されるコードに関しても同様です。

@IBOutlet weak var label: UILabel!

 しかし、ImplicitlyUnwrappedOptional<Wrapped>を使う以上、潜在的にはクラッシュする可能性があります。例えば、recipeプロパティに値を代入せずに画面遷移してしまい、その後、このプロパティにアクセスすればクラッシュしてしまいます。

let vc: DetailViewControllerFromStoryboard = ...

// recipeプロパティを設定し忘れている
navigationController?.pushViewController(vc, animated: true)

 もう1つの問題点は、as!を用いた強制的なキャストが発生している点です。これは、instantiateViewControllerWithIdentifier(_:)の戻り値の型がUIViewControllerなので、具体的な型へとキャストする必要があるためです。もちろん、このコードも潜在的にはクラッシュの可能性があります。ViewControllerのIDの指定を間違った場合などが典型的なクラッシュの例となるでしょう。

// typo: Detail -> Deteil
let vc =
  storyboard.instantiateViewControllerWithIdentifier("Deteil") as! DetailViewControllerFromStoryboard

 Swiftは静的型付けやnull許容性のコントロールによって、実行時のエラーを減らし、コードの誤りができるだけコンパイル時に検出されることを意図している言語です。一方で、Storyboardを用いたViewControllerのインスタンス化は「プログラマが」気をつけないとクラッシュする可能性があるという点で、Swiftの安全性を損ねていると言えます。このことは、Interface Builderが元々はObjective-Cという言語の動的な特性を最大限に活かしたツールであるということを考えると、当たり前かもしれません。

 一方で、Interface Builderを用いずに、コードだけでViewControllerを初期化する場合のコードは次のようになります。

class DetailViewControllerWithoutStoryboard: UIViewController {
    let recipe: Recipe

    init(recipe: Recipe) {
        self.recipe = recipe
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

class MasterViewController: UIViewController {
    // (省略)

    let recipe = Recipe()
    let vc = DetailViewControllerWithoutStoryboard(recipe: recipe)
    navigationController?.pushViewController(vc, animated: true)

    // (省略)
}

 通常のイニシャライザを利用できるので、プロパティrecipeが必須のプロパティでかつ再代入不可能であるということを強制することができています。

let recipe: Recipe

init(recipe: Recipe) {
    self.recipe = recipe
    super.init(nibName: nil, bundle: nil)
}

 ちなみに、init(nibName:bundle:)UIViewControllerの指定イニシャライザです。

コードの変更をレビューしやすい

 Storyboardは単なるXMLですが、その構造が非常に複雑であるため、レイアウトの変更があった際に、差分を見ただけでその意図を汲むことは容易ではありません。また、意図的に編集していない箇所が勝手に更新されることがある、という点も問題です。次のスクリーンショットは、ある制約の値をStoryboard上で12から10に変更した場合の差分です。

f:id:yuseinishiyama:20151104150925p:plain

 一方で、コードであれば変更箇所のチェックは容易です。次のスクリーンショットは、ある制約の値をコード上で12から10に変更した場合の差分です。「userNameLabelというラベルの末尾のスペースが12から10に変更された」ということが明らかに見てとれます。

f:id:yuseinishiyama:20151104150834p:plain

 ちなみに、コード上での制約の設定を簡潔に行うために、PureLayoutというライブラリを使用しています。このライブラリに関しては後述します。

定数を用いて一貫したデザインポリシーを適用できる

 Interface Builderを用いてレイアウトを構成する場合、レイアウト間で共通の値を設定することができません。しかし、コードベースのレイアウトであれば、定数を定義しておき、それを複数のレイアウト間で共有することが可能です。

struct Constant {
    struct Inset {
        static let S: CGFloat = 8
        static let M: CGFloat = 12
        static let L: CGFloat = 18
    }
    struct CornerRadius {
        static let S: CGFloat = 3
        static let L: CGFloat = 5
    }

    // (省略)
}

 このように定数を定義しておけば、ポリシーが変更された場合もその定数の値を変更するだけで変更を全体に適用できます。例えば、「アプリ全体で余白感がもっと欲しい」となれば、この定数の値を大きくすれば良いでしょう。

要素の変更に柔軟に対応できる

 レイアウトを実装している途中で、View要素の型を変更したくなるケースがあるかもしれません。例えば、当初はUIButtonで実装していたものを、より複雑なレイアウトになっため、UIViewとサブビューで置き換える場合などがそれにあたります。Interface Builderでレイアウトを行っている場合、このようなケースでは、一度元の要素を削除してから、新たな要素を追加し、もう一度制約を設定し直すということが求められます。しかし、コードベースのレイアウトであれば、単純に型を変えるだけで対応できます。

要素の設定に関するコードが分散しない

 Interface Builderを用いれば、GUI上でView要素のプロパティ、例えば背景色やフォントなどを設定できることは既に説明しました。しかし、実際のアプリケーションでは、これらの値が動的になることも頻繁にあります。そのようなケースでは、コード上でView要素のプロパティを設定しなければならず、結果として、「あるプロパティはInterface Builderで設定され、あるプロパティはコードで設定されている」というような状況が発生します。こうなってくると、最終的な見た目がどこで決定するのか分からない状況が生まれてしまいます。特に、Interface Builderで設定した不必要な値がコードで上書きされていたりすると、混乱を招きます。

 コードベースでレイアウトしていれば、ビューに対して適用される全ての設定をコードから把握できます。ビューの設定に関するコードでViewControllerが汚染されることを嫌う傾向もありますが、Swiftではプロパティの初期化に式が利用できるので、以下のようにView要素の初期化に関するコードを一箇所にまとめれば、ViewControllerの可読性を下げることもありません。

 次のようにクロージャを利用してプロパティを初期化すれば、ありがちなviewDidLoad()の肥大化を防ぐことができます。

final class DetailViewController: UIViewController {
    // クロージャを利用して、プロパティを初期化
    // ラベルの設定が一箇所にまとまっている
    private var descriptionLabel: UILabel = {
          let label = UILabel()
          label.translatesAutoresizingMaskIntoConstraints = false
          label.font = ...
          label.textColor = ...
          label.numberOfLines = ...
          return label
    }()

    // (省略)
}

コードベースのレイアウトのデメリットへの対処

 前節で、Interface Builderを利用したレイアウトには次のようなメリットがあることを説明しました。

  • 画面遷移をグラフィカルに確認できる
  • レイアウトの結果がひと目で分かる
  • プログラミング経験の無い人であっても容易にパラメータを調整できる

 裏を返せば、コードベースのレイアウトには次のようなデメリットがあることが分かります。

  • 画面遷移をグラフィカルに確認できない
  • レイアウトの結果がひと目で分からない
  • プログラミング経験の無い人が容易にパラメータを調整できない

 本説では、これらのデメリットにどのように対処しているか、ということについて説明します。

画面遷移をグラフィカルに確認できないことへの対処

 ある程度の規模のプロジェクトになると、その画面遷移は大変複雑になります。同じ画面に、複数の箇所から遷移したり、画面遷移時にナビゲーションコントローラのスタックを操作したり、といった状況になることも珍しくありません。こうした、複雑な画面遷移になってくると、Storyboardを使ってグラフィカルに表現したとしても、あまり視認性が高くありません。かえって複雑になり、管理が難しくなる可能性さえあります。

 担当しているプロジェクトも、すでに画面遷移が複雑になりつつあります。また、かなり大規模になる可能性があるプロジェクトなので、Storyboardを利用した画面遷移の管理にはあまり期待していません。

レイアウトの結果がひと目でわからないことへの対処

 レイアウトの結果が実行するまで分からないことは、コードベースのレイアウトの最大の欠点と言わざるを得ません。ところで、レイアウトの結果が実行するまで分からない、という問題は以下2つの問題へと分解できます。

  • 特定の画面やアプリケーションの全体像が把握できない
  • レイアウトのデバッグが困難

 当該プロジェクトでは、Zeplinというツールを用いてレイアウトを管理しているので、前者に関しては問題になりません。Zeplinを確認すれば、レイアウトの全体像が確認できるからです。

 以下は、Zeplin上で表示されているレイアウトの例です。マージン、ラベルのフォント等のプロパティが表示されていることが分かります。基本的にレイアウトに関する作業は、これを正解の状態として、それを実現する作業になります(誤解を招かないために補足しておくと、これはデザインがトップダウンで決定されるという意味ではありません。当然エンジニアもデザインの段階から議論に参加しますが、最終的に決定されたレイアウトが、Zeplin上に展開されるという意味です)。

f:id:yuseinishiyama:20151104150936p:plain

 後者に関しては、確かに、コードを見ただけで最終的な結果を想像するのは容易ではないので、デバッグする際は何度もBuild&Runを繰り返すことになり、余計な時間を消費します。しかし、Xcode7+Swift2になってからコンパイル時間が大きく改善されたので、こうしたトライアンドエラーは現実的な時間内に行えるようになりました。

 また、AutoLayoutをラップしたサードパーティーライブラリを使用すれば、幾分か直感的にコードを記述することが可能なので、慣れてくれば、実行して確認しなくとも正しいレイアウトのコードを書くことができるようになります。

プログラミング経験の無い人が容易にパラメータを調整できないことへの対処

 プログラミング経験の無い人によるデザイン調整が可能である、ということは、デザイナーが直接レイアウトを修正するようなプロジェクトでは当然重要でしょう。GUIによるレイアウトを採用する理由で、最も良く耳にするのもこれかもしれません。

 一方で、私が担当しているプロジェクトでは、直接デザイナーがデザインを修正するようなケースはほとんどありません。なぜなら、デザインから実装までのフローが次のようになっているためです(デザインの変更がある場合も、基本的には同様のフローを繰り返します)。

  1. SketchInVision等のツールでプロトタイプを作成(デザイナー)
  2. エンジニアがプロトタイプを確認した上でデザイナーと議論し、デザインをFixする(デザイナー、エンジニア)
  3. Zeplin上でレイアウトを確認できるようにする(デザイナー)
  4. Zeplinを確認し実装する(エンジニア)

(注:カッコ内はその作業を担当する職種)

 また、デザイナーはデザインを行うと同時に、「デザイン設計」も行っています。ここでの「デザイン設計」とは、デザインの意図を元に、統一したデザインポリシーを定義することを意味します。例えば、「投稿に関連するアクションに紐付いたボタンの色」、「List形式のビューの親ビューに対するInset」といったように、コンテキストに対して決められた値が存在します。このことから、局所的なデザインの微調整が入ることはそれほど多くありません。

 こうした要因から、当該プロジェクトでは、プログラミング経験の無い人がデザインの修正を直接行えないことは問題になっていません。

コードベースのレイアウトの実装方法

 本節ではコードベースのレイアウトの実装方法について説明します。

サードパーティライブラリを使用してレイアウトのコードを簡潔にする

 標準の方法を用いて、コードでビューの制約を記述するのは簡単とは言えず、コードも冗長になりがちです。しかし、これを解消するためのサードパーティライブラリが存在しています。例えば、以下のライブラリが有名でしょう。

 私が担当しているプロジェクトでは、PureLayoutを採用しました。PureLayoutを利用すれば、制約に関するコードをかなり簡潔に記述することができます。

// imageViewの横幅に対する制約を設定し、それを変数に代入する
var imageWidthConstraint: NSLayoutConstraint
imageWidthConstraint = imageView.autoSetDimension(
  .Width, toSize: 120)


// labelを親ビューの中心に配置する
label.autoCenterInSuperview()

// 複数のビューをy軸方向に中央揃え、固定間隔で配置する
let views: NSArray = [view1, view2, view3]
views.autoDistributeViewsAlongAxis(
  .Vertical, alignedTo: .Vertical, withFixedSpacing: Constant.Inset.M)

レイアウトに関するプロパティやメソッド

 本項では、コードベースでのレイアウトを実現する上で、重要なプロパティやメソッドを紹介します。

translatesAutoresizingMaskIntoConstraints──AutoLayoutでAutoresizingMaskを表現する

 このプロパティがtrueになっていると、AutoresizingMaskと同様の挙動を実現するための制約が自動的に設定されます。もし、AutoLayoutベースのレイアウトを組むのであれば、このプロパティをfalseにする必要があります。

 Interface Builderを用いてViewを生成すれば、この値は自動的にfalseに設定されるのですが、コードで生成するとデフォルト値のtrueが設定されてしまします。そのため、コードだけでAutoLayoutベースのビューを生成する場合は、その都度、明示的にfalseを代入しなければなりません。

loadView──viewプロパティの初期化

 コードだけでViewControllerを実装する場合、ViewControllerのviewプロパティもコードで設定する必要があります。viewプロパティはloadView()内で設定する必要があります。以下のコードは、UITableViewのインスタンスを生成し、viewプロパティに設定しています。

final class ViewController : UIViewController {
    private let tableView: UITableView = {
        let tableView = UITableView()
        tableView.separatorStyle = .None
        return tableView
    }()

    override func loadView() {
        view = tableView
    }

    // (省略)
}

 この時、スーパークラスのloadView()を呼んではいけないということに注意してください(参考 : https://developer.apple.com/library/ios/documentation/UIKit/Reference/UIViewController_Class/#//apple_ref/occ/instm/UIViewController/loadView)

 もし、UIViewControllerの上にUITableViewを配置しているだけのStoryboardがあるような場合は、このように記述してファイル数を減らしたほうがプロジェクト全体の見通しが良くなるかもしれません。

viewDidLoad──viewプロパティの設定

viewDidLoad()ではviewプロパティが初期化されていることが保証されているので、viewに対する設定、例えばaddSubview(_:)などはここで行います。

final class ViewController: UIViewController {

    private let userAvatarImageView : UIImageView = {
        let imageView = UIImageView()
        imageView.translatesAutoresizingMaskIntoConstraints = false
        imageView.contentMode = ...
        return imageView
    }()

    private let userNameLabel: UILabel = {
        let label = UILabel()
        label.translatesAutoresizingMaskIntoConstraints = false
        label.font = ...
        label.textColor = ...
        return label
    }()

    private let descriptionTextView: UITextView = {
        let textView = UITextView()
        textView.translatesAutoresizingMaskIntoConstraints = false
        textView.placeholder = ...
        textView.font = ...
        textView.textColor = ...
        return textView
    }()

    override func loadView() {
        view = UIView()
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        view.addSubview(userAvatarImageView)
        view.addSubview(userNameLabel)
        view.addSubview(descriptionTextView)
    }

    // (省略)
}

updateViewConstraints/updateConstraints──制約の設定

 制約の設定は、ViewControllerであればupdateViewConstraints()で、通常のViewであればupdateConstraints()の中で行います。制約が重複して追加されないように注意しましょう。

final class ViewController : UIViewController {
    private var didSetupConstraints = false

    // (省略)

    override func updateViewConstraints() {
        func setupConstraints() {
            imageView.autoPinEdgeToSuperviewEdge(
              .Top, withInset: Constant.Inset.M)
            imageView.autoPinEdgeToSuperviewEdge(
              .Leading, withInset: Constant.Inset.M)
        }

        if !didSetupConstraints {
            setupConstraints()
            didSetupConstraints = true
        }

        // スーパークラスのメソッドの呼び忘れが無いように注意する
        super.updateViewConstraints()
    }

    // (省略)
}

requiresConstraintBasedLayout──AutoLayoutベースのViewであることを明示する

 あるViewがAutoLayoutベースのレイアウトなのかどうかは、そのViewに対して、その制約を変更するような処理が実行された時点で決まります。たとえば、制約が追加された時などです。これは、すべての制約をupdateConstraints()の内部で設定している場合、いつまでたってもupdateConstraints()が呼ばれず制約が設定されない、ということを意味します。このような自体を避けるために、AutoLayoutベースのUIViewのサブクラスでは、このクラスメソッドをオーバーライドしてtrueを返すようにしましょう。

override class func requiresConstraintBasedLayout() -> Bool {
    return true
}

 上記の問題を解消するために、setNeedsUpdateConstraints()を呼んでいるコードも時々見かけますが、厳密にはrequiresConstraintBasedLayout()を使うことが正しい方法です。

 requiresConstraintBasedLayout()について触れている記事はあまり見たことがないので、無視されがちなメソッドなのかもしれませんが、その挙動を正しく理解しておく必要があります。The Mystery of the +requiresConstraintBasedLayoutは、このメソッドの挙動について検証しています。

viewDidLayoutSubviews/layoutSubviews──フレームに依存する処理の記述

 AutoLayoutベースのレイアウトであっても、最終的なフレームに依存するコードを書くケースもあり得ます。そのようなコードは、ViewControllerではviewDidLayoutSubviews()、通常のViewではlayoutSubviews()内に記述します。

override func layoutSubviews() {
    super.layoutSubviews()

    // この時点でフレームが確定している

    // imageViewを丸くする
    imageView.layer.cornerRadius = bounds.size.height/2
    imageView.layer.masksToBounds = true

    // 最終的なラベルの幅にあわせて、preferredMaxLayoutWidthを設定する
    label.preferredMaxLayoutWidth = label.bounds.width
}

 このケースのように、レイアウトのどのフェーズで何が決定され、また決定されていないのか、ということを把握していないと期待する結果を得られないことがあります。コードベースのレイアウトを行う場合は尚更です。レイアウトのパスを正しく理解するにあたって、Advanced Auto Layout Toolboxが大変参考になります。

おわりに

 本記事では、コードベースのレイアウトの利点と、その実装方法について説明しました。

 私が直面しているケースでは、コードベースのレイアウトに軍配が上がったため、また、主張を明確にするためにコードベースのレイアウトを推奨するような内容となりました。しかし、実際はプロジェクトやチーム毎に適切な判断を行うべきでしょう。

 また、GUIベースのレイアウトとコードベースのレイアウトは相反するものではありません。実現したいレイアウトにあわせて、その都度、適切な方針を選択するべきです。その上で、コードベースのレイアウトを選択することになった場合に、今回の記事が少しでも皆様の役に立てば幸いです。

 最後になりましたが、海外展開はまだまだ初期のフェーズであり、やらなければならないことが無数に存在します。また、海外の文化、ユーザーのモバイル利用環境など様々な事柄を考慮しなければならないため、必然的に開発の難易度は高くなります。私たちは、こうした困難に積極的に立ち向かい、海外でクックパッドのサービスを展開することに協力してくれるモバイルエンジニアを積極募集中です!

弊社採用ページ(海外グループ iOS/Android アプリエンジニア)

Wantedly 募集ページ