💡

クラシルアプリのスクロールパフォーマンス最適化実践

に公開

背景

現状

ユーザーがクラシルアプリを利用する主要なシーンの一つが検索結果一覧です。アプリの主要部分はUIKitで構成されており、一部のSwiftUIビューはUIHostingControllerを介してUIKitに組み込まれています。

最適化の目的

クラシルのユーザー層には、比較的低スペックなデバイスを利用されている方も多く含まれています。iPhone 13やiPhone SE (第2世代) などのデバイスでもスムーズに利用できるアプリを提供することが私たちの目標です。長期的な開発によって蓄積された技術的負債により、パフォーマンス最適化は継続的に取り組むべき課題であり、同種のアプリの中でもトップクラスのユーザー体験を提供することを目指しています。

最適化の結果

Xcode Organizer Metricsによる最適化効果の検証結果は以下の通りです:

スクロールパフォーマンスの改善:

  • iPhone (全機種)、90パーセンタイルにおいて、Scroll Hitch Rateが13ms/sから8.0ms/sへ低下し、39%改善しました。

Hang Rateの改善:

  • iPhone (全機種)、90パーセンタイルにおいて、Hang Rateが27s/hから19s/hへ低下し、28%改善しました。

パフォーマンス最適化の方法論

私たちは体系的なパフォーマンス最適化手法を採用しています:

  1. 測定・検証 - InstrumentsやSimulatorのデバッグツールを用いてベースライン測定を実施
  2. 問題分析 - 測定結果に基づきパフォーマンスのボトルネックを分析し、最適化仮説を立てる
  3. 最適化実装 - コードの最適化を的確に実施
  4. 効果検証 - ステップ1に戻り、最適化効果を検証
  5. 継続的改善 - 次のパフォーマンスボトルネックを特定し、上記のプロセスを繰り返す

カクつきの深い理解

iOSの滑らかな体験を支える技術基盤

iOSは高品質なアプリを構築するための強固な基盤を提供しています。iOSのジェスチャー応答システム、RunLoopメカニズム、アニメーション優先度制御といったコア技術は、システム設計の初期段階から入念に最適化されており、これがiOSエコシステムのユーザー定着率の高さに貢献する重要な技術的要因となっています:

  • ジェスチャーシステム:iOSは効率的なジェスチャー認識器チェーンを通じて、ユーザーのタッチを即座に応答イベントに変換し、インタラクションの即時性を保証します。
  • RunLoopメカニズム:RunLoopは異なるモードを管理し、重要なユーザーインタラクションとUI更新タスクを優先的に処理することで、遅延を防止します。
  • Core Animation:Core AnimationエンジンはGPUのハードウェアアクセラレーション機能を活用し、スムーズなアニメーションを高速にレンダリングします。ほとんどの計算はGPU内で効率的に完了し、CPUの負荷を軽減します。

レンダリングパイプラインとカクつきの原理

コードの実行から画面表示までには、以下の重要なステップを経る必要があります:

CPU処理 → GPUレンダリング → フレームバッファ → VSync信号 → 画面表示

CPU段階:

  • レイアウト計算(AutoLayout制約解決)

  • ビュー階層の構築

  • Core Animationのコミット

  • テキストレンダリングと画像デコード

GPU段階:

  • ジオメトリ変換計算
  • ラスタライズ処理
  • テクスチャ合成
  • 最終ピクセル出力

カクつきが発生する具体的な原因分析:
メインスレッドで集中したタスク(レイアウトや画像デコードなど)を実行する際、1フレームの時間予算(60fpsで16.67ms、120fpsで8.33ms)を超過すると、GPUが処理後のデータを適時に受け取れず、前のフレームが再表示されることになります。これにより、ユーザーが体感するカクつきが発生します。

