Interface Builder用のLinterを作ってみた


Interface Builderの問題点

iOSアプリを開発する限り付き合わなければいけないInterface Builder。
視覚的にViewの構造を確認できる便利な代物ですが、人類が扱うには少々厳しいポイントがいくつかあります。その1つが「指定できるオプションの多さ」です。
現在、Interface Builderのツールタブは6つに別れておりオプションを一望することはできません。そのため、指定するオプションを間違えていたとしても気づきにくい仕様になっています。

例えば、プロジェクト全体でRelative to marginの使用を禁止で統一しているとします。1 しかし、プログラマーはInterface Builder上でなかなか気がつくことができません。コードレビューで見つかる可能性もありますが、レビュアーの負担はできるだけ減らしたいです。

IBLinter

そこで、Interface Builder用のLinter を作りました。

SwiftLintと同様にBuild Phaseにコマンドを仕込んで警告を出したり、ビルドエラーで落とすことができます。

インストール

Homebrewで入れられます。

$ brew install kateinoigakukun/homebrew-tap/iblinter

使い方

Build Phase で + New Run Script Phase して以下のスクリプトを仕込みます。

if which iblinter >/dev/null; then
  iblinter lint
fi

これによりビルドする度にLintが走り、問題があれば警告を出すようになります。

設定

.iblinter.yml という設定ファイルをプロジェクトのルートディレクトリに配置すると色々と設定できます。

iblinter.yml
enabled_rules: #有効にするルールid
  - relative_to_margin
  - misplaced

disabled_rules: #無効化するルールid
  - custom_class_name
  - enable_autolayout

excluded: #Lintから除外するパス
  - Carthage
  - Pods

以下は現在定義してあるルールです。

Rule id 説明
custom_class_name ViewControllerのCustom Classをファイル名と一致させる
relative_to_margin Relative to margin を禁止する 1
misplaced ViewがAutolayoutの制約通りに配置されていない時にビルドエラーにする
enable_autolayout useAutolayoutをオンでないときにビルドエラーにする

IBLinterの実装

Interface Builderで作成したStoryboardやxibファイルの実態は単なるXMLなので、コマンド内部で行っているのは

  • XMLをパース
  • パースしたオブジェクトをバリデート
  • Xcodeの警告として表示

のみです。XPathや正規表現でバリデートする方がパフォーマンスは良さそうですがメンテナンス性に欠けるため、Swiftオブジェクトにマッピングしています。

以下のコードはrelative to marginを禁止するルールのコードです。一度マッピングして型を付けているのでコード補完によって気持ちよくルールが書けます。

public struct RelativeToMarginRule: Rule {

    public static let identifier: String = "relative_to_margin"

    public init() {}

    public func validate(storyboard: StoryboardFile) -> [Violation] {
        let scenes = storyboard.document.scenes
        let viewControllers = scenes?.flatMap { $0.viewController }
        return viewControllers?.flatMap { $0.rootView }
            .flatMap { validate(for: $0, file: storyboard) } ?? []
    }

    public func validate(xib: XibFile) -> [Violation] {
        return xib.document.views?.flatMap { validate(for: $0, file: xib) } ?? []
    }

    private func validate(for view: ViewProtocol, file: InterfaceBuilderFile) -> [Violation] {
        guard let constraints = view.constraints else {
            return view.subviews?.flatMap { validate(for: $0, file: file) } ?? []
        }
        let relativeToMarginKeys: [InterfaceBuilderNode.View.Constraint.LayoutAttribute] = [
            .leadingMargin, .trailingMargin, .topMargin, .bottomMargin
        ]
        let attributes = constraints.flatMap { [$0.firstAttribute, $0.secondAttribute] }.flatMap { $0 }
        let violations: [Violation] = attributes.filter { relativeToMarginKeys.contains($0) }
            .map { at in
                let message = " \(at) is deprecated in \(view.customClass ?? view.elementClass)"
                return Violation.init(interfaceBuilderFile: file, message: message, level: .warning)
        }
        if let subviews = view.subviews {
            return subviews.flatMap { validate(for: $0, file: file) } + violations
        } else {
            return violations
        }
    }
}

まとめ

Interface Builderと上手く付き合っていく方法の1つを紹介してみました。今後も色々と模索して、幸せな世界を手に入れたいですね。

まだまだバリデートのルールが少ないのでPR下さい。
https://github.com/kateinoigakukun/IBLinter


  1. Autolayoutの制約にRelative to marginを付けると8pt(or 16pt)のマージンが余計に入り込むため。