Xcode Previewsを用いたUIKitベースのプロジェクトの開発効率化

こんにちは。メルペイのiOSチームの@kenmazです。

メルペイのiOSチームでは Xcode11 で導入された Xcode Previews 機能を用いて、UIKitベースのプロジェクトの開発効率向上に取り組んでいます。Xcode PreviewsといえばSwiftUI用の開発支援機能、というイメージがありますが、実は従来のUIKitベースのプロジェクトに対しても使用が可能です。

Xcode Previews を使うことでiOS Simualtor等を起動することなく、様々な状態のビューをリアルタイムにプレビューできるため、開発効率が大幅に向上します。

またXcode Previewsが提供する強力なプレビュー機能を使うことで、Interface Builderの支援も不要となったため、従来のxib/Storyboardを用いたビューの実装から、コードによるビューの実装への移行を試みることにしました。

この記事では、メルペイ iOSチームで導入を開始したXcode Previewsを用いたUIKitベースのプロジェクトの開発方法の詳細と、それによって得られたメリットや知見を紹介します。

f:id:kenmaz:20191212130844p:plain

Xcode Previews を用いたビュー開発の概要

まずはXcode Previewsを使ってビューをどのように開発しているのかを実際に見てみましょう。ここでは例として、何らかの検索結果の絞り込みを行うためのフィルターUIの実装であるFilterBarView.swiftを実装する手順を見ていきます。

f:id:kenmaz:20191212130844p:plain

画面左が今回例として使用する FilterBarView の実装コードです。ビューの実装自体はコードで実装しても、xib/Storyboardで実装してもどちらでも構いません。この例ではコードでビューを構築しています。

画面中央のFilterBarViewPreviewは、実装したビューをプレビューするためのコードです。SwiftUIのUIViewRepresentableを使ってFilterBarViewをSwiftUIのビューとしてラップすることで、Xcode Previewsでプレビューできるようにしています。また単にビューを表示するのではなく、様々な条件下でどのようにビューが表示されるかを確認するために、previews staticプロパティに様々な条件のビューをセットしています。

その結果、画面右側の「キャンバス」に様々な状態のFilterBarViewがプレビュー表示されます。

従来のiOSのビューの開発といえば、Interface Builder(IB)を使って、xibやStoryboardを編集し、必要に応じてIBOutlet/IBActionでコードと関連付けたあとに、ビルドしてiOS Simulatorを起動して、実際にボタンなどを押してみて動作確認を行い、問題があればIBでビューを再編集して再ビルドして・・・、という流れが一般的でした。

一方、Xcode Previewsを使うことにより、Xcodeのエディタで記述したビューのコードが、そのままXcode上でリアルタイムにプレビュー表示されます。ビューが正しく実装できているかを確認するために、何度もiOS Simulatorを起動する必要はありません。コードを修正すれば即座に変更点がプレビュー表示に反映されます。

また、条件に応じて表示内容が変わるビューの表示や、画面が大きい/小さい端末での表示、DynamicTypeやダークモード時の表示など、確認が面倒な状況下におけるビューの表示も、Xcode Previewsを使えばリアルタイムに確認できます。

f:id:kenmaz:20191213140207g:plain

このように、Xcode Previewsを使うことで、日々開発しているUIKitベースのプロジェクトの開発効率を大きく向上させることができます。Xcode Previewsを既存のUIKitベースのプロジェクトに導入するのは非常に簡単です。次のセクションでは、その導入方法と詳細について見ていきます。

Xcode Previewsの導入

Preview用ターゲットの作成

Xcode Previews は Xcode上で編集中のビューをリアルタイムにプレビュー表示するための機能で、SwiftUI とともに導入されたXcode11の新機能のひとつです。そしてXcode Previewsは、SwiftUIのViewだけではなく、UIKitのUIViewUIViewControllerといった従来のビュー実装もプレビューできます。

Mastering Xcode Previews – WWDC 2019 – Videos – Apple Developer

ただし、SwiftUI も Xcode Previews もDeployment Targetが iOS13 以上に設定されていないと使用できません。メルカリは iOS10 以上をサポートしているので、そのままでは Xcode Previews を使ってメルカリのビューをプレビューすることはできません。

そこでメルペイのiOSチームでは、プロダクションビルド用のTargetとは別に、Preview用のターゲットを作成することにしました。

f:id:kenmaz:20191212131029p:plain

