はじめに
こんにちは。N予備校iOSアプリ開発チームです。
以前、N予備校iOSアプリへ SwiftUI を導入するまでの道のりについてという記事を書かせていただきました。今回は導入しSwiftUI化を一部の画面で行った結果、どうなったかをお話します。SwiftUIで簡単なアプリを作る程度以上の前提知識がある方向きの記事となっております。
結果からお話しますと、SwiftUIの List
を使用した画面で描画が遅くなりました。
SwiftUI化を行う上でいろいろとありましたが、この記事ではSwiftUIの List
を使った描画時間についてお話します。 List
だけでも、使い方を変えるだけでパフォーマンスに差が出る話はAppleのDeveloper Forumsの投稿など眺めているとよく話題に上がってきます。SwiftUI化の真っ最中にはこれらの記事を筆頭に様々な記事を読み漁りました。しかし、現在では削除されている記事も多く、SwiftUIの活発さが身に染みる今日この頃です。さて、この List
に注目しつつ、SwiftUI化を行った弊社iOSアプリに何が起きたのか少しだけお付き合いください。
問題点
弊社iOSアプリではSwiftUI化を行った画面の一部で、描画時間による待ち時間のユーザー体験が悪くなりました。この待ち時間は「描画領域が広く古い端末」ほど顕著に長くなります。下記の表1は実際に古い端末であるiPad Pro(2ndGen) iOS15でSwiftUI化前後の描画時間を比較しました。弊社iOSアプリの「無限スクロールによってリスト要素が増える」画面でスクロール操作を連続して行い、20件の追加ロードを5回行う時間になります。SwiftUI化前後で比較したところSwiftUI化後はSwiftUI化前の5倍以上の時間がかかっています。 以上より、この待ち時間ではサービス品質に問題有りと判断しました。
端末の種類 | SwiftUI化前 | SwiftUI化後 |
---|---|---|
iPad Pro(2ndGen) iOS15 | 11.24 | 60.234 |
遅くなった背景には様々な要因があるのですが、ここではSwiftUIの List
に焦点を当ててお話します。私たちのケースでは、特に List
内で Button
を利用することが大きな遅延の要因となりました。List
画面をスクロールしデータが増えていくにつれ「描画領域が広く古い端末」ほどスクロールがもたつきました。しかし、デザインの都合上、 List
内の Button
利用によるタップジェスチャーと次の画面遷移などナビゲーション効果が外せないものでした。
ベンチマークテスト(Sampleコードで実演)
では、実際に簡単なアプリケーションで2パターンの List
を作成し描画速度を比べ、どれくらい遅くなったのか一緒に見てみましょう。
計測方法
下記のようにしてベンチマークをとります。
- 検証実機: iPad Pro(2ndGen), iOS15
- Xcode 14.2
- 計測ツール:Instruments
- ベンチマークスコア:View Bodyの全Viewを対象にしたTotal Duration
- ストーリー: アプリを起動し、表示されたリストを最後までスクロール後、アプリを終了する
- テストデータ数(リストの件数): 500件
- 試行回数: 5回
今回問題になった動作はListのスクロールです。リストのデータ数を500件にし、リストの最後までスクロールする検証ストーリーにしました。ベンチマークスコアにはSwiftUIの描画時間を使用します。また、検証端末のOSバージョンはiOS15とします。iOS16かつiOS16 SDK以降、SwiftUIの List
は UITableView
から UICollectionView
へ内部実装が変更されており、ここでは比較条件が複雑になるため割愛します。計測ツールのInstrumentsについての説明もここでは割愛します。公式ドキュメントで同ツールを使用したパフォーマンス計測の例が記載されているので参考にしてみてはいかがでしょうか。
パターン1: Identifiableに適合したデータのリスト表示
基本的な List
で計測してみます。List
の中身のために下記の Member
という Identifiable
を適合した構造体を作成します。
struct Member: Identifiable { var id = UUID() var no: Int }
この複数の Member
をリスト表示する SampleList
を下記のように作成します。
struct SampleList: View { let members: [Member] = Array(Members(500)) var body: some View { List(members) { member in Text(String(member.no)) } } }
複数の Member
を持った配列を members
という変数に定義します。 Members
は Member
オブジェクトを指定数分で作成するカスタムイテレーターです。ここではカスタムイテレーターの説明やコードの記載を割愛します。 SampleList
を上記「計測方法」項のストーリーに沿って描画時間を計測してみます。
ベンチマークスコア
試行回数 | 1 | 2 | 3 | 4 | 5 | 平均値 |
---|---|---|---|---|---|---|
Total Duration (ms) | 24.17 | 24.95 | 23.91 | 24.57 | 24.19 | 24.358 |
上記の表から、この計測では約24msほどかかりました。この数値を頭の片隅に置きつつ他のリスト表示も試して見ましょう。
パターン2: リスト内ボタンの表示
パターン1の SampleList
内にある Text
を Button
に変更しタップジェスチャーの効果を追加します。
このViewを ButtonList
として下記のように変更します。先ほどのパターン1と同様に上記「計測方法」項のストーリーに沿って描画時間を計測してみます。
struct ButtonList: View { let members: [Member] = Array(Members(500)) var body: some View { List(members) { member in Button(String(member.no)) { print("Tapped index:\(member.no).") } } } }
ベンチマークスコア
試行回数 | 1 | 2 | 3 | 4 | 5 | 平均値 |
---|---|---|---|---|---|---|
Total Duration (ms) | 51.6 | 57.72 | 55.33 | 55.23 | 55.08 | 54.992 |
上記の表がベンチマーク結果です。なんと全ての回で50ms以上を叩きだしました。 Text
を Button
に変更しただけですが、2倍以上の時間がかかっています。
ベンチマーク結果
パターン番号: Struct名 \ 試行回数 | 1 | 2 | 3 | 4 | 5 | 平均値 |
---|---|---|---|---|---|---|
パターン1: SampleList | 24.17 | 24.95 | 23.91 | 24.57 | 24.19 | 24.358 |
パターン2: ButtonList | 51.6 | 57.72 | 55.33 | 55.23 | 55.08 | 54.992 |
以上、2パターンを比較してきました。パターン1とパターン2の数値から List
内で Button
を使うことが描画時間にかなりの影響がでるとわかります。実際のアプリはデータ件数が増えたり、 List
内でより複雑なViewを内包することになるでしょう。何を表示するのか、処理速度や負荷はどれくらいかかるのか、よく注意する必要があります。
問題発覚と調査
実はSwiftUI化の後でデータが増えていく List
が重いことに気づきました。理由は「描画領域が広く古い端末」での検証がSwiftUI化の後に行われたからです。SwiftUI化の実装中はOS差異による動作やレイアウト崩れに注力しパフォーマンスに意識が向いておらず、多種多様な実機の検証まで発見が遅れることになりました。発見後は原因調査に入り、結果、SwiftUIのListが重いとなったのです。それは今まで行ってきたSwiftUI化をやめUIKitへ戻すことに繋がります。
解決方法
前項でSwiftUI化をやめUIKitへ戻すとお話しましたが、全てを戻したわけではありません。幸い、弊社アプリでSwiftUI化を行った List
以外の部分はサービス品質に問題無しと判断されました。
そこで、SwiftUIの List
で「リスト要素が増える場合」に限って UIViewControllerRepresentable
を利用し一部のUIKit化を行いました。
限られた時間の中で、可能な限り改修の変更が少なくなるよう取った解決方法でしたが、UITableViewに戻ったことで画面遷移のナビゲーション効果を従来通りに実装できるようになりました。
改善とその結果
せっかくなので UIViewControllerRepresentable
を利用したパターンと従来通りのUIKitの UITableView
を利用したパターンも比べてみましょう。ただ、SwiftUIではなくなるので、今までのInstrumentsのView Bodyを使った計測方法ではベンチマークが取れません。代わりにInstrumentsのTime Profilerでアプリ全体の処理にかかった時間をベンチマークスコアにします。
パターン3: UIViewControllerRepresentableを利用したリスト表示
まずは UIViewControllerRepresentable
を適合したViewを作成します。少し長いですが、中身は UITableView
を使った Member
の配列を表示するリストです。
struct RepresentableList: UIViewControllerRepresentable { typealias UIViewControllerType = UITableViewController // MARK: - Private Properties private static let _cellIdentifier = "RepresentableCell" // MARK: - Properties var members: [Member] = Array(Members(500)) // MARK: - UIViewControllerRepresentable func makeUIViewController(context: Context) -> UIViewControllerType { let viewController = UITableViewController(style: .plain) viewController.tableView.delegate = context.coordinator viewController.tableView.dataSource = context.coordinator viewController.tableView.register(UITableViewCell.self, forCellReuseIdentifier: Self._cellIdentifier) viewController.tableView.estimatedRowHeight = 16 viewController.tableView.rowHeight = UITableView.automaticDimension context.coordinator.viewController = viewController return viewController } func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { } func makeCoordinator() -> Coordinator { return Coordinator(members: members) } final class Coordinator: NSObject, UITableViewDataSource, UITableViewDelegate { // MARK: - Properties var members: [Member] weak var viewController: UITableViewController? // MARK: Initializers init(members: [Member]) { self.members = members } // MARK: - UITableViewDataSource func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return members.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: RepresentableList._cellIdentifier, for: indexPath) var content = cell.defaultContentConfiguration() content.text = String(members[indexPath.row].no) cell.contentConfiguration = content return cell } } }
パターン4: 従来通りのUIKitのUITableViewを利用したリスト
今までサンプルコードを記載してきましたが、今回はパターン3の内容とほとんど変わらないため割愛します。従来通りにUIKitの UITableView
を実装し、パターン3のとおり Member
の配列を表示するリストを作り計測します。
ベンチマーク結果
今まで計測した全てのパターンでTime Profilerのアプリ全体の処理にかかった時間を比較してみましょう。
パターン番号: Struct or Class 名\試行回数 | 1 | 2 | 3 | 4 | 5 | 平均値 |
---|---|---|---|---|---|---|
パターン1: SampleList | 2.55 | 1.78 | 2.48 | 2.51 | 3.18 | 2.5 |
パターン2: ButtonList | 3.25 | 3.51 | 3.31 | 3.17 | 3.2 | 3.288 |
パターン3: RepresentableList | 1.48 | 1.61 | 1.53 | 2.02 | 2.12 | 1.752 |
パターン4: UITableView | 1.31 | 2.11 | 1.39 | 1.49 | 1.91 | 1.642 |
上記の表より、パターン3の UIViewControllerRepresentable
を適合した RepresentableList
はSwiftUIの List
を利用したパターン1やパターン2より速いことがわかります。そしてパターン4の UITableView
について、パターン3で改善したとはいえ従来のUIKitを利用した描画速度には敵いません。パターン3の UIViewControllerRepresentable
はSwiftUIからUIKitを利用しているので妥当な結果でしょう。しかし、速度としてはかなりの改善ができました。ここまで比較してきた結果をまとめます。
- SwiftUIの
List
内でButton
を内包すると描画がかなり遅くなる - SwiftUIの
UIViewControllerRepresentable
を使うことで上記より速く描画できる UITableView
が一番速い
余談になりますが、 Button
の代わりに Text
に onTapGesture()
をつけたパターンや Text
の Touch Up で反応するよう Gesture
をカスタマイズしたパターンも試しました。onTapGesture()
では描画速度に差はあまり出なかったのですが Gesture
のカスタマイズでは遅くなりました。ここから先は調査時間のタイムリミットも関係し調べられていませんが、 Button
の持っている Gesture
に何かありそうです。
改善の結果
弊社アプリに UIViewControllerRepresentable
を利用した結果、SwiftUIの List
で Button
を使うよりも描画の時間を減らすことができ、サービス品質として問題ないレベルまで改善ができました。ただ、サンプルコードから解るようにコードが複雑になりました。弊社アプリは未だフルSwiftUI化を行っていないため、UIKit→SwiftUI→UIKitと変換がおこっています。これは明確なSwiftUI化のデメリットと言えるでしょう。
総括
以上、初のSwiftUI化についてお話ししました。一部のリストは複雑化するデメリットを抱えてしまいましたが、念願のSwiftUIを導入できた向上心やStoryboardから解放されたなどメリットも沢山あります。これからもSwiftUI化は続けていきます。その際は今回起こった問題を忘れずに下記の要点をチェックしていきます。
- 「最低限の環境」でパフォーマンスをチェックし、サービス品質を保てているか
- 「無限スクロールによってリスト要素が増える画面」に限ってSwiftUIの
List
を使わない UIViewControllerRepresentable
を使う場合、コードの複雑化は運用できるレベルか
この記事で少し記載しましたが、iOS16かつiOS16 SDKでSwiftUIの List
は内部で UITableView
から UICollectionView
へ変更されており、それは List
の処理を追っていくとわかります。SwiftUIはそもそも発展途上であり多くの変更や改善が続いているので、 UIViewControllerRepresentable
を利用しなくなる未来に期待しています。ぜひ皆様の対策や抱えた問題をお聞かせいただけたら幸いです。SwiftUI化がんばっていきましょう。
We are hiring!
株式会社ドワンゴの教育事業では、一緒に未来の当たり前の教育をつくるメンバーを募集しています。 カジュアル面談も行っています。 お気軽にご連絡ください!
開発チームの取り組み、教育事業の今後については、他の記事や採用資料をご覧ください。
N予備校春の入学無料キャンペーンのお知らせ
2023年4月に無料キャンペーンを実施していましたが、期間満了のため終了いたしました。 ご応募いただいたみなさんありがとうございました。