スクロール時のカクつきは特に顕著です。その理由は以下の点が挙げられます。

  • 大量のセルが高速なレイアウトとレンダリングを必要とする
  • 画像の連続的なデコードと表示が必要
  • スクロールジェスチャーにリアルタイムで応答する必要がある
  • CPUとGPUの負荷がピーク状態にある

カクつきの識別方法

  • テスト環境の設定
    • 実機テスト:シミュレータのパフォーマンスは実機のパフォーマンスボトルネックを正確に反映できません。
    • リリースビルドテスト:コンパイル最適化が有効になっており、デバッグ関連の追加オーバーヘッドが除去されていることを確認します。
    • ターゲットデバイステスト:iPhone SE (第2世代) などの低スペックデバイスに重点を置き、より多くのパフォーマンス問題を捕捉します。
  • 検出ツールの使用
    • ハング検出

      設定 → デベロッパ → ハング検出 を有効にすることで、メインスレッドが250msを超えてブロックされる箇所を容易に特定できます。

    • Instruments Animation Hitchesテンプレート

      InstrumentsのAnimation Hitchesテンプレートを使用して、メインスレッドで時間のかかる関数呼び出しを監視し、パフォーマンスのホットスポットを特定します。

    • オフスクリーンレンダリング検出
      シミュレータで、Debugメニュー → Color Off-screen Renderedを有効にすることで、最適化が必要な黄色い領域のビューを視覚的に識別できます。

今回のKurashiruにおける具体的な最適化施策

メインスレッドの最適化

パフォーマンスホットスポットの識別

InstrumentsのTime Profilerからメインスレッドの関数実行時間を特定:

  • 60fps表示の場合、16msを超える関数は描画の滑らかさに大きく影響します
  • 120fps (ProMotion) 表示の場合、8msを超える関数は最適化が必要です

遅延実行

RunLoopを活用した最適化:

// 次のrunloopでの実行を遅延
DispatchQueue.main.async {}

// またはRunLoopのcommon modeを使用
CFRunLoopPerformBlock(CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue) {}

common modeの主な特徴の一つは、スクロール中は呼び出されない点です。これはiOSがユーザーのスクロールジェスチャーを効率的に処理するために行った工夫であり、ユーザーインタラクションの高優先度を保証します。スクロール時、RunLoopは.tracking modesに切り替わるため、common modesに登録されたコールバックは呼び出されません。

データ更新の最適化、頻繁なreloadDataを回避:

UITableView/UICollectionViewのreloadメソッドを呼び出すと、全てのコンテンツが再構成され再レンダリングされます。全体更新は安全な方法ではあるものの、パフォーマンスのボトルネックとなる可能性があります。通常、部分的なセルの更新のみが必要であり、全体更新は不要な場合が多いです。また、複雑なリストでは、複数のリソースを非同期で要求することが頻繁にあります。レスポンスを受け取るたびにreloadを実行すると、短期間に不必要な更新が複数回トリガーされ、この方法は非効率的です。

これらの問題に対し、スロットリングメカニズムと特定のIndexPathの正確な更新を用いることで、パフォーマンスを最適化できます。

// スロットルメカニズムを使用
private var reloadWorkItem: DispatchWorkItem?

func throttledReload() {
    reloadWorkItem?.cancel()
    reloadWorkItem = DispatchWorkItem { [weak self] in
        self?.tableView.reloadData()
    }
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: reloadWorkItem!)
}

// 特定の行を正確に更新
tableView.reloadRows(at: indexPaths)

UICollectionViewの最適化

高さキャッシュメカニズム

UITableView/UICollectionViewはセルを表示する際、全てのセルの高さを事前に計算してcontentSizeの具体的な値を決定し、bounds.originを変更することでスクロールを実現します。
データが変更されるたびに高さの再計算がトリガーされます。しかし、連続して読み込まれるリストの場合、既にレンダリングされたコンテンツの高さを再計算する必要はなく、新しく読み込まれた部分の高さだけを計算すれば十分です。
そのため、計算済みのコンテンツの高さをキャッシュすることは、直接的かつ効果的な最適化方法となります。
具体的な実装としては、データモデルにHashableプロトコルを実装して効率的なhashメソッドを提供し、データと対応する高さをDictionaryに格納してUICollectionViewで再利用することができます。

