長いリスト向けSwiftUIパフォーマンス調整:実践的な対処法
長いリスト向けのSwiftUIパフォーマンスチューニング:再レンダリング、安定した行ID、ページング、画像読み込み、古いiPhoneでのスムーズなスクロールに対する実践的な対処法。

実際のSwiftUIアプリで「遅いリスト」はどう見えるか
SwiftUIで「遅いリスト」が現れるのは、多くの場合バグではありません。指の動きにUIが追いつかない瞬間です。スクロール中にリストがためらい、フレームが落ち、全体が重く感じられます。
典型的な兆候:
- 古い端末で特にスクロールがカクつく
- 行がチラついたり、一瞬誤った内容を表示する
- タップが遅れる、スワイプアクションの反応が遅れる
- 本体が熱くなりバッテリー消費が増える
- スクロールを続けるとメモリ使用量が増える
長いリストは各行が「小さく見える」場合でも遅く感じることがあります。描画するピクセルだけがコストではないからです。SwiftUIは行ごとの識別、レイアウト計算、フォントや画像の解決、フォーマット処理、データ変更時の差分計算などを行います。これらの処理が頻繁に起きると、リストがホットスポットになります。
ここで2つの考えを分けておくと役に立ちます。SwiftUIで「再レンダリング」と言うとき、多くはビューのbodyが再計算されることを指します。bodyの再計算自体は大抵安価です。高コストなのは、その再計算が誘発する処理:重いレイアウト、画像デコード、テキスト測定、あるいはSwiftUIが行の識別が変わったとみなして多数の行を再構築することです。
例を想像してください。2,000件のチャットメッセージがあり、新しいメッセージが毎秒届き、各行がタイムスタンプを整形し、複数行テキストを測定し、アバターを読み込みます。たった1件が追加されても、スコープが広すぎる状態変更が原因で多くの行が再評価され、その一部が再描画されることがあります。
目標はマイクロ最適化ではありません。目的はスクロールの滑らかさ、タップの即時反応、そして実際に変更された行だけを更新することです。以下の対策は、安定した識別、行を安価にすること、不必要な更新を減らすこと、読み込みを制御することに焦点を当てています。
主な原因:識別、行ごとの作業量、更新の嵐
SwiftUIリストが重く感じるとき、多くの場合「行数が多すぎる」ことが根本原因ではありません。スクロール中に余計な作業が発生していることが多いのです:行の再構築、レイアウトの再計算、画像の何度もの再読み込みなど。
主な原因は大まかに三つに分かれます。
- 不安定な識別(identity): 行に一貫した
idがなく、\.selfのような変更されうる値を使っている。SwiftUIは古い行と新しい行を対応付けられず、必要以上に再構築する。 - 行ごとの作業が重い: 日付フォーマット、フィルタ、画像のリサイズ、ネットワーク/ディスク処理を行ビュー内で実行している。
- 更新の嵐: タイピング、タイマー、進捗など1つの変更が頻繁な状態更新を引き起こし、リストが何度もリフレッシュされる。
例: 2,000件の注文があり、各行が通貨をフォーマットし、属性付き文字列を生成し、画像フェッチを開始するとします。親ビューで「最終同期」タイマーが1秒ごとに更新されていると、注文データ自体が変わらなくても、そのタイマーがリストを頻繁に無効化してスクロールをカクつかせることがあります。
ListとLazyVStackの違いが感じられる理由
Listは単なるスクロールビュー以上のものです。テーブル/コレクションの振る舞いやシステム最適化を念頭に作られています。大規模データセットを比較的少ないメモリで扱うことができますが、識別や頻繁な更新に敏感な場合があります。
ScrollView+LazyVStackはレイアウトや見た目の制御をより細かくできますが、余計なレイアウト作業や高コストな更新を誘発しやすい面があります。古い端末ではその差が顕著に出ます。
UIを書き直す前に、まずは計測してください。安定したIDの使用、行の作業を外に出す、状態のチャーンを減らすといった小さな修正で問題が解決することがよくあります。
SwiftUIが効率的に差分を取れるように行の識別を修正する
長いリストがジャギーに感じるとき、識別が原因であることがよくあります。SwiftUIはIDを比較してどの行を再利用できるか判断します。IDが変わると、SwiftUIは行を新規扱いにして古いものを破棄し、必要以上に再構築します。これがランダムな再レンダリング、スクロール位置の喪失、不要なアニメーション発生の原因になります。
最も簡単な改善は、各行のidを安定させ、データソースに結びつけることです。
よくある誤りはビュー内で識別子を生成することです:
ForEach(items) { item in
Row(item: item)
.id(UUID())
}
これはレンダーごとに新しいIDを強制するため、毎回すべての行が「異なる」と見なされます。
モデルにすでに存在するID、例えばデータベースの主キーやサーバーID、安定したスラッグなどを使いましょう。持っていない場合は、モデル作成時に一度だけ生成して保存してください(ビュー内では生成しない)。
struct Item: Identifiable {
let id: Int
let title: String
}
List(items) { item in
Row(item: item)
}
インデックスに注意してください。ForEach(items.indices, id: \.self)は位置を識別子にしてしまいます。挿入、削除、ソートがあると行が「移動」し、SwiftUIが誤ったビューを再利用する可能性があります。配列が真に静的な場合にのみインデックスを使ってください。
id: .selfを使う場合は、その要素のHashable値が時間とともに安定していることを確認してください。フィールドが更新されてハッシュが変わると、行の識別が変わってしまいます。EquatableやHashableを設計する際の安全なルールは、編集可能なプロパティ(nameやisSelectedなど)ではなく、単一で安定したIDに基づくことです。
チェックリスト:
- IDはデータソースから来ている(ビュー内の
UUID()ではない) - 表示内容が変わってもIDは変わらない
- 配列位置に依存した識別は、リストが並び替えられない場合に限る
再レンダリングを減らして行ビューを安価にする
長いリストが遅く感じるのは、各行がbodyの再評価ごとに多くの作業をしているからです。目標は各行を再構築しても安価にすることです。
隠れた高コスト要因のひとつは「大きな」値を行に渡すことです。大きな構造体、深くネストしたモデル、重い計算プロパティを渡すと、見た目が変わっていなくても余計な作業が発生します。文字列の再生成、日付のパース、画像のリサイズ、複雑なレイアウトツリーの再構築が思ったより頻繁に起きているかもしれません。
高コストな処理をbodyの外へ移す
遅い処理があるなら、行のbody内で毎回再構築しないでください。データ到着時に前処理しておく、ViewModelでキャッシュする、小さなヘルパーでメモ化するなどの方法があります。
累積する行レベルのコスト例:
- 各行で新しい
DateFormatterやNumberFormatterを生成する body内での重い文字列フォーマット(結合、正規表現、Markdown解析)body内での.mapや.filterによる派生配列生成- ビュー内で大きなバイナリを読み取り変換する(JSONのデコードなど)
- 多数のネストしたスタックや条件分岐による過度に複雑なレイアウト
単純な例: フォーマッタはstaticにして、整形済み文字列を行に渡す。
enum Formatters {
static let shortDate: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .none
return f
}()
}
struct OrderRow: View {
let title: String
let dateText: String
var body: some View {
HStack {
Text(title)
Spacer()
Text(dateText).foregroundStyle(.secondary)
}
}
}
行を分割し、Equatableを使う
もし一部分だけが変わる(バッジ数など)なら、その部分をサブビューに分離して残りの行は安定させます。値駆動のUIなら、サブビューをEquatable化(またはEquatableViewでラップ)すると、入力が変わらないときSwiftUIが作業をスキップできることがあります。等価性の入力は小さく具体的に保ち、モデル全体をベースにしないようにしてください。
リスト全体の更新を引き起こす状態更新を制御する
行自体は問題ないのに、何かがリスト全体を頻繁に更新させていることがあります。スクロール中は小さな余計な更新でもカクつきの原因になります。特に古い端末では顕著です。
よくある原因の一つはモデルを頻繁に再生成することです。親ビューが再構築され、ビューが所有するViewModelに@ObservedObjectを使っていると、SwiftUIはそれを再生成し購読がリセットされ、再度Publishが発生します。ビューがモデルを所有する場合は@StateObjectを使って一度だけ作成し安定させ、外部から注入されるオブジェクトには@ObservedObjectを使ってください。
もう一つの静かな性能キラーは頻繁すぎるPublishです。タイマー、Combineパイプライン、進捗更新は毎秒何度も発火することがあります。公開プロパティがリストに影響を与える(あるいは画面で共有されているObservableObject上にある)と、そのたびにリストが無効化されます。
例: 検索フィールドが各キー入力でqueryを更新し、5,000件を即座にフィルタすると、ユーザーが入力中にリストが絶えず差分を取り続けます。検索はデバウンスして、短いポーズの後にフィルタ済み配列を更新してください。
役に立つパターン:
- 変化の速い値をリストを駆動するオブジェクトから外す(より小さいオブジェクトやローカルな
@Stateを使う) - 検索やフィルタはデバウンスする
- 高頻度のタイマーパブリッシュは避ける、または実際に変化があったときだけ更新する
- 行ごとの状態は可能な限りローカルにする(行内部の
@State) - 大きなモデルを分割する:リストデータ用の
ObservableObjectと画面レベルのUI状態用の別オブジェクトを用意する
要旨は単純です:スクロール中は静かにしておくこと。重要な変更がないなら、リストに仕事をさせないようにする。
コンテナ選び:List対LazyVStack
どのコンテナを選ぶかはiOS側でどれだけ仕事をしてくれるかに影響します。
Listは標準的なテーブルUI(テキスト、画像、スワイプ、選択、区切り、編集モード、アクセシビリティ)に適しています。内部でAppleが長年チューンした最適化の恩恵を受けます。
ScrollView+LazyVStackはカード、混在するコンテンツブロック、フィードスタイルなどカスタムレイアウトに向いています。「Lazy」は表示時にビルドするという意味ですが、Listと同じ振る舞いをすべて提供するわけではありません。非常に大きなデータセットではメモリ使用が増え、古い端末でスクロールがカクつくことがあります。
簡単な決定ルール:
- 設定画面、受信箱、注文一覧などの典型的なテーブルなら
Listを使う - カードや混在コンテンツなどカスタムレイアウトが必要なら
ScrollView+LazyVStackを使う - 数千件のアイテムで単純な表が必要ならまず
Listから始める - ピクセル単位での制御が必要なら
LazyVStackを試し、メモリとフレーム落ちを測定する
また、気づかないうちにスクロールを遅くするスタイリングに注意してください。影、ブラー、複雑なオーバーレイなどはレンダリングコストを増やします。深度感を出したいなら行全体に適用するのではなく、小さな要素(アイコンなど)に対して重い効果を適用してください。
具体例: 5,000行の「注文」画面はListの方が滑らかに保たれることが多いです。LazyVStackに切り替えてカードスタイルで大きな影や複数のオーバーレイを使うと、コードは綺麗でもジャンクが発生するかもしれません。
ページング:スムーズでメモリスパイクを避ける
ページングは長いリストを高速に保つために有効です。描画する行数を減らし、保持するモデルを少なくし、差分処理の量を抑えられます。
開始点として明確なページング契約を作りましょう:固定のページサイズ(たとえば30~60件)、「結果がもうない」フラグ、そして取得中のみ表示する読み込み行など。
よくある罠は「最後の行が表示されたとき」に次のページを読み込むことです。これは遅すぎることが多く、ユーザーは末尾で一時停止を目にします。代わりに末尾から数行手前で読み込みを始めてください。
単純なパターン:
@State private var items: [Item] = []
@State private var isLoading = false
@State private var reachedEnd = false
func loadNextPageIfNeeded(currentIndex: Int) {
guard !isLoading, !reachedEnd else { return }
let threshold = max(items.count - 5, 0)
guard currentIndex >= threshold else { return }
isLoading = true
Task {
let page = try await api.fetchPage(after: items.last?.id)
await MainActor.run {
let newUnique = page.filter { p in !items.contains(where: { $0.id == p.id }) }
items.append(contentsOf: newUnique)
reachedEnd = page.isEmpty
isLoading = false
}
}
}
これにより、重複した行(API結果の重なり)、複数のonAppearから起きる競合、過剰な一括ロードなどの一般的な問題を回避できます。
プル・トゥ・リフレッシュをサポートする場合は、ページング状態を慎重にリセットしてください(itemsをクリアし、reachedEndをリセットし、可能なら実行中のタスクをキャンセルする)。バックエンドを制御できるなら、安定したIDとカーサーベースのページングを採用するとUIは明らかに滑らかになります。
画像、テキスト、レイアウト:行レンダリングを軽く保つ
長いリストが遅く感じる主原因はほとんどの場合行そのものです。画像が典型的な原因で、デコード、リサイズ、描画がスクロール速度を上回ると古い端末で顕著に現れます。
リモート画像を読み込む場合、スクロール中にメインスレッドで重い処理が起きないようにしてください。44~80ptのサムネイルに対してフル解像度をダウンロードするのは避けましょう。
例: 各行がアバターを持つ「メッセージ」画面で、各行が2000x2000の画像をダウンロードしてスケールダウンし、ブラーや影を付けていると、データモデルが単純でもリストはカクつきます。
画像処理を予測可能にする
高影響な習慣:
- 表示サイズに近いサムネイルをサーバー側または事前生成で用意する
- デコードやリサイズは可能な限りメインスレッド外で行う
- サムネイルをキャッシュし、速いスクロールで再取得や再デコードを避ける
- プレースホルダーは最終サイズに合わせ、チラつきやレイアウトジャンプを防ぐ
- 行内の画像に対して重い修飾(大きな影や複雑なマスク、ブラー)は避ける
レイアウトの安定化でスラッシュを防ぐ
行の高さが頻繁に変わると、SwiftUIは測定に多くの時間を使いがちです。サムネイルに固定フレームを使う、行の行数制限を一貫させる、余白を安定させるなどして行を予測可能にしてください。テキストが伸びる可能性がある場合は1〜2行に制限するなどして、1つの更新で再測定が発生しないようにします。
プレースホルダーも重要です。グレーの円が後でアバターに置き換わる場合、同じフレームを占有することで行の再レイアウトを防げます。
測定方法:Instrumentsで実際のボトルネックを見つける
「なんとなくカクつく」だけで作業するのは当て推量になりがちです。Instrumentsはスクロール中に何がメインスレッドで動いているか、一時的な割当がどれだけ発生しているか、フレームドロップの原因を示してくれます。
実際のデバイス(サポートするなら古いもの)でベースラインを定義してください。繰り返し可能な動作を1つ決めます:画面を開いて上から下へ高速スクロール、ロードモアを1回トリガー、戻ってくる、というように。最もひどいヒッチポイント、メモリピーク、UIの応答性を記録します。
有益なInstrumentsの3つのビュー
これらを組み合わせて使います:
- Time Profiler: スクロール中のメインスレッドのスパイクを探す。レイアウト、テキスト測定、JSON解析、画像デコードなどがここに出ることが多い。
- Allocations: 速いスクロール中の一時的オブジェクトの急増を監視する。繰り返し発生するフォーマットや属性付き文字列、新しい行モデルの生成を示唆する。
- Core Animation: ドロップフレームと長いフレーム時間を確認する。レンダリング圧力かデータ処理かを切り分けるのに役立つ。
スパイクを見つけたらコールツリーを掘り、画面あたり1回起きているのか、行ごとにスクロールごとに起きているのかを判断してください。後者が滑らかなスクロールを壊す原因です。
スクロールとページングイベントのサインポストを追加する
多くのアプリはスクロール中に追加の作業をします(画像読み込み、ページング、フィルタリング)。サインポストでタイムライン上にその瞬間を可視化すると原因特定が速くなります。
import os
let log = OSLog(subsystem: "com.yourapp", category: "list")
os_signpost(.begin, log: log, name: "LoadMore")
// fetch next page
os_signpost(.end, log: log, name: "LoadMore")
変更は一度に一つずつ行い、その都度再テストしてください。FPSが改善してもAllocationsが悪化したら、スタッターをメモリ圧力にトレードしただけかもしれません。ベースラインノートを残し、数値が正しい方向に動く変更だけを残しましょう。
リスト性能を密かに殺すよくあるミス
明らかな問題(大きな画像、膨大なデータセット)もありますが、データが増えたときにだけ現れる問題も多いです。以下は特に注意すべきミスです。
1) 不安定な行ID
ビュー内でIDを作る(id: .selfを参照型に使う、UUID()を行のボディで生成するなど)は古典的なミスです。SwiftUIは識別により差分を取り、IDが変わると行を新しく扱い、キャッシュされたレイアウトを破棄することがあります。
モデルから安定したID(データベースの主キー、サーバーID、もしくはモデル作成時に保存したUUID)を使ってください。なければ追加しましょう。
2) onAppear内での重い処理
onAppearはスクロールで行が入れ替わるたびに頻繁に呼ばれます。各行がそこで画像デコードやJSON解析、DBルックアップを始めると繰り返しスパイクを起こします。
重い処理は行の外で行い、データロード時に前処理・キャッシュして、onAppearはページングトリガーなど安価な処理に限定してください。
3) 行編集のためにリスト全体にバインドしている
各行に大きな配列への@Bindingを渡すと、小さな編集でも大きな変化に見え、多くの行が再評価されリスト全体が更新されることがあります。
行には不変の値を渡し、変更は「idのためのfavoriteをトグル」など軽量なアクションで伝える方が良いです。行固有の状態はその行に属する場合のみローカルに保持してください。
4) スクロール中の過度なアニメーション
アニメーションはリストで高コストになりがちです。リスト全体に対してanimation(.default, value:)をかけたり、頻繁に変わる値をすべてアニメートするのは避けましょう。
シンプルに保つコツ:
- 変化するのはその行だけの範囲にアニメーションを限定する
- 高速スクロール中はアニメーションを避ける(選択やハイライトなど)
- 頻繁に変わる値の暗黙アニメーションに注意する
- 複雑な合成効果より単純なトランジションを優先する
実例: チャット系のリストで各行がonAppearでネットワークフェッチを開始し、UUID()をidに使い、かつ「既読」ステータスをアニメーションさせているとします。この組み合わせは絶え間ない行の入れ替わりを作り出します。識別を直し、処理をキャッシュし、アニメーションを制限すれば同じUIが劇的に滑らかになります。
クイックチェックリスト、簡単な例、次のステップ
もしいくつかしかできないなら、まずここから始めてください:
- 各行に安定した一意のIDを使う(配列インデックスや毎回生成するUUIDは避ける)
- 行の作業を極力小さくする:重いフォーマット、大きなビュー木、
body内の高コスト計算を避ける - 公開更新を制御する:タイマーや入力など高速に変化する状態がリスト全体を無効化しないようにする
- ページングとプリフェッチでメモリ使用をフラットに保つ
- 変更前後をInstrumentsで測定し、憶測でなくデータに基づいて判断する
サポートインボックスに20,000件の会話があることを想像してください。各行が件名、最新メッセージのプレビュー、タイムスタンプ、未読バッジ、アバターを表示します。ユーザーは検索を行い、新着メッセージがスクロール中に届くかもしれません。遅いバージョンの多くは次のようなことを同時にやっています:各キー入力で行を再構築し、テキストを何度も測定し、画像を早すぎる段階で大量に取得する。
段階的かつ実用的なプラン(コードベースを根こそぎ変える必要はありません):
- ベースラインを取得:短いスクロールと検索セッションをInstrumentsで記録(Time Profiler + Core Animation)
- 識別を修正:モデルに実際のIDがあることを確認し、
ForEachで一貫して使う - ページングを追加:最初は最新の50~100件、その後ユーザーが末尾に近づいたら読み込む
- 画像を最適化:小さなサムネイルを使い、結果をキャッシュし、メインスレッドでのデコードを避ける
- 再測定:レイアウトパスの減少、ビュー更新の減少、古いデバイスでのフレーム時間の安定を確認する
もしiOSアプリだけでなくバックエンドやウェブ管理パネルまで含む製品を作るなら、データモデルとページング契約を早期に設計するのも有効です。AppMaster (appmaster.io) のようなプラットフォームはそのフルスタックワークフロー向けに作られており、データとビジネスロジックを視覚的に定義して、デプロイ可能なソースコードを生成したりセルフホストしたりできます。
よくある質問
まずは行のIDを直してください。モデルから安定したidを使い、ビュー内でIDを生成しないでください。IDが変わるとSwiftUIは行を新規扱いにし、必要以上に再構築します。
bodyの再計算自体は通常安価です。問題となるのはそれが引き起こす重い処理です。複雑なレイアウト、テキスト測定、画像デコード、または不安定なIDによって多くの行が再構築されるとフレーム落ちが起きます。
行の中でUUID()を使ったり、配列のインデックスをIDにすると問題になります。サーバーやデータベースのID、あるいはモデル作成時に保存したUUIDなど、更新を通じて変わらないIDを使ってください。
id: .selfは、編集可能なフィールドが変わるとハッシュが変わる可能性があり、パフォーマンスを悪化させます。必要ならば、HashableやEquatableのベースはnameやisSelectedのような編集可能なプロパティではなく、単一の安定IDにしてください。
body内で重い処理をしないこと。日付や数値はあらかじめフォーマットして渡し、フォーマッタを毎回生成しないでください。map/filterで大きな派生配列を作るのも避け、表示用の小さな値を行に渡しましょう。
onAppearはスクロールで行が出入りするたびに何度も呼ばれます。onAppearで画像デコードやデータベース読み出しなど重い処理を始めると繰り返しスパイクが発生します。ページングのトリガーのような軽い処理に限定してください。
高速で変化する公開プロパティ(タイマー、入力、進捗など)がリストを保持する主要なオブジェクトにあると、そのたびにリスト全体が無効化されます。検索はデバウンスする、タイマー更新頻度を下げる、大きなObservableObjectを分割するなどで更新の嵐を避けましょう。
標準的なテーブル的UI(行、スワイプ、選択、区切りなど)ならListを使うのが安全です。カスタムレイアウトや混在コンテンツが必要ならScrollView+LazyVStackを使いますが、メモリやレイアウトの余分な仕事に注意して測定してください。
最後の行が表示されたときだけ次ページを読み込むのは遅いです。末尾から数行手前の閾値で読み込みを開始し、isLoadingやreachedEndでガードし、重複結果はIDで弾いてください。
本物のデバイスでベースラインを取り、Instrumentsでメインスレッドのスパイクやメモリ割増を確認してください。Time Profilerで滞留する処理、Allocationsで一時オブジェクトの増加、Core Animationでドロップフレームを見ます。