MerpayMercariWalletKit (以下WalletKitとします) はメルカリのメルペイ機能の主要なUI実装を含むターゲットですが、それとは別にMerpayMercariWalletKitPreview (以下Previewとします) ターゲットを作りDeployment Target = iOS13 に指定しています。そしてWalletKitに含まれるすべてのコードと、Xcode Previews用のコードを、Previewターゲットにビルド対象として含めます。これによってメルペイの主要なUI実装を、Xcode Previewsを使ってプレビューできるようになります。

XcodeのTarget MembershipのUIを使って、すべてのコードをWalletKitPreview ターゲットに登録するのは非常に面倒ですが、幸いメルペイではほとんどのprojectはXcodeGenで自動生成されているので、以下のようにproject.yml に定義を加えるだけで済みます。

targets:
MerpayMercariWalletKit:
type: framework
platform: iOS
...
sources:
- MerpayMercariWalletKit
...
+  MerpayMercariWalletKitPreview:
+    type: application
+    platform: iOS
+    deploymentTarget: "13.0"
+    sources:
+      - MerpayMercariWalletKit
+      - MerpayMercariWalletKitPreview

通常の開発時は、Previewターゲットを使って、コーディングとプレビューを繰り返し、完成したらWalletKitターゲットに切り替えて実際にiOS Simulatorで起動して最終チェックを行う、といった手順になります。ターゲットを切り替えるのが少々面倒に感じますが、ctrl + 0 ショートカットを使えば割とスムーズに切り替えられるのでこれまでのところ大きな支障はありません。

プレビュー対象のビューの実装

プレビュー用のターゲットの準備ができたので、早速プレビュー対象となるビューそのものを実装していきます。ここでは例として紹介したFilterBarView.swiftのビューの実装コードを抜粋して紹介します。

final class FilterBarView: UIView, InputAppliable {
enum Input {
case setResetButtonVisibility(visible: Bool)
case updateCategoryButtonLabel(label: String, isSelected: Bool)
}
...
private lazy var label: Label = { .. }
private lazy var categoryButton: Button = { .. }
private lazy var resetButton: Button = { .. }
init() {
super.init(frame: .zero)
let stack = UIStackView(axis: .horizontal, alignment: .center, spacing: .xxsmall, distribution: .fill, subviews: [
            label,
            categoryButton,
            UIStackView(axis: .horizontal, alignment: .center, spacing: .zero, distribution: .fill, subviews: [
                Spacer(),
                resetButton
            ])
])
self.addSubview(stack)
stack.pin(to: self)
}
func apply(input: Input) {
switch input {
case .setResetButtonVisibility(let visible):
resetButton.isHidden = !visible
case .updateCategoryButtonLabel(let label, let isSelected):
button.label = label
button.isSelected  = isSelected
}
}
...

いくつか気になる点はありますが、基本的にはただのUIViewのサブクラスです。xibなどを使わずコンストラクタ内でSwiftコードでビューを構築しています。

注目していただきたいのは、コード上部に定義されているInput型と、func apply(input:)メソッドです。Input型はこのFilterBarViewの状態を抽象化したenumであり、これを引数としてapplyメソッドを呼び出すと、このビューの状態を変更し表示内容をいろいろと切り替えられる様になっています。

このように、ただのUIViewのサブクラスではありますが、外部から表示内容を切り替えられるようなインタフェースを実装しておくことがポイントです。こうしておくことにより、Xcode Previewsのプレビュー機能の効果を最大限に発揮できるようになります。

プレビュー用のコードの実装

ビュー本体の実装が完了したので、次はプレビュー用のコードを実装していきます。

UIKitのUIViewをプレビューするにはまずSwiftUIが提供するUIViewRepresentableプロトコルを使って、プレビュー対象のFilterBarViewをラップした型を定義する必要があります。ここではWrapperという名前の型を定義します。

struct Wrapper: UIViewRepresentable {
typealias UIViewType = FilterBarView
let inputs: [FilterBarView.Input]
init(inputs: [FilterBarView.Input]) {
self.inputs = inputs
}
func makeUIView(context: UIViewRepresentableContext<Wrapper>) -> UIViewType {
FilterBarView()
}
func updateUIView(_ uiView: FilterBarView, context: UIViewRepresentableContext<Wrapper>) {
inputs.forEach {
uiView.apply(input: $0)
}
}
}

UIViewRepresentableプロトコルが実装を要求するメソッドは2つです。

ひとつは、makeUIView(context:)メソッドです。このメソッド内でプレビューしたいビューのインスタンスを初期化して返します。

もう一つは、updateUIView(_:context:)メソッドです。このメソッド内ではmakeUIViewメソッドで生成したビューのインスタンスに対して自由に操作を行うことができます。今回の例ではWrapper型のコンストラクタで渡したFilterBarView.Inputの値を引数としてビューのapply()メソッドを呼び出すことで、プレビューするビューの状態を更新しています。

Wrapper型が適合しているUIViewRepresentableプロトコルは、SwiftUIのViewプロトコルを継承しています。そのためWrapperはUIViewのサブクラスであるFilterBarViewをラップしたSwiftUIのビューとして扱えるようになっています。

最後に、このWrapper型を使って実際のプレビュー表示処理を実装します。Xcodeのキャンバス上にプレビューを表示するにはSwiftUIが提供するPreviewProviderプロトコルに適合した型を定義する必要があります。上で定義したWrapperと同じ.swiftファイル内に以下のようなコードを追加しましょう。

struct FilterBarViewPreview: PreviewProvider {
static var previews: some View {
Group {
Group {
Wrapper(inputs: [
                    .setResetButtonVisibility(visible: false),
                    .updateCategoryButtonLabel(label: "カテゴリー", isSelected: false)
                ]).previewDisplayName("カテゴリー未選択")
Wrapper(inputs: [
                    .setResetButtonVisibility(visible: true),
                    .updateCategoryButtonLabel(label: "✗✗✗✗カテゴリー", isSelected: true)
                ]).previewDisplayName("カテゴリー選択中")
Wrapper(inputs: [
                    .setResetButtonVisibility(visible: true),
                    .updateCategoryButtonLabel(label: "長いカテゴリー名長いカテゴリー名", isSelected: true)
                ]).previewDisplayName("長い名前のカテゴリー選択中")
Wrapper(inputs: [
                    .setResetButtonVisibility(visible: true),
                    .updateCategoryButtonLabel(label: "✗✗✗✗カテゴリー", isSelected: true)
                ]).environment(.sizeCategory, .extraExtraExtraLarge)
.previewDisplayName("大きいフォントサイズ")
}.previewLayout(.fixed(width: 375, height: 56))
Group {
Wrapper(inputs: [
                    .setResetButtonVisibility(visible: false),
                    .updateCategoryButtonLabel(label: "カテゴリー", isSelected: false)
                ]).previewLayout(.fixed(width: 320, height: 56))
}.previewDisplayName("小さい画面サイズ")
}
}
static var platform: PreviewPlatform? = .iOS
}

Xcodeは、現在エディタで開いているSwiftファイル中に、PreviewProviderプロトコルに適合した型が定義されていることを検知すると、そのpreviewsプロパティに設定されたSwiftUIのビューをキャンバス上に描画します。

上記コードでは、様々な状態のFilterBarViewの見た目をプレビューするために、そのラッパーであるWrapperを様々な条件のInputを使って初期化し、previewsプロパティにセットしています。

プレビュー表示

以上の実装によってXcodeのキャンバス上にFilterBarViewがプレビュー表示されます。

f:id:kenmaz:20191212170550p:plain

プレビュー時にビューに渡すInputによって、上から順に