AutoLayoutの最適化

AutoLayoutの原理:

Auto LayoutはCassowaryアルゴリズムを用いた制約ソルバーを使用しており、本質的に以下のような状態マシンとして機能します。

  1. 制約収集フェーズ - すべての制約条件を収集
  2. 制約解決フェーズ - 数学的アルゴリズムによるレイアウト計算
  3. 結果適用フェーズ - 計算結果をビューのframeに適用

手動でのframe設定やTextureなどのフレームワークの使用が最も高いパフォーマンスを発揮しますが、開発効率、保守性、システム互換性を考慮すると、Auto Layoutは依然としてバランスの取れた選択肢と言えます。

Constraintのissue処理:

Debug View Hierarchyの機能を用いてデバッグする際、左側で各ViewにAuto Layoutの問題があるかどうかを確認できます。問題が存在する場合、Auto Layoutの解決プロセスに不確定要素が発生し、解決速度が低下してパフォーマンスに影響を及ぼします。

通常、Constraintに関する問題が発生する主な原因は以下の通りです:

  • Constraintが一意ではない:つまり、同じViewに対して、同じ方向に複数のConstraintが設定されているケース。
  • Constraintの衝突:例えば、幅を固定値に設定しているが、他のConstraintとの関係でAutoLayoutが計算時に両立できず、どちらかを破棄しなければならない状況。
  • Constraintの不足:必要なConstraintが設定されていないため、AutoLayoutがViewの位置や高さ・幅を推測できないケース。
  • Constraintの優先度の衝突:複数のConstraintに同じpriorityが設定されており、どちらを優先するかAutoLayoutが判断できずに問題が発生するケース。

AutoLayoutの問題を解決するには、個別のケースごとに原因を分析する必要があります。Xcodeが提示するデバッグ情報をもとに、内側のViewから順に「このViewの位置は何によって決まるのか?」「サイズは何によって決まるのか?」と問い直し、最終的には外側のViewまで辿っていき、すべてのConstraintが必要かつ矛盾のない状態になっていることを確認することが重要です。

Constraint階層の最適化:

制約を設定する際は、階層をまたぐConstraint(子ビューと親ビューの親ビュー間の制約など)を作成することは避けるべきです。これはAuto Layoutの制約解決の複雑さを大幅に増加させるためです。

推奨される方法は、隣接する階層間でのみ制約を設定することです(子ビューと直接の親ビュー間、または同階層のビュー間で制約を確立する)。

// bad
subview.leadingAnchor.constraint(equalTo: superview.superview.leadingAnchor)

// good
subview.leadingAnchor.constraint(equalTo: containerView.leadingAnchor)
containerView.leadingAnchor.constraint(equalTo: superview.leadingAnchor)

ビューの再利用の最適化

頻繁な生成と破棄を避ける:

UIViewの生成コストは比較的高く、スクロール中に頻繁にビューを生成・破棄すると、パフォーマンスに大きな影響を与えます。既存のビューを再利用し、表示・非表示を切り替えて状態を制御する方が望ましい方法です。

class OptimizedCell: UITableViewCell {
    private lazy var optionalView: UIView = UIView()

    func configure(showOptionalContent: Bool) {
        optionalView.isHidden = !showOptionalContent
    }
}

過度なレイアウトを避ける:

  • setNeedsLayout() - レイアウトが必要とマークし、次の更新サイクルで実行
  • layoutIfNeeded() - レイアウト計算を即時実行、コストが高い
  • setNeedsDisplay() - 再描画が必要とマークし、慎重に使用

