こんにちは。クックパッド事業本部 買物サービス開発部の藤坂(@yujif_)です。
2020年10月にクックパッド iOS アプリで「買い物機能」をリリースしました。今回はこの新機能の開発にあたって考えたことや取り組みについてご紹介します。
買い物機能とは
生鮮食品EC「クックパッドマート」の仕組みと連携し、レシピサービス「クックパッド」のアプリから食材を注文できます*1。これはただクックパッドマートの機能を使えるだけ、というわけではありません。「レシピ」と「買い物」が融合するからこその良い体験づくりを目指しています。
詳しい内容はプレスリリースやクックパッドでお買い物 - 地域限定機能をデザインする上で考えたこと- にもまとまっていますので、ぜひあわせてご覧ください。
レシピから買い物へ
レシピサービスならではの良さとして、例えば「材料欄からスムーズに買える」という便利さがあります。作りたいレシピが見つかったとき、必要な食材をすぐに買い揃えられます*2。
買い物からレシピへ
逆に、買い物からレシピへの流れもあります。
クックパッドマートの仕組みによって、精肉店や鮮魚店などの専門店や地域の農家など、さまざまなお店・生産者の魅力的な食材をアプリから一覧できます。
食材を眺めていて「へぇ〜こんなの買えるんだ!」「どう食べるのが良いのだろう?」と気になったら、すぐに自分好みの食べ方・楽しみ方を、クックパッドに集まる沢山のアイデアの中から見つけられます。
「明日はこれ作ろう!」「週末はこれが食べたいな〜」とワクワクしながら注文し、次の料理が楽しみになる、そんな素敵な時間を作れるかもしれません。
実は SwiftUI で作られている
買い物機能の画面は、そのほとんど全てが SwiftUI で実装されています。
SwiftUI は iOS / macOS アプリを構築できるフレームワーク*3です。2019年の WWDC で Apple から発表されました。従来の UIKit と比べて、より簡潔なコードで UI を組める点が特徴的です。
- 利点
- UI を作りやすく、生産性が向上する
- 将来的に標準化しうる SwiftUI にキャッチアップできる
- UIKit と組み合わせて使える
- Dynamic Type、Dark Mode など iOS の最近の機能が考慮されている
- 欠点
- まだ挙動に不具合が残っている部分もある
- iOS 12 以前では使えない
- UIKit に比べると機能が足りていない
- 知見を持つエンジニアがまだ限られている
SwiftUI はまだ比較的新しい技術であり、上記のようなメリットとデメリットが考えられます。ゼロから作る新規アプリではなく、長い歴史もあって大規模なクックパッドアプリ*4で、なぜ SwiftUI を本番投入することにしたのでしょうか?
技術選定の背景
1. 本番で早く検証し、サービス開発の効率を上げたい
「レシピ」と「買い物」を融合して、毎日の料理を楽しみにすること、それがこの買い物機能をつくる目的です。
最終的に目指す先は決まっていても、どういうコンセプトが最も良いのか、どのような機能・UI ならコンセプトを実現できるのか、具体的な形はまだ誰にも分かっていません。何度も作り変えながら模索していくことになります。
実生活の中で使って発見を増やす
新しいアイデアの検証では、最小限のプロトタイプを作り、ユーザーインタビューをして判断することが一般的です。時間を費やしすぎずに重要な知見を得られる点がメリットですが、それだけでは不十分な面もあると経験上感じています。
実際にアプリを使っていると、開発中やインタビュー中など当初は気づいていなかった価値や問題を実感して気持ちが変わることがあります。
自宅のリビングやキッチン、通勤中、送り迎えや買い物など、現場にいる当事者だからこそ、課題に対する解像度も高く「どうなっていたら嬉しいのか?」という解決策が自然と浮かぶこともあり、結果的に質の高いフィードバックが得られやすいと思っています。
「レシピ」と「買い物」を組み合わせた価値を追求していくためには、瞬発的な検証だけでなく、日常的に使ってなるべく深い発見を重ねて、両者で補完しながらユーザー理解を精緻化していくことが一層重要ではないかと考えていました。
そのためには、実際のアプリを使って本番といえる環境で素早く検証を積み重ねられることが必要になります。
UI の「作って壊し」をやりやすく
では、どうすれば素早くアプリを本番で試せるのでしょうか。今回の場合、UI 実装を速くすることが効果的に思えました。
買い物機能は完全に新規のアプリケーションというわけでもないので、バックエンドはクックパッドマートの API を使えるなど、基礎部分がある程度固まっていました。そのため、レシピアプリ側でまず最低限使える状態にした後、改善を繰り返す期間が焦点となってきます。
そのタイミングで最も変動が大きいのは UI ではないかと思います。
サーバーから返す情報が同じままでも、見せ方を変えるだけで印象や体験は変えられます。時には API からまるごと変える場合もありますが、多くは UI(View 層)の作り直しが楽になるだけでも実装は速くなります。
このように UI の「作って壊し」を頻繁に繰り返しやすい仕組みを用意したいと考える中で、SwiftUI が候補の一つとして挙がりました。
2. SwiftUI のリスクを抑えつつ導入できる見込みがあった
クックパッド iOS アプリでは 2メジャーバージョンをサポート
買い物機能のリリース時期は2020年後半を予定していました。例年通りなら iOS 14 がリリースされる頃です。クックパッド iOS アプリは最新2メジャーバージョンをサポートする方針で、iOS 13 と 14 に向けた体制となります。そのため、iOS 12 以下で SwiftUI を使えないことはそれほど大きな問題ではありませんでした。
機能・画面単位で切り分けやすいアーキテクチャ
クックパッド iOS アプリでは数年前から VIPER ベースの Layered Architecture を採用しており、責務ごとにしっかりと実装が分かれていました。また、マルチモジュール化*5も進めており、大きな機能はモジュール単位で分離されているため、他の機能開発にも影響せず実装を進めやすいという点もありました。一部だけ SwiftUI を導入するのも容易な環境だったと言えます。
【方針】View 層のみで SwiftUI を部分的に導入する
これらの状況を総合して、メリットを生かしつつリスクを最低限に抑えられる形であれば、SwiftUI を導入するのは良い選択だと考えました。
具体的には、既存の VIPER アーキテクチャに適合したまま、View 層のみで SwiftUI を使うという形です。SwiftUI には画面遷移を行うための NavigationView などのコンポーネントもありますが、それらは使わずにあくまで素朴な UI コンポーネントのみを使います。
- 使う例:Button, Text, VStack, HStack, ZStack, ScrollView
- 使わない例:NavigationView, List, Form, TextField*6
画面単位で完全に切り分けられているため、もし SwiftUI で実装に困難が生じたらすぐにそこだけでも UIKit に戻せるというリスク対策になっています。
実装
既存のVIPER アーキテクチャへの SwiftUI の組み込み
基本的には、画面(VIPERシーン)ごとに従来通り UIViewController
があり、そこに橋渡し役の UIHostingController
を介して、SwiftUI で書かれた View
が置かれる形です。
ここで、SwiftUI.View へデータを流し込むためには DataSource、SwiftUI.View からユーザー操作等のイベントを伝えるためには Delegate をそれぞれ用意して渡しています(詳しくは後述)。
final class KaimonoCartViewController: UIViewController, KaimonoCartViewProtocol, KaimonoCartViewDelegate { …… private var dataSource: KaimonoCartView.DataSource = .init() override public func viewDidLoad() { super.viewDidLoad() let rootView = KaimonoCartView(delegate: self, dataSource: dataSource) let hostingVC = UIHostingController(rootView: rootView) addChild(hostingVC) hostingVC.didMove(toParent: self) view.addSubview(hostingVC.view) hostingVC.view.translatesAutoresizingMaskIntoConstraints = false hostingVC.view.al.pinEdgesToSuperview() // 内製の Auto Layout helper …… } }
UIViewController から SwiftUI.View へデータを流し込む
VIPER アーキテクチャで Interactor → Presenter → ViewController と渡ってくるデータを、 SwiftUI で書かれた View 側に伝える際には ObservableObject
を使っています。
https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app
本稿のコード例では DataSource と名付けています。View で必要なデータは @Published
をつけたプロパティとして、DataSource に定義しておきます。
struct KaimonoCartView: View { class DataSource: ObservableObject, ReactiveCompatible { @Published var isOrderProcessing: Bool = false @Published var isCartProductsLoading: Bool = true @Published var cartProducts: [CartProduct] = [] …… } weak var delegate: KaimonoCartViewDelegate? @ObservedObject var dataSource: DataSource …… var body: some View { …… } }
その上で、DataSource は親の ViewController で所有*7して、View からは @ObservedObject
property wrapper でそれを監視させます。
クックパッド iOS アプリでは VIPER のデータフローに RxSwift を利用しているため、ReactiveCompatible
プロトコルにも適合させて以下のように繋ぎました。
// KaimonoCartViewController.swift presenter.cartProducts .drive(dataSource.rx.cartProducts) .disposed(by: disposeBag) presenter.isCartProductsLoading .drive(dataSource.rx.isCartProductsLoading) .disposed(by: disposeBag) presenter.isOrderProcessing .drive(dataSource.rx.isOrderProcessing) .disposed(by: disposeBag)
このように ViewController の DataSource へ最新の値を流し込めば、あとは値の変化に応じて自動的に View が再描画されます。
SwiftUI.View から UIViewController へイベントを伝える
View で起きたイベントを ViewController に伝える際には Delegate を使いました。
基本的には UIKit などの命名規則と合わせて、Delegate の呼び出し元の名前に動詞を続ける命名にしています。ただし、第一引数で self を渡すのはやめています*8。
// KaimonoCartView.swift protocol KaimonoCartViewDelegate: AnyObject { func kaimonoCartViewDidTapProductTile(productID: Product.ID) func kaimonoCartViewDidTapTermsLawButton() func kaimonoCartViewDidTapOrderButton() …… }
// KaimonoCartView.swift private var footer: some View { HStack(alignment: .center, spacing: 0) { …… Button(action: { delegate?.kaimonoCartViewDidTapOrderButton() }, label: { Text("注文する") …… }) } }
// KaimonoCartViewController.swift func kaimonoCartViewDidTapOrderButton(){ // 注文処理のトリガーに必要な値を流す }
SwiftUI で実際どうだったか
よかった点:開発効率の向上
まず、UI の組み立ては期待通り快適で素早くできました。その様子は Apple 公式のチュートリアルでもおわかりいただけると思うので割愛します。ここでは実際に UI を作り込んでいく中で良かったと感じた点をご紹介します。
1. 複雑・多様な状態のある画面実装が楽
買い物機能では、サービスの特性上さまざまな要因で表示内容を変える必要があります。 (例:受け取り場所の設定状況、注文締切、受け取りのタイミングなど)
従来なら UITableView や UICollectionView を使った実装を考えるところですが、セルやレイアウトの定義、更新タイミングなど考慮すべきこともコード量も多くなります。複雑度が増すほど不具合も生んでしまいやすく、この状況では画面構成を色々と変えてみたくても実装者のフットワークは重くなります。
これに対して、SwiftUI では表示条件をそのままシンプルに書けば良く、データが更新された時の再描画も任せておけます。
具体例1:かいものタブ トップ画面
例えば以下の要件があるとします。
- 過去に1件以上注文があるなら「最近の買い物を見る」導線を表示したい
- 配送済み かつ 未受け取りの品があるなら それを「受け取り可能なご注文があります」に変えたい
- 過去に1件以上注文があるが 配送時のプッシュ通知がオフ なら、通知設定導線を表示したい
View は次のように表現できます。
// KaimonoTopView.swift var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { if dataSource.isLoadingDeliveries { ActivityIndicator() } else { if dataSource.hasLeastOneOrder { if dataSource.hasAcceptableDeliveries { acceptableDeliveriesRow // 「受け取り可能なご注文があります」 } else { normalDeliveriesRow // 「最近の買い物を見る」 } if dataSource.isPushNotificationDenied { pushNotificationDeniedRow // 通知設定導線 } } } …… } } }
実際にはこれ以外にも様々な要件があり、かなり複雑な画面です。しかし、SwiftUI なら素朴に条件を並べるだけで構成できます。コードを見て仕様把握しやすく、改変したいときも素直に変えるだけで済むので保守性が高いと感じました。
具体例2:カート画面
SwiftUI の宣言的な表現の良さは、以下の場面でも実感できました。
- 注文ボタンが押されたら、注文処理が完了するまではボタンを無効化しておきたい
- 変更操作や画面遷移をしないように「品数の変更」や「よくある質問」などのボタンも一通り無効化しておきたい
- もし注文処理が通信エラー等で中断されたら、再度押せるように有効化したい
この要件を .disabled(_:)
modifier 1行で対応できるのはとても心地よいと感じます。
https://developer.apple.com/documentation/swiftui/list/disabled(_:)
// KaimonoCartView.swift // 注文処理中だけ、ボタンを無効化したい var body: some View { ZStack { VStack(spacing: 0) { if dataSource.isCartLoading { ActivityIndicator() } else { ScrollView { paymentSettingRow deliveryInformationRow deliveryTutorialsRow notesRow faqRow } Divider() footer } } .disabled(dataSource.isOrderProcessing) // ↑この1行で、この VStack 内の Button などはすべて良い感じに disabled になる if dataSource.isOrderProcessing { ActivityIndicatorToast() } } }
2. UI コンポーネントの取り回しが楽
1つの UI コンポーネントを複数画面で使い回すことはよくあると思います。 UIKit でも使い回し自体は可能でしたが、SwiftUI ではより扱いやすいと感じました。
2.1 柔軟に移植や組み合わせができる
例えば買い物機能では、商品タイル ProductTile
という UI コンポーネントを多用しています。
struct ProductTile: View { var product: Product var didTapImage: (_ productID: Int64) -> Void var didTapAddToCartButton: (_ productID: Int64) -> Void var body: some View { …… } }
商品の状態によって「NEW」「数量限定」などのラベルが表示されます。もし選択中の受け取り日にこの商品の配送がなければ「直近でお届けできる日を確認」、在庫がなければ「売り切れ」などのオーバーレイも表示されます。
この ProductTile
を機能させるには、少なくとも以下の3つを与える必要があります。
- 商品データ
product
- 商品サムネイル画像をタップされたときの挙動
didTapImage
- カート追加ボタンをタップされたときの挙動
didTapAddToCartButton
これは親 View から渡します。
上図は、商品グリッド表示の ProductsGrid
を埋め込んだ店舗詳細画面 KaimonoShopDetailView
の例です。ProductsGrid
を通して ProductTile
はグリッド状に表示されます。
struct ProductsGrid: View { var products: [Product] var didTapImage: (_ productID: Int64) -> Void var didTapAddToCartButton: (_ productID: Int64) -> Void ……… var body: some View { ForEach(0 ..< rows.count, id: \.self) { index in HStack(alignment: .top, spacing: gridSpacing) { ForEach(rows[index]) { product in // ProductTile に必要なものは initializer の引数に示されている。 // 使う側はそれを用意して渡せば完了。 ProductTile( product: product, didTapImage: didTapImage, didTapAddToCartButton: didTapAddToCartButton ) } } } } }
前述の説明とも関連しますが、買い物機能の SwiftUI 実装では 親 View から 子 View へ必要なデータを与えて、子 View で起きたイベントの処理は親に委譲される構造にしました。
つまり、依存は親から注入されるので UI コンポーネント自体はどれも無垢です。その画面特有の都合などは最終的に委譲された先にたどり着く ViewController の実装が担います。そのため、UI コンポーネント自体はどの画面にもほぼコピー&ペーストですぐ移植できますし、互いに組み合わせたり入れ込むこともたやすくできます。
異なる UI コンポーネントに置き換えたいときも、同じものに依存しているなら変更箇所はごくわずかで済みます。
UIKit でも扱いやすい UI コンポーネントを作ることは可能でしたが、SwiftUI.View の struct ならより手軽に用意できる印象があります。
2.2 後から細分化もしやすい
必要なタイミングで「改変して独立」しやすくなっているとも感じました。
UI コンポーネントは細かすぎても荒すぎても不便ですし、開発状況の変化によって望ましい形は変わっていきます。最初は複数画面で同一でも良かった UI コンポーネントも、別々の道を歩みはじめたくなるケースはよくあります。
そのように分離して独立させたいとき、UIKit では Interface Builder (.xib, .storyboard) を使う場合でもすべてコードで書く場合でも Auto Layout の制約*9などに気を配る必要がありました。また、もし古い IBOutlet の紐付きを見落とせば、アプリのクラッシュの原因*10にもなってしまいます。
SwiftUI では前述の依存の構造とも相まってそのようなしがらみがなく、一部だけ抽出して独立させるのもかなり簡単だと思えました。
最初から粒度を気にせずに UI を作り始められて、後で必要になったときに結合・分離に柔軟に対応できるのは魅力的です。
3. スタイル調整も楽
スタイルを調整しやすいのも良い点です。よく使うシャドウや角丸に加えて、マージン調整、文字の装飾なども UIKit に比べて簡潔で扱いやすくなっています。細部までこだわった表現も簡単にできて、デザイナーと一緒にベストな実装を模索できました。
デザイナーも Pull Request を
同じチームのデザイナー @sn_taiga さんが自らスタイル調整の Pull Request を出してくれることが何度かありました。
UI を微調整したいとき、エンジニアとデザイナーの間で指示・実装・修正確認のやりとりが何度も発生することもあると思います。もしデザイナーが直接修正できるなら、オーバーヘッドを減らして時間を短縮できます。
従来の UIKit に比べて分かりやすく、iOS エンジニアでなくても簡単に修正できる部分が増えていくのは大きな進歩だと感じます。
困った点:余計な苦労もある
メリットもありましたが、予想通り SwiftUI を使うことで苦労した点もあります。
1. 不具合
iOS 13.0〜13.4 あたりでは「それはないでしょ……」という不具合もありました。一つ一つ対応策はあって地道に対処していくことになるのですが、それだけでもう一記事かけそうなので、ここでは一例だけ紹介します。
例:ScrollView 内の Button が、スクロールのためのタップで誤動作してしまう
iOS 13 の初期の頃に実機で起きる不具合で、動作確認中にショックを受けました。これは少なくとも iOS 13.5.1 以降*11では修正済みのようです。買い物機能ではワークアラウンドとして .onTapGesture
を代わりに使っており、時期を見て Button
に戻していく予定です。iOS バージョンによって分岐する Button 用コンポーネントを用意する案も検討しています。
ScrollView { VStack(alignment: .leading) { …… CartSectionHeader(title: "受け取り情報") PickupNameRow(pickupName: dataSource.pickupName) .onTapGesture(perform: didTapPickupNameRow) // Button はスクロール時のタップで誤動作することがあるので、代わりに .onTapGesture を使う Divider() …… } }
2. 機能不足
iOS 14 からは必要なものが大分揃った印象がありますが、iOS 13 の SwiftUI では「あってほしい……」と思う機能が足りません。想定していた通り UIKit で代用するか、工夫したコンポーネントを用意するなどの対応が必要になります。
例1:ScrollView の contentOffset の get / set
ボタンをタップしたら指定位置まで自動でスクロールしたい、といった要件はよくあります。しかし、iOS 13 の SwiftUI の ScrollView
ではスクロール位置の設定が UIKit のように簡単にはできません。iOS 14 からは ScrollViewReader
と ScrollViewProxy
が登場してできるようになりました。
https://developer.apple.com/documentation/swiftui/scrollviewproxy
例2:読みやすい幅対応
クックパッド iOS アプリは iPad にも対応しており、読みやすい幅を考慮した UI にしています。
Auto Layout では readableContentGuide
のような仕組みが用意されていましたが、iOS 13 の SwiftUI では公式に用意された仕組みを見つけることができませんでした。
そのため GeometryReader
と 与えられた画面幅に対して読みやすい幅を返す ApproximateReadableContent
というヘルパー(社内製)を組み合わせた ReadableScrollView
を用意して対応しました。
struct ReadableScrollView<Content: View>: View { var content: Content var body: some View { ScrollView { HStack(alignment: .top, spacing: 0) { Spacer(minLength: 0) VStack(alignment: .leading, spacing: 0) { content } .frame(maxWidth: ApproximateReadableContent.maximumWidth) Spacer(minLength: 0) } } } }
他には
@Environment(\.horizontalSizeClass) var sizeClass
をもとに適切な padding を設定するという方法もあるようです。
例3:遅延読み込み
VStack 内に並べる項目数が非常に多くなると、画面描画がカクカクしはじめるなどパフォーマンス上の問題が出てきます。
見えない部分まですべて描画するのではなく、スクロールに合わせて逐次構築していく「遅延読み込み」を実現したいところですが、iOS 13 の SwiftUI ではまだ便利な仕組みが用意されていません。項目数や構成を減らして軽くするか、従来の UICollectionView などに置き換えるかといった選択肢になります。
iOS 14 からは LazyVStack
/ LazyHStack
のように欲しかったものが揃ってきました。詳しくは WWDC 2020 の Stacks, Grids, and Outlines in SwiftUI をご参照ください。
まとめ
SwiftUI の導入によって、総合的には開発体験と効率は向上できたと思っています。一度慣れれば新規の UI 実装も素早くでき、改変も楽にできます。何より楽しく開発できるという点で、SwiftUI を採用して良かったと感じます。
iOS 13 の初期バージョンの SwiftUI にはまだ困る面もあります。ただそれは今後最新の iOS が普及するにつれて解決する問題だと思っています。現状では QA 体制・自動テストの工夫で問題に気づけるようにし、事業的な優先度と実装の難易度を把握して、適宜 UIKit を使うなどの判断ができる体制で付き合っていく必要はあります。
チームで「改善していける」良い雰囲気づくり
開発体験の向上については、アプリ実装の素早さがチームの雰囲気づくりにも貢献できた点があると感じています。
デザイナーと話しながら、色々なパターンを素早く実際に作って、本番データでアプリの挙動をすぐに試してみることも可能でした。ミーティングで出たアイデアをすぐに具体化してみて、UI 面の課題やコンテンツ面・運用上の問題に気づいて軌道修正する、そのように勢いよく改善が進んでいくと、開発のモチベーションも上がると思っています。
このように楽しく素早い開発ができる環境を整え、チームで次々と改善していける良い雰囲気づくりができた点でも一定の成果はあったと感じています。
一方で、現時点のユーザー体験はまだまだ理想形にはほど遠い状態です。 2021年はこの作り変えやすい土台を活かして、「レシピ」と「買い物」が融合した圧倒的に良い体験を探っていきたいと考えています。
クックパッドでは仲間を募集しています!
今回は買い物機能の開発にあたっての技術選定や SwiftUI の活用事例についてご紹介しました。
買い物機能の取り組みにご興味を持ってくださった方は、プロダクトマネージャー @naganyo さんの1年の振り返りの記事もぜひご一読ください。 note.com
クックパッドでは技術を活用してサービスや事業を推進していきたい方を大募集中です!
iOS・Android・Web フロントエンド、サーバーサイド(Ruby on Rails, Java 等)、検索技術、ログ分析、マーケティングなどなど様々な領域で取り組みたい課題が沢山あります。 カジュアル面談や学生インターンシップなども随時実施していますので、ぜひお気軽にご連絡ください!
*1:近隣地域の生産者や市場直送の新鮮でおいしい食材を、1品から送料無料で購入できる。https://info.cookpad.com/pr/news/press_2020_1015
*2:最短で注文当日に受け取り可能。https://www.youtube.com/watch?v=FIhAFjVmS10
*3:より正確には「Apple プラットフォーム向けのアプリ」で、iOS や macOS に限らず tvOS や watchOS のアプリも作れる。https://developer.apple.com/xcode/swiftui/
*4:最初のコミットは2012年の8月、2020年12月末時点では6万1,000以上のコミットを経て、24万1,800行のコードベースがある。
*5:クックパッドのエンジニアが語る、巨大で歴史あるアプリにおける破壊と創造 - ログミーTech https://logmi.jp/tech/articles/321186
*6:検証当時、NavigationView は動作が不安定な部分があったため。List, Form はカスタマイズ性に乏しく、TextField も日本語変換時の動作が怪しかったため。
*7:@ObservedObject は iOS 13.3 未満あたりで値を弱参照していて、ふとした拍子に消える可能性があったので ViewController 側に持たせるようにしている。
*8:UIKit では UI 操作などのために外部に提供するインターフェイスとなるが、SwiftUI では ObservableObject のように別の形が提供されているので基本的には不要なはず。むしろ SwiftUI のスコープ外から意図しない操作を可能にしてクラッシュさせる恐れもある。ここではそれを回避したいため。
*9:既存実装では Auto Layout の制約がからまっていて読み解きが大変だったり、結局一度消してすべて付け直したりすることもよくある。UIStackView によって改善されたものの、後から一部を抜き出すときはやや煩雑な印象がある。
*10:コードと xib の両方から消したつもりで、xib 側に一部残っていると実行時エラーでクラッシュする。
*11:いつの間にか直っていた。実機のみで起きる不具合で、アップデート後は iOS 13.0 〜13.4 の実機が手に入りづらく、修正された正確なバージョンはわからない。