  • カテゴリー未選択状態
  • カテゴリー選択中状態
  • 長い名前のカテゴリー選択中状態
  • DynamicTypeで大きいフォントサイズを使用した状態
  • 小さい画面サイズでの表示中

といったそれぞれの状態における表示結果が確認できるようになっています。

このようにプレビューを充実させておくことによって、アプリを実行することなく気軽に色んなパターンを試せるので、例えばカテゴリー名に長い文字が指定された場合を想定してなくて画面が崩れてしまった、といったトラブルを事前に防ぎやすくなります。

また、リファレンスとしても役立ちます。コードを書いた本人以外の人がコードを保守する場合、まずプレビューを見てもらうことで、コードを都度追わなくても、そのビューがもつ表示パターンを視覚的に把握することができます。

プレビューしやすい設計

このように便利なXcode Previewsですが、その威力を最大限に活かすためにはビュー周りの設計を少し工夫してやると良いです。

ビューの表示状態を外部から制御できるようにしておく

上でも少し解説しましたが、まずはプレビュー対象のビューの表示状態を外部から制御できるようにしておくことが重要です。こうすることでいろいろな状態のビューを気軽にプレビューできるようになります。

メルペイでは外部から何らかのInputを注入するための汎用的なプロトコルとしてInputAppliableを定義しています。メルペイではほとんどのビューがInputAppliableに適合しており、外部からビューの状態を変更できるように設計されています。今回の例であるFilterBarView.swiftもこのプロトコルに沿って実装しています。

protocol InputAppliable {
associatedtype Input
func apply(input: Input)
}

プロダクションのコード上では、ViewControllerやViewModelがapply()メソッドを呼びビューの表示を制御し、プレビュー中はPreviewProvider経由でapply()メソッドを呼びプレビューを実行する、という感じです。

ViewControllerのプレビュー

Xcode PreviewsではUIViewだけではなくUIViewControllerのプレビューを行うこともできます。UIViewRepresentableの代わりにUIViewControllerRepresentableを使ってViewControllerをラップすることでViewControllerごとプレビューできます。

xibを用いる場合はViewControllerにレイアウト関連のコードを実装することが多いので、UIViewControllerをそのままプレビューさせるのが自然でしょう。ただしViewControllerのプレビューを行う際は一点注意が必要です。

ViewControllerは、例えばviewDidAppear のタイミングでViewModelを経由して外部との通信を行ったり、何らかの非同期処理を行って、その結果を使ってビューを更新する、ということがよくあります。しかし手元で調査したところ、プレビューのタイミングではviewDidAppearメソッドは呼ばれず、それ以降の処理が実行されません。そのため、ビューが更新されたあとの状態をプレビューできないため、せっかくのXcode Previewsの効果を発揮できません。

その場合は、キャンバス上にあるLive Previewボタンを押すことで、viewDidAppearまで実行され、非同期処理などが完了したあとのビューの状態を確認できます。Live Preview中はviewDidAppearが実行されるだけでなく、実際にビュー上のボタンなどをタップすることも可能なので、タップ時のイベントハンドラが正しく動作しているか、などもクイックに確認することができます。

f:id:kenmaz:20191213140850g:plain

ViewとViewControllerを分離しておく

上記のようにViewControllerをプレビューする場合は、viewDidAppearの実行や、ViewModelなどを経由して実行される処理についてケアする必要があります。例えば画面表示と同時にAPI通信するようなViewControllerの場合、プレビュー表示するたびに実際にAPI通信が発生してしまうのはイマイチです。その場合はDIを使ってAPI通信処理をスタブするなどの対処が必要となるでしょう。

そのため、ある程度複雑なViewControllerの場合は、ViewとViewControllerのコードを分離して、レイアウトに関するコードはすべてViewにまとめ、View単体でプレビューを実装したほうが良いでしょう。

xibからコードによるビューの実装への移行

さて、Xcode Previewsが提供するプレビュー機能が非常にパワフルであることがわかりました。こうなってくると、もはや Interface Builder (IB) は不要なのでは?と感じてきました。

メルカリアプリではもともとほぼすべてのビューをxib/Storyboardを使用して開発してきました。特にAuto Layoutの登場以来、ビューの実装は複雑性が増してきたため、IB によるビジュアルな支援機能は開発に欠かせないものでした。しかし、それもXcode Previewsによって代替できそうです。

また、xib/Storyboardにはいくつか問題がありました。その一つがコードレビューの難しさです。xib/Storyboardの実態はXcodeが生成するXMLの文字列であり、人間が目で読んだり書いたりすることを前提とはしていません。長年の勘と経験でレビューをしている、という方も多いのではないでしょうか。その点、コードによる実装であればすべてSwiftのコードによって記述されるため、通常のSwiftのプラクティスに沿ったコードであれば十分にレビューはできます。

そこでメルペイの一部機能の開発では、xibの使用をやめ、コードでビューを実装するスタイルへ移行してみることにしました。

その結果、Xcode Previewsのおかげで、とくに大きな問題もなく開発をすすめることができましたが、いくつか注意すべき点が見えてきました。

Auto Layoutに対するサポートが薄い

IBのAuto Layoutの実装支援機能は、xib/Storyboard + IB を用いる利点のひとつでした。矛盾したAuto Layoutの設定が行われていると即座に IB が警告を表示してくれる、というものです。

Xcode Previewsはあくまでただのプレビューなのでその点のサポートは薄く、矛盾した制約を設定した場合は、何も言わずにただプレビューが崩れて表示されるだけです。

この問題については、基本的にUIStackViewを使ってレイアウトを構成しAuto Layoutの直接指定は最低限に抑える、という方針を取ることにしました。

UIStackViewの活用

これまでのところ、ほとんどの画面はUIStackViewを組み合わせることで実現できています。上述のFilterBarView.swiftでもUIStackViewを使うことで Auto Layout の指定は最小限に抑えることができました。

SwiftUIやFlutterといったモダンなモバイルアプリ開発フレームワークでも、HStack/VStackRow/Columnといった行列志向のレイアウト方法が主流になってきているため、UIStackViewを中心としたレイアウトの実装は、今後を見据えても良い方向性であると感じています。

その他のポイント

canImport(SwiftUI)の罠

Appleが提供しているSwiftUIのサンプルコードなどでは、Xcode11で導入された #canImport(SwiftUI) を使って、ビューとプレビューのコードを同一の.swiftファイルにまとめて定義している例が見られます。しかしメルペイでは、ビューの実装コードと、Preview用のコードを、別々の.swiftファイルとして分けて記述しています。理由は2つあります。

ひとつは単純にViewのコード量が肥大化してしまうためです。
もうひとつは、このViewをアプリに組み込んでiOS12以下のiOS Simulatorで起動しようとするとSimulatorがクラッシュしてしまうという、iOS13のknown issueによるものです。

iOS 13 Release Notes | Apple Developer Documentation

例えば以下のように書いたとします。

final class FilterBarView: UIView {
// ビューの実装
}
#if canImport(SwiftUI) && DEBUG
import SwiftUI
@available(iOS 13.0, *)
struct FilterBarViewPreview: PreviewProvider {
// プレビューの実装
}
#endif

このように記述した FilterBarView をiOS12 Simulatorで起動しようとすると以下のようなエラーとともにクラッシュしてしまいます。

dyld: Library not loaded: /System/Library/Frameworks/SwiftUI.framework/SwiftUI
Referenced from: /Users/xxx/Library/Developer/Xcode/DerivedData/xxxx/Build/Products/Debug-iphonesimulator/MerpayMercariWalletKit.framework/MerpayMercariWalletKit
Reason: no suitable image found.  Did find:
/System/Library/Frameworks/SwiftUI.framework/SwiftUI: mach-o, but not built for iOS simulator

アプリの Other Linker Flags-weak_framework SwiftUI を設定するという回避方法もありますが、プロダクションにはあまりこのようなWorkaroundは設定したくはありません。

この情報は同僚の@ku さんから教えてもらいました。Thanks @ku !

TestFixturesプロジェクト

前述の例では、Viewの各状態を作り出すのに必要なInputは、純粋なStringやbool値のみでした。しかし多くのコードではもっと複雑な構造のInputデータが必要になることもあります。場合によってはInputとして通信が発生するようなコンポーネントを渡さないといけない場合もあるかもしれません。プレビューするたびに通信が走るのは避けたいので、その場合はPreview用のStubを作ってInputとして与えることになります。

ところで、メルペイのworkspaceにはMockやStub, ViewのInputとして使えるダミーデータが既に沢山定義されています。それらはユニットテスト用のものであったり、Sandboxアプリ用のものであったり、スナップショットテスト用のものであったりします。それらに加えて今度はプレビュー用のデータを用意しないといけませんが、それは非常に大変なことですし、そういうプロダクションに関係の無いテスト用のデータは往々にしてコピペして作られてしまいがちです。

そこでXcode Previewの導入に伴い、メルペイのワークスペース全体で共通で使えそうなMock, Stub, ダミーデータを一括して管理するための TestFixtures プロジェクトを新たに作りました。TestFixtureプロジェクトは、テストやプレビュー用のコードをまとめるためのプロジェクトで、プロダクションビルドには含まれません。これにより、プロダクションのコードやプロジェクトを汚すこと無く、テストデータの無駄な重複が発生を防ぎ、クリーンな状態を保つことができます。

DesignSystem

前述のFilterBarView.swiftのコード中では、Label, Button といったクラス名や、spacing: .xxsmall, pin メソッドなど、見慣れないキーワードが使われています。これはいずれもメルカリアプリに含まれる DesignSystem.framework が提供しているものです。DesignSystemについては、また別の機会に紹介できればと思います。なお下記の記事では、メルカリのWeb Frontendにおける DesignSystemの取り組みについて紹介されていますので、興味のある方はご覧ください。

Design Systemへの取り組み 〜Frontend編〜 – Mercari Engineering Blog

まとめ

以上、メルペイのiOSチームが最近取り組んでいる、Xcode Previews を用いたUIKitベースのプロジェクトの開発効率化について紹介しました。

iOSチーム内でも、慣れ親しんだxib/Storyboard + IBを用いた開発から、Xcode Previews + コードによるビューの実装に移行するのは多少の障壁がありましたが、Xcode Previewsのパワフルさを実感するたびに、その障壁はどんどん下がってきているのを感じます。

この開発スタイルはまだコードの一部にしか適用されておらず、まだ試行錯誤しながら知見をためている段階です。SwiftUIやXcode Preview、そしてFlutterなど、モバイルアプリ開発を取り巻く環境はどんどん変わっていきますが、より堅牢で生産性の高い開発手法を今後も追求していきたいところです。

メルペイではミッション・バリューに共感しているiOSエンジニアを募集しています。一緒に働ける仲間をお待ちしております。

apply.workable.com