セルのconfigure処理中にこれらのレイアウト関連関数を呼び出すと、追加の計算コストが発生することに注意が必要です。パフォーマンスの無駄を避けるため、他の実装方法を優先的に検討すべきです。これらのレイアウト関連関数の使用は、正しいUIの実装に必要な場合のみ検討してください。

オフスクリーンレンダリングの最適化

オフスクリーンレンダリングの原理:

オフスクリーンレンダリングが発生する際、テクスチャを直接表示できないため、追加の前処理が必要となります。GPUは専用のメモリ領域でレンダリングを行い、その結果をメインスクリーンバッファに合成する必要があり、これによりGPUの負荷とメモリ使用量が増加し、結果として画面のカクつきが発生します。

GPUの処理は高度にパイプライン化されています。本来は全ての計算処理がフレームバッファに順序よく出力されますが、他のメモリへの出力指示を受けると、パイプライン内の作業を中断し、cornerRadius処理などの特殊な処理に切り替える必要があります。その後、通常のフレームバッファ出力プロセスに戻るために、クリアと復帰の処理も必要となります。

UITableViewやUICollectionViewにおいて、スクロール時の各フレームは表示されている全てのセルの再描画をトリガーします。オフスクリーンレンダリングが存在する場合、上記のコンテキスト切り替えが1秒間に60回発生し、さらに各フレームで数十枚の画像が関係する可能性があります。この頻繁な切り替えはGPUの性能に多大な負荷をかけることになります。

一般的な発生シナリオ:

  • cornerRadiusmasksToBounds = trueを同時に設定する場合

    • 解決策の一つとして、UIImageViewの上に別のUIImageViewを重ねて配置し、上層のビューは中央が透明で縁がtintColorで塗りつぶされた円形を表示します。このUIKitレベルでマスクを実現する方法により、オフスクリーンレンダリングを回避できます。
  • shadow関連のプロパティを使用する場合

    • ShadowPathを使用することで、事前にパスを指定するため、GPUはレンダリング時にCALayerの実際のコンテンツに基づいて追加のメモリ割り当てや計算を行う必要がなく、オフスクリーンレンダリングを回避できます。
  • maskプロパティを使用する場合

    • マスク効果を実現したいだけの場合、上下2層のViewを使用してmaskの代替とすることができます。

    • 例えば、Kurashiruにはユーザーレビューを表示するコンポーネントがあります。このコンポーネントは元々maskを使用して実装されており、オフスクリーンレンダリングを引き起こしていました。このViewはCollectionView内で使用されているため、スクロールのパフォーマンスに影響を与えていました。そのため、今回の最適化では2層のViewを使用する方式を採用し、この問題を回避しました。

    • Before

    • After

  • UIBlurEffectの使用

オフスクリーンレンダリングのコストは高いものの、避けられない場合は、パフォーマンスへの影響を最小限に抑える方法を考える必要があります。最適化の考え方はシンプルです:画像の角丸処理に多くのリソースを費やしているのであれば、その結果をキャッシュすることで、次のフレームでその成果を再利用し、再度描画する必要がなくなります。

CALayerはこの課題に対してshouldRasterizeという解決策を提供しています。これをtrueに設定すると、Render Serverはレイヤーのレンダリング結果(サブレイヤー、角丸、影、グループの透明度などを含む)を強制的にメモリに保存します。これにより、次のフレームで再利用が可能となり、オフスクリーンレンダリングを再度実行する必要がなくなります。以下の点に注意が必要です:

  • shouldRasterizeの主な目的はパフォーマンスの低下を抑えることですが、少なくとも1回はオフスクリーンレンダリングが発生します。レイヤーが元々シンプルで角丸や影などがない場合、この設定を有効にすると不要なオフスクリーンレンダリングが追加で発生してしまいます
  • オフスクリーンレンダリングのキャッシュには容量制限があり、画面の総ピクセル数の2.5倍を超えることはできません。また、キャッシュが100ミリ秒以上使用されない場合は自動的に破棄されます
  • レイヤーのコンテンツ(サブレイヤーを含む)は静的である必要があります。リサイズやアニメーションなどの変更が発生すると、それまで苦労して作成したキャッシュが無効になってしまいます。これが頻繁に発生する場合、「毎フレームでオフスクリーンレンダリングが必要」という状況に逆戻りしてしまい、これは開発者が極力避けるべき状況です。この問題に対して、Xcodeは「Color Hits Green and Misses Red」オプションを提供しており、キャッシュの使用が期待通りかどうかを確認することができます

