こんにちは。メルペイの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ベースのプロジェクトの開発方法の詳細と、それによって得られたメリットや知見を紹介します。
Xcode Previews を用いたビュー開発の概要
まずはXcode Previewsを使ってビューをどのように開発しているのかを実際に見てみましょう。ここでは例として、何らかの検索結果の絞り込みを行うためのフィルターUIの実装であるFilterBarView.swift
を実装する手順を見ていきます。
画面左が今回例として使用する 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を使えばリアルタイムに確認できます。
このように、Xcode Previewsを使うことで、日々開発しているUIKitベースのプロジェクトの開発効率を大きく向上させることができます。Xcode Previewsを既存のUIKitベースのプロジェクトに導入するのは非常に簡単です。次のセクションでは、その導入方法と詳細について見ていきます。
Xcode Previewsの導入
Preview用ターゲットの作成
Xcode Previews は Xcode上で編集中のビューをリアルタイムにプレビュー表示するための機能で、SwiftUI とともに導入されたXcode11の新機能のひとつです。そしてXcode Previewsは、SwiftUIのViewだけではなく、UIKitのUIView
やUIViewController
といった従来のビュー実装もプレビューできます。
Mastering Xcode Previews – WWDC 2019 – Videos – Apple Developer
ただし、SwiftUI も Xcode Previews もDeployment Target
が iOS13 以上に設定されていないと使用できません。メルカリは iOS10 以上をサポートしているので、そのままでは Xcode Previews を使ってメルカリのビューをプレビューすることはできません。
そこでメルペイのiOSチームでは、プロダクションビルド用のTargetとは別に、Preview用のターゲットを作成することにしました。
MerpayMercariWalletKit
(以下WalletKitとします) はメルカリのメルペイ機能の主要なUI実装を含むターゲットですが、それとは別にMerpayMercariWalletKitPreview
(以下Previewとします) ターゲットを作りDeployment Target = iOS13
に指定しています。そしてWalletKit
に含まれるすべてのコードと、Xcode Previews用のコードを、Preview
ターゲットにビルド対象として含めます。これによってメルペイの主要なUI実装を、Xcode Previewsを使ってプレビューできるようになります。
XcodeのTarget Membership
のUIを使って、すべてのコードをWalletKit
とPreview
ターゲットに登録するのは非常に面倒ですが、幸いメルペイではほとんどの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
がプレビュー表示されます。
プレビュー時にビューに渡す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
が実行されるだけでなく、実際にビュー上のボタンなどをタップすることも可能なので、タップ時のイベントハンドラが正しく動作しているか、などもクイックに確認することができます。
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/VStack
やRow/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エンジニアを募集しています。一緒に働ける仲間をお待ちしております。