Swiftがオープンソース化しましたが、そちらは各々の高ぶったテンションに任せるとして(笑)。より詳細に情報まとめようかとも思いましたが、Advent Calendarも棚から牡丹餅だと良くないなと感じたので、今回は別のお話をします。
問題提起
みなさんStoryboard使っていますか?全体像が把握しやすくなったり、コード量が減ったり、何より単純なビューならぱぱっと作れちゃうので便利ですよね。私もよく利用します。しかし、常にStoryboardがベストな選択肢かというと、(特に複数人開発の場合)そうでもないかもしれないと感じています。そこで、以前より試してみたかったProgrammaticなレイアウトの効率的な開発方法について検討して、Stroyboardと比較してみます。
Storyboardのデメリット
普段複数人開発をしながら、Storyboardに対して以下の点が気になっていました。
前提
- 複数人開発
- 1画面1Storyboard
- Pull Requestによるコードレビュー
コードレビューしにくい
下記を見て、どんなビューか分かりますか?
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="9060" systemVersion="14F1021" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="WFr-Fl-Yll">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="9051"/>
<capability name="Constraints to layout margins" minToolsVersion="6.0"/>
</dependencies>
<scenes>
<!--Root View Controller-->
<scene sceneID="yc2-SE-HPL">
<objects>
<tableViewController id="qdY-e8-SHw" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="default" rowHeight="44" sectionHeaderHeight="28" sectionFooterHeight="28" id="cfH-Xd-L70">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="calibratedWhite"/>
<prototypes>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" id="ACN-eO-r2f">
<rect key="frame" x="0.0" y="92" width="600" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="ACN-eO-r2f" id="faU-f0-AR9">
<rect key="frame" x="0.0" y="0.0" width="600" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="cb5-JQ-3vo">
<rect key="frame" x="546" y="7" width="46" height="30"/>
<state key="normal" title="Button"/>
</button>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="f0d-Za-98F">
<rect key="frame" x="8" y="11" width="42" height="21"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" cocoaTouchSystemColor="darkTextColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstItem="cb5-JQ-3vo" firstAttribute="trailing" secondItem="faU-f0-AR9" secondAttribute="trailingMargin" id="Unm-y1-EVR"/>
<constraint firstItem="cb5-JQ-3vo" firstAttribute="centerY" secondItem="faU-f0-AR9" secondAttribute="centerY" id="gV8-jo-BYu"/>
<constraint firstItem="f0d-Za-98F" firstAttribute="centerY" secondItem="faU-f0-AR9" secondAttribute="centerY" id="lJi-he-9we"/>
<constraint firstItem="f0d-Za-98F" firstAttribute="leading" secondItem="faU-f0-AR9" secondAttribute="leadingMargin" id="zmE-pD-fkV"/>
</constraints>
</tableViewCellContentView>
</tableViewCell>
</prototypes>
<connections>
<outlet property="dataSource" destination="qdY-e8-SHw" id="X7g-dr-8YX"/>
<outlet property="delegate" destination="qdY-e8-SHw" id="cU2-Ib-Inu"/>
</connections>
</tableView>
<navigationItem key="navigationItem" title="Root View Controller" id="Njf-SB-mj8"/>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="81t-kI-7Mx" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="1109" y="336"/>
</scene>
<!--Navigation Controller-->
<scene sceneID="CXu-Lh-ZKY">
<objects>
<navigationController id="WFr-Fl-Yll" sceneMemberID="viewController">
<navigationBar key="navigationBar" contentMode="scaleToFill" id="G4D-Mz-iox">
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
</navigationBar>
<connections>
<segue destination="qdY-e8-SHw" kind="relationship" relationship="rootViewController" id="Ga5-8M-n6J"/>
</connections>
</navigationController>
<placeholder placeholderIdentifier="IBFirstResponder" id="KfJ-9y-IMv" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="289" y="336"/>
</scene>
</scenes>
</document>
UINavigationController
をInitialにして、rootViewControllerとしてUITableViewControllerを設定し、そのCellにUILabelとUIButtonを両端マージン付きのスペース0、縦中央に配置しています。まぁなんとなく分かりますが、これがもっと複雑になったら、少なくとも差分を見ながら内容を把握して問題点を指摘するのは難しくなってきます。その場合、該当ブランチをチェックアウトしたり、Storyboard単体をダウンロードしてXcodeで閲覧することになるかと思いますが、なかなか面倒です。
加えて、いざ指摘しようとしても、直すのがコードではないので、結局口頭で指摘することになり、手間もかかり経緯も残らない、という課題もあります。
制約や階層の全体把握はしにくい
画面間遷移や画面の様子の全体把握はStoryboardの強みです。Wordやホームページビルダー(懐かしい)みたいな感じ。所謂WYSIWYGですね。しかし、グラフィカルに設定できるツールの弱点は、見にくいものは慣れても見にくいこと。裏側で賢くやってくれているものの、その分どうしてもツールの表示仕様が把握のしやすさの限界にそのまま影響してきます。バグがあるとお手上げするしかない場合が起こってくることもありますね。Storyboardに関していうと、上記の話にも近いかもしれませんが、設定されている制約どうなってるんだっけ?を見ようとしても、何度かクリックを繰り返さないと、全体が見えてこない。Size Classesが入ってくると尚更わかりにくい。他人の作ったStoryboardだと、特にそうです。また、画面の階層(ビューの下に隠れたビュー、とか)の把握も、ツリー表示されているものの、それ以上の工夫が難しい。コードだと、まずはaddSubviewして...とかで工夫の余地があったりします。TeXやHTMLでの作業に慣れている方ならば、気持ちは分かっていただけると思いたい。
責任範囲が不明確になりやすい
みなさん、Storyboardを使うとき、delegateはStoryboardとコードのどちらで設定しますか?色は?カスタムViewの設定値は?
現在私は、「なるべくStoryboardでできるものはStoryboardで」としています。しかし、明確に規約として区別するのはなかなか難易度の高いです。そして、複数人開発だと、どうしても「これはStoryboardで設定できます」という(ちょっと不毛な)指摘が増えてきてしまいます。作る上では大きな問題でなくても、その画面に改修を入れる際、どう設定を追っていくべきでしょうか?コードに書かれてないから、と思ってみたらStoryboardを少し掘ったら書かれていたり、逆もあったり。
ViewControllerの生成方法に制限が発生する
Swiftでは、イニシャライザ内で自分自身を生成することができないため、例えば呼び出し元でStoryboardを利用したViewControllerに値を渡すためには、initializerとは別にstaticメソッドを追加する必要があります。暗黙的な約束事が生まれてしまうので、できるだけこういうのは避けたいところ。
// これができない
public final class ViewController: UIViewController {
public let parameter: String
public init(parameter: String) {
self = UIStoryboard(name: "ViewController", bundle: nil).instantiateInitialViewController() as ! ViewController
self.parameter = parameter
}
}
// こうするしかない
public final class ViewController: UIViewController {
public var parameter: String!
public static func instantiate(parameter: String) -> ViewController {
let viewController = UIStoryboard(name: "ViewController", bundle: nil).instantiateInitialViewController() as ! ViewController
viewController.parameter = parameter
return viewController
}
}
よくわからない差分が出る
保存するだけで差分がでる。たまにコミットする人がいる。
結局動かさないとわからない
制約指定できてるはずなのに、実際動かすときちんと動かなくて...という相談がちらほら出てくることがあります。WYSIWYGとは。
Xcodeが重い
これはXcodeのせい。Storyboardは悪くない。
提案
だから私は、レイアウトもコードで書いてしまいたい。しかし、TeXやHTMLのように見ながら作れる便利なツールがあるわけではありません。シミュレータで動かして毎回確認するしかない。
...本当に?いやいや、Playgroundがあるじゃないですか。
ということで、前置きとても長くなりましたが(笑)、Playgroundを利用してレイアウト設計する方法を検討してみます。
先行事例
- PlaygroundでUIViewのアニメーションを簡単に確認
- Prototyping UIView Animations in a Swift Playground
- Thinkful-Ed/UIView-Playground
アニメーションを試すツールとしては、事例があるようですね。今回は、UIViewをHTML(+ JS/CSS)のように見立て、コードで変更の都度結果を確認しながらレイアウト設計する方法を検討します。
手順
前提
-
SnapKit/SnapKitを利用する
使っても使わなくても良いですし、別のライブラリでももちろん構いません。個人的に、(コード派なくせに)コードでそのままAutoLayoutはないな、と思っているので導入。 - iOS
Macもそんなに変わらないけれど今回はiOS。
1. New workspace
Workspaceを作って、
2. New iOS Project
その中にiOS Projectを作ってください。
3. New Playground
さらにiOS Projectの中にPlaygroundを追加(PlaygroundでiOSライブラリを使うため)。
4. SnapKit
SnapKitをCarthageで。
echo 'github "SnapKit/SnapKit"' > Cartfile
carthag checkout
Workspace内にCarthage/Checkouts/SnapKit/SnapKit.xcodeproj
を追加 → SnapKit iOS
ターゲットをビルド。これでimport SnapKit
できるようになります。
5. Playground雛形
import UIKit
import XCPlayground
import SnapKit
let view = View(frame: CGRect(x: 0, y: 0, width: 320, height: 640))
XCPlaygroundPage.currentPage.liveView = view
public final class View: UIView {
public override init(frame: CGRect) {
super.init(frame: frame)
}
public required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
こんな感じでAssistant Editorを開けば見ながら編集ができてしまいます。
UIViewサブクラスをSourcesに入れちゃうと折角の見ながら編集ができないので、Playground下部をUIViewサブクラスコピペスペースにして、適宜UIViewインスタンス生成行のクラスを変更して確認、という運用になるかと思います。
6. 軽くAutoLayout
軽くAutoLayoutを設定してみると下のようになります。
超絶簡単なViewですが、TeXのような感覚で結果を見ながらコードでレイアウトする、という目的は十分果たせそうな気がします。
import UIKit
import XCPlayground
import SnapKit
let view = View(frame: CGRect(x: 0, y: 0, width: 320, height: 640))
XCPlaygroundPage.currentPage.liveView = view
public final class View: UIView {
private let label = UILabel()
private let button = UIButton(type: .System)
public override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor.whiteColor()
// Add subviews
addSubview(label)
addSubview(button)
// Constraints
label.snp_makeConstraints { make in
make.trailing.equalTo(self)
make.bottom.equalTo(self)
}
button.snp_makeConstraints { make in
make.center.equalTo(self)
}
// Values
label.text = "UILabel"
button.setTitle("UIButton", forState: .Normal)
}
public required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
思ったのは、
- コード読むだけでも上から順に理解できるのやっぱ良い
- UIView frame変えやすい(Storyboardより楽)
→ ScrollViewとかに有用? - hiddenとかを試すのも楽
-
private let
で使うViewを全て並べておくと便利そう- 何を載せているのか明確になって良い
-
init
時以外(更新時とか)でもアクセスする際に、手間が省ける(最初に揃えておいた方がぐちゃぐちゃしにくそう) - 外部からアクセスされるならprivateを外してやれば良い
- HTML/CSS/JSみたく載せるメソッド/配置(AutoLayout)するメソッド/装飾するメソッド/設定するメソッドみたいに分けても良さそう(この辺整理したら普及するかも?)
- Storyboardよりも「Viewでレイアウトする」(→ ViewControllerでやらない)を意識させやすそう
7. 終わったらプロダクトコードに配置
UIViewクラス部分はそのままプロダクトコードに持っていけるはず。
自分がやるならPlaygroundとプロダクトコードのレポジトリは分ける気がします。
提案手法の課題
個人的には採用検討してもよいぐらいな気持ちですが、要検討な部分もあります。
- Size Classes対応について要検討
→ iPad/iPhoneという概念がないので、プロダクトで使っている判定ロジックを偽装したりする必要がある - Button押せない
→ ちょっと押せるかな?って期待したけれど、押せないですね笑。Storyboardと比べて劣っているとかではないのでまぁ。
TODO
出来次第更新します
- メインターゲット内のViewを更新しながらPlaygroundで結果確認する場合の方法検討(View編集画面とAssistant editorの開き方・配置とか)
- その場合Size Classesなどの判定はどのようにPlaygroundで処理されるのか確認
最後に
本当は実績積んでからの共有が望ましいのでしょうが、現在Storyboardで進んでいる所を止めて、というのは現実的でないので、一旦書きながら試してみました。思ったよりも使いやすかったので、機会があって実際に利用し始めたらまたまとめることにします。