N予備校iOSアプリへ SwiftUI を導入してみて List編

はじめに

こんにちは。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倍以上の時間がかかっています。 以上より、この待ち時間ではサービス品質に問題有りと判断しました。

表1: スクロール操作を連続して行い、20件の追加ロードを5回し終えるまでに要する時間 (sec)

端末の種類 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の ListUITableView から 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 という変数に定義します。 MembersMember オブジェクトを指定数分で作成するカスタムイテレーターです。ここではカスタムイテレーターの説明やコードの記載を割愛します。 SampleList を上記「計測方法」項のストーリーに沿って描画時間を計測してみます。

ベンチマークスコア

表2: 試行回数毎のTotal Duration (ms)

試行回数 1 2 3 4 5 平均値
Total Duration (ms) 24.17 24.95 23.91 24.57 24.19 24.358

上記の表から、この計測では約24msほどかかりました。この数値を頭の片隅に置きつつ他のリスト表示も試して見ましょう。

パターン2: リスト内ボタンの表示

パターン1の SampleList 内にある TextButton に変更しタップジェスチャーの効果を追加します。 この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).")
            }
        }
    }
}

ベンチマークスコア

表3: 試行回数毎のTotal Duration (ms)

試行回数 1 2 3 4 5 平均値
Total Duration (ms) 51.6 57.72 55.33 55.23 55.08 54.992

上記の表がベンチマーク結果です。なんと全ての回で50ms以上を叩きだしました。 TextButton に変更しただけですが、2倍以上の時間がかかっています。

ベンチマーク結果

表4: 2パターンのTotal Duration (ms) を比較

パターン番号: 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のアプリ全体の処理にかかった時間を比較してみましょう。

表5: 試行回数毎のTime Profiler (sec)

パターン番号: 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 の代わりに TextonTapGesture() をつけたパターンや Text の Touch Up で反応するよう Gesture をカスタマイズしたパターンも試しました。onTapGesture() では描画速度に差はあまり出なかったのですが Gesture のカスタマイズでは遅くなりました。ここから先は調査時間のタイムリミットも関係し調べられていませんが、 Button の持っている Gesture に何かありそうです。

改善の結果

弊社アプリに UIViewControllerRepresentable を利用した結果、SwiftUIの ListButton を使うよりも描画の時間を減らすことができ、サービス品質として問題ないレベルまで改善ができました。ただ、サンプルコードから解るようにコードが複雑になりました。弊社アプリは未だフル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!

株式会社ドワンゴの教育事業では、一緒に未来の当たり前の教育をつくるメンバーを募集しています。 カジュアル面談も行っています。 お気軽にご連絡ください!

カジュアル面談応募フォームはこちら

www.nnn.ed.nico

開発チームの取り組み、教育事業の今後については、他の記事や採用資料をご覧ください。

speakerdeck.com

N予備校春の入学無料キャンペーンのお知らせ

2023年4月に無料キャンペーンを実施していましたが、期間満了のため終了いたしました。 ご応募いただいたみなさんありがとうございました。