画像読み込みの最適化

画像処理のフロー

ネットワークダウンロード → データキャッシュ → 画像読み込み → 画像デコード → GPUテクスチャアップロード → 画面表示

各段階がパフォーマンスのボトルネックとなる可能性があります:

  • ネットワークダウンロード

    • ネットワークリクエストの遅延はユーザーの体感速度に影響を与えます
    • 同時リクエストが多すぎるとネットワーク輻輳を引き起こす可能性があります
    • 効果的なキャッシュがない場合、重複ダウンロードが発生します
  • 画像デコード

    • パフォーマンス低下の主な原因:CPU負荷の高い処理で、大きな画像のデコードに時間がかかります。
    • 圧縮された画像は画面表示前に処理が必要です
      • JPEG:非可逆圧縮を使用し、複雑なデコードが必要

      • PNG:可逆圧縮を使用し、解凍と色空間変換が必要

      • 圧縮フォーマットのデータはGPUレンダリングに直接使用できません

        let image = UIImage(data: imageData)
        imageView.image = image  // デコードはここで発生!
        
  • サイズ処理

    • UIImageViewが画像を表示しようとする際、システムは以下の処理が必要です:

      1. 解凍:JPEG/PNGデータを生のピクセルデータに解凍
      2. 色空間変換:デバイスで表示可能な色形式に変換
      3. メモリ割り当て:デコードされたピクセルデータ用のメモリを確保
      // 例:4032 × 3024 ピクセルの画像、圧縮ファイルサイズ4MB
      let width = 4032
      let height = 3024
      let bytesPerPixel = 4  // RGBA、各チャネル1バイト
      
      // デコード後のメモリ使用量
      let memoryUsage = width × height × bytesPerPixel
      // = 4032 × 3024 × 4 = 48,771,072 バイト
      // ≈ 46.5 MB
      
      print("圧縮ファイルサイズ: 4MB")
      print("デコード後のメモリ使用量: \(memoryUsage / 1024 / 1024)MB")
      
  • GPUアップロード:大きなテクスチャはGPUのパフォーマンスに影響を与えます

最適化戦略

1. 適切なサイズの画像読み込み:

適切なサイズの画像を選択することで、ユーザーのネットワークトラフィックを効果的に削減できるだけでなく、画面上での画像の読み込み速度を大幅に向上させることができます。
Kurashiruでは、バックエンドが使用シーンに応じて適切なサイズの画像リンクを提供します。例えば、ユーザーアイコンを表示する際に、高解像度版のアイコン画像を読み込む必要はありません。
この最適化の実装は比較的簡単です。Postmanなどのツールを用いてネットワークトラフィック内の不適切なサイズの画像リソースをチェックし、該当するコード呼び出し箇所を特定し、使用する画像リンクを調整するだけです。

2. バックグラウンドでの画像処理:

上記の説明から分かるように、画像読み込みプロセスにおけるデコード段階はパフォーマンスのボトルネックとなっています。UIImageがUIImageViewに割り当てられる際、デフォルトのデコード処理はメインスレッドで実行されます。適切に処理されない場合、このプロセスがメインスレッドをブロックする可能性があります。
解決策は、デコード処理をバックグラウンドスレッドに移行することです。

3. ダウンサンプリング:

なぜダウンサンプリングが必要なのでしょうか?

UITableView/UICollectionViewでは、次のような状況に遭遇することがよくあります。

  • 元画像:3000×2000ピクセル(23MBのメモリ)
  • 表示領域:300×200ピクセル
  • メモリの無駄:表示サイズは1/100であるにもかかわらず、100%のメモリを使用

パフォーマンス比較:

// メモリ使用量の比較
let originalMemory = 3000 * 2000 * 4  // 24MB
let downsampledMemory = 300 * 200 * 4  // 240KB

print("メモリ節約: \((originalMemory - downsampledMemory) / 1024 / 1024)MB")
// 出力: メモリ節約: 23MB

以下の方法で画像のダウンサンプリングを実行できます:

func downsampleImage(data: Data, to pointSize: CGSize, scale: CGFloat = UIScreen.main.scale) -> UIImage? {
    let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
    guard let imageSource = CGImageSourceCreateWithData(data, imageSourceOptions) else { return nil }
    
    let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
    
    let downsampleOptions = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceShouldCacheImmediately: true,
        kCGImageSourceCreateThumbnailWithTransform: true,
        kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels
    ] as CFDictionary
    
    guard let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions) else {
        return nil
    }
    
    return UIImage(cgImage: downsampledImage)
}

Kurashiruで使用しているNukeフレームワークでは、Processorを使用することでより簡単にダウンサンプリングを実現できます。詳細については、こちらのドキュメントを参照してください。

4. プリフェッチメカニズム:

iOSはUITableViewとUICollectionViewにプリフェッチメカニズムを提供しています。対応するdelegateメソッドを実装することで、システムはユーザーのスクロール速度と方向に基づいて、どのセルのデータを事前に準備する必要があるかを自動的に計算します。これにより、UI表示前にデータの準備(例えば、バックグラウンドスレッドでの画像の事前ダウンロード)を開始でき、実際の表示時には準備済みのコンテンツを即座に表示できます。

extension ViewController: UICollectionViewDataSourcePrefetching {
    func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) {
        indexPaths.forEach { indexPath in
            let imageURL = getImageURL(for: indexPath)
            ImageCache.shared.prefetchImage(url: imageURL)
        }
    }

    func collectionView(_ collectionView: UICollectionView, cancelPrefetchingForItemsAt indexPaths: [IndexPath]) {
        indexPaths.forEach { indexPath in
            let imageURL = getImageURL(for: indexPath)
            ImageCache.shared.cancelPrefetch(url: imageURL)
        }
    }
}

さらに、画像読み込みには効率的なキャッシュ戦略、優先度制御、プログレッシブレンダリング(低品質画像を先に表示し、その後高品質版を読み込む)など、様々な一般的な最適化手法があります。成熟した画像読み込みフレームワークを使用することで、これらの基本的なパフォーマンス最適化を簡単に実現できます。

一般的なiOSの画像ライブラリには以下のものがあります

  • SDWebImage
  • Kingfisher
  • Nuke

まとめ

体系的なパフォーマンス最適化により、Kurashiru iOSアプリのスクロールパフォーマンスを大幅に向上させることができました。今回の最適化のポイントは以下の通りです:

  1. 科学的な計測システムの確立 - Xcode Organizerなどのツールを使用したパフォーマンス指標の継続的なモニタリング
  2. レンダリング原理の深い理解 - CPU/GPUパイプラインの観点からのパフォーマンスボトルネックの分析
  3. 多面的な最適化戦略 - メインスレッド、レイアウト、レンダリング、画像読み込みなど、各層での最適化
  4. 継続的な改善メカニズム - 長期的なパフォーマンス最適化プロセスの確立

パフォーマンス最適化は継続的なプロセスであり、ユーザー体験、開発効率、保守コストのバランスを取る必要があります。iOSシステムとハードウェアの進化に伴い、新しい最適化技術とベストプラクティスにも継続的に注目していきます。

dely Tech Blog

Discussion