SwiftUIパフォーマンス:LazyVStackと複雑なリストの最適化
LazyVStackとSwiftUIリストの最適化テクニック。メモリ消費を削減し、スクロールパフォーマンスを向上させ、よくある落とし穴を回避します。

リストはiOSアプリケーションで最も頻繁に使用されるコンポーネントの1つです。SwiftUIのLazyVStackとListはデータコレクションを表示するための高性能なソリューションを提供しますが、誤った使い方は急速にユーザー体験を低下させます。これらのコンポーネントの内部メカニズムを理解することで、よくある落とし穴を回避し、滑らかなインターフェースを構築できます。
本記事ではSwiftUIリストに不可欠な最適化テクニックを紹介します:レイジーローディング、ビューのリサイクル、識別子の管理、大規模データセット向けの高度なパターンです。
SwiftUIにおけるLazyローディングの理解
Lazyローディングは、ビューが画面に表示されたときにのみインスタンス化します。すべての子ビューを即座に作成するVStackとは異なり、LazyVStackはこの作成を遅延させ、メモリ消費と初期レンダリング時間を大幅に削減します。
import SwiftUI
// ❌ Problem: VStack instantiates all 10,000 views immediately
struct NonLazyListView: View {
let items = (1...10000).map { "Item \($0)" }
var body: some View {
ScrollView {
VStack {
ForEach(items, id: \.self) { item in
// Each view is created at launch
ExpensiveRowView(title: item)
}
}
}
}
}
// ✅ Solution: LazyVStack creates views on demand
struct LazyListView: View {
let items = (1...10000).map { "Item \($0)" }
var body: some View {
ScrollView {
LazyVStack {
ForEach(items, id: \.self) { item in
// Only visible views are created
ExpensiveRowView(title: item)
}
}
}
}
}パフォーマンスの差は数百要素でも顕著になります。10,000要素では、VStackは起動に数秒かかることがありますが、LazyVStackは瞬時に動作します。
Lazyローディングの影響を測定する
InstrumentsはメモリとCPUの使用率を正確に測定できます。次のテストビューはその違いを示します:
struct ExpensiveRowView: View {
let title: String
// Simulating expensive initialization
init(title: String) {
self.title = title
// Log to visualize when the view is created
print("Creating row: \(title)")
}
var body: some View {
HStack {
// Image with processing
Circle()
.fill(
LinearGradient(
colors: [.blue, .purple],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 50, height: 50)
VStack(alignment: .leading) {
Text(title)
.font(.headline)
Text("Subtitle with computation")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding()
}
}VStackで実行すると、10,000のログがすぐに表示されます。LazyVStackでは、表示されている要素(画面サイズに応じて約15〜20)のみが最初にログされ、スクロールすると他の要素が表示されます。
LazyVStackは表示後、作成されたビューをメモリに保持します。セルを積極的にリサイクルするListとは異なり、LazyVStack内のビューは親コンポーネントが破棄されるまで残ります。
安定した識別子の重要性
識別子はSwiftUIリストの更新における中心的なメカニズムを構成します。不安定な識別子は不要なビューの再作成を引き起こし、誤ったアニメーションやスクロール位置の喪失といった視覚的なバグを発生させる可能性があります。
// ❌ Problem: using index as identifier
struct UnstableIdentifierView: View {
@State private var items = ["A", "B", "C", "D"]
var body: some View {
List {
// Index changes if an element is deleted
ForEach(items.indices, id: \.self) { index in
Text(items[index])
}
}
}
}
// ❌ Problem: using UUID() in ForEach
struct RegeneratedIdentifierView: View {
let items = ["A", "B", "C", "D"]
var body: some View {
List {
// UUID() generates a new ID on each render
ForEach(items, id: \.self) { item in
// Subtle issue if items contain duplicates
Text(item)
}
}
}
}
// ✅ Solution: model with stable identifier
struct Item: Identifiable {
let id: UUID // Created once
var name: String
init(name: String) {
self.id = UUID()
self.name = name
}
}
struct StableIdentifierView: View {
@State private var items = [
Item(name: "A"),
Item(name: "B"),
Item(name: "C"),
Item(name: "D")
]
var body: some View {
List {
// id is stable for the item's lifetime
ForEach(items) { item in
Text(item.name)
}
}
}
}一意かつ永続的な識別子を使用することで、SwiftUIは更新、アニメーション、比較の際に要素を正しく区別できます。
Equatableによるセルの最適化
SwiftUIは再レンダリングが必要かどうかを判断するためにビューを比較します。デフォルトでは、この比較はリフレクションを使用するため高コストになる可能性があります。Equatableを実装することで、最適化された明示的な比較が可能になります。
// Data model
struct Contact: Identifiable, Equatable {
let id: UUID
var name: String
var email: String
var avatarURL: URL?
var lastActivity: Date
// Custom comparison: ignore lastActivity
// if other properties are identical
static func == (lhs: Contact, rhs: Contact) -> Bool {
lhs.id == rhs.id &&
lhs.name == rhs.name &&
lhs.email == rhs.email &&
lhs.avatarURL == rhs.avatarURL
// lastActivity intentionally excluded
}
}
// Optimized cell view
struct ContactRow: View, Equatable {
let contact: Contact
// Explicit comparison to avoid unnecessary re-renders
static func == (lhs: ContactRow, rhs: ContactRow) -> Bool {
lhs.contact == rhs.contact
}
var body: some View {
HStack(spacing: 12) {
// Async avatar
AsyncImage(url: contact.avatarURL) { phase in
switch phase {
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fill)
case .failure:
Image(systemName: "person.circle.fill")
.foregroundStyle(.gray)
default:
ProgressView()
}
}
.frame(width: 44, height: 44)
.clipShape(Circle())
// Contact information
VStack(alignment: .leading, spacing: 2) {
Text(contact.name)
.font(.body.weight(.medium))
Text(contact.email)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(.vertical, 4)
}
}
// List using EquatableView
struct ContactListView: View {
let contacts: [Contact]
var body: some View {
List {
ForEach(contacts) { contact in
// EquatableView prevents re-renders if contact unchanged
EquatableView(content: ContactRow(contact: contact))
}
}
}
}この最適化は、特に計算や画像を含むセルでの高速スクロール中、CPU負荷を大幅に削減します。
iOSの面接対策はできていますか?
インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。
非同期画像読み込みの管理
画像はリストにおいてしばしばパフォーマンスのボトルネックになります。誤った処理はスクロールのカクつきや過剰なメモリ消費を引き起こします。
import SwiftUI
// Singleton image cache
actor ImageCache {
static let shared = ImageCache()
private var cache = NSCache<NSString, UIImage>()
private init() {
// Memory limit: 50 MB
cache.totalCostLimit = 50 * 1024 * 1024
}
func image(for url: URL) -> UIImage? {
cache.object(forKey: url.absoluteString as NSString)
}
func setImage(_ image: UIImage, for url: URL) {
// Cost estimation: image bytes
let cost = Int(image.size.width * image.size.height * 4)
cache.setObject(image, forKey: url.absoluteString as NSString, cost: cost)
}
}
// Optimized image view with caching
struct CachedAsyncImage: View {
let url: URL?
let size: CGSize
@State private var image: UIImage?
@State private var isLoading = false
var body: some View {
Group {
if let image {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
} else if isLoading {
Rectangle()
.fill(Color.gray.opacity(0.2))
.overlay(ProgressView())
} else {
Rectangle()
.fill(Color.gray.opacity(0.2))
}
}
.frame(width: size.width, height: size.height)
.clipped()
.task(id: url) {
await loadImage()
}
}
private func loadImage() async {
guard let url else { return }
// Check cache
if let cached = await ImageCache.shared.image(for: url) {
self.image = cached
return
}
isLoading = true
defer { isLoading = false }
// Download and resize
do {
let (data, _) = try await URLSession.shared.data(from: url)
// Resize to save memory
if let original = UIImage(data: data),
let resized = await resizeImage(original, to: size) {
await ImageCache.shared.setImage(resized, for: url)
self.image = resized
}
} catch {
// Handle error silently
}
}
private func resizeImage(_ image: UIImage, to size: CGSize) async -> UIImage? {
// Use screen scale
let scale = await UIScreen.main.scale
let targetSize = CGSize(
width: size.width * scale,
height: size.height * scale
)
return await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
let renderer = UIGraphicsImageRenderer(size: targetSize)
let resized = renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: targetSize))
}
continuation.resume(returning: resized)
}
}
}
}この実装はメモリキャッシュ、事前リサイズ、非同期読み込みを組み合わせて、滑らかなスクロール体験を提供します。
先読みのためのインテリジェントなプリフェッチ
非常に長いリストでは、プリフェッチによって画像が表示される前に読み込まれます:
// Prefetching coordinator
@Observable
final class ImagePrefetcher {
private var prefetchTasks: [URL: Task<Void, Never>] = [:]
private let prefetchDistance = 10 // Number of items ahead
func prefetchImages(for items: [Contact], visibleRange: Range<Int>) {
// Calculate prefetch range
let prefetchStart = max(0, visibleRange.lowerBound - prefetchDistance)
let prefetchEnd = min(items.count, visibleRange.upperBound + prefetchDistance)
// Launch prefetch for items in range
for index in prefetchStart..<prefetchEnd {
guard let url = items[index].avatarURL else { continue }
// Avoid duplicates
guard prefetchTasks[url] == nil else { continue }
prefetchTasks[url] = Task {
// Check if already cached
if await ImageCache.shared.image(for: url) != nil {
return
}
// Prefetch
do {
let (data, _) = try await URLSession.shared.data(from: url)
if let image = UIImage(data: data) {
await ImageCache.shared.setImage(image, for: url)
}
} catch {
// Ignore prefetch errors
}
}
}
// Cancel out-of-range prefetches
cancelOutOfRangePrefetches(validRange: prefetchStart..<prefetchEnd, items: items)
}
private func cancelOutOfRangePrefetches(validRange: Range<Int>, items: [Contact]) {
let validURLs = Set(
items[validRange].compactMap { $0.avatarURL }
)
for (url, task) in prefetchTasks {
if !validURLs.contains(url) {
task.cancel()
prefetchTasks.removeValue(forKey: url)
}
}
}
}ListとLazyVStackの選択
ListとLazyVStackの選択は使用ケースに依存します。各コンポーネントには知っておくべき特定の利点があります。
// ✅ List: ideal for interactive content
// - Automatic cell recycling
// - Native swipe actions support
// - Built-in separators and styles
struct ContactsWithSwipeActions: View {
@State private var contacts: [Contact] = []
var body: some View {
List {
ForEach(contacts) { contact in
ContactRow(contact: contact)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
deleteContact(contact)
} label: {
Label("Delete", systemImage: "trash")
}
}
.swipeActions(edge: .leading) {
Button {
favoriteContact(contact)
} label: {
Label("Favorite", systemImage: "star")
}
.tint(.yellow)
}
}
}
.listStyle(.plain)
}
private func deleteContact(_ contact: Contact) {
contacts.removeAll { $0.id == contact.id }
}
private func favoriteContact(_ contact: Contact) {
// Favorite logic
}
}
// ✅ LazyVStack: ideal for custom layouts
// - Full control over spacing and padding
// - No imposed styles
// - Better performance for simple display
struct CustomFeedView: View {
let posts: [Post]
var body: some View {
ScrollView {
LazyVStack(spacing: 16) {
ForEach(posts) { post in
PostCard(post: post)
}
}
.padding(.horizontal)
}
}
}
// Post model for example
struct Post: Identifiable {
let id: UUID
let author: String
let content: String
let imageURL: URL?
}
struct PostCard: View {
let post: Post
var body: some View {
VStack(alignment: .leading, spacing: 12) {
// Header
HStack {
Circle()
.fill(Color.blue)
.frame(width: 40, height: 40)
Text(post.author)
.font(.headline)
Spacer()
}
// Content
Text(post.content)
// Optional image
if let imageURL = post.imageURL {
CachedAsyncImage(url: imageURL, size: CGSize(width: 300, height: 200))
.cornerRadius(12)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(16)
.shadow(color: .black.opacity(0.1), radius: 4, y: 2)
}
}Listはセルを積極的にリサイクルしますが、これはローカル状態(@State)に問題を引き起こす可能性があります。Listセル内の@State値は予期せず再利用されることがあります。状態はデータモデルやViewModelに保存することが推奨されます。
最適化されたセクションとヘッダー
コンテンツをセクションに整理することは可読性を向上させますが、実装が悪いとパフォーマンスに影響します。固定ヘッダーとセクション管理には特別な注意が必要です。
// Grouped data model
struct GroupedContacts {
let letter: String
let contacts: [Contact]
}
// View with optimized sections
struct SectionedContactList: View {
let groupedContacts: [GroupedContacts]
var body: some View {
ScrollView {
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
ForEach(groupedContacts, id: \.letter) { group in
Section {
// Section content
ForEach(group.contacts) { contact in
ContactRow(contact: contact)
.padding(.horizontal)
.padding(.vertical, 8)
// Custom separator
if contact.id != group.contacts.last?.id {
Divider()
.padding(.leading, 68)
}
}
} header: {
// Optimized pinned header
SectionHeader(title: group.letter)
}
}
}
}
}
}
// Lightweight header for performance
struct SectionHeader: View {
let title: String
var body: some View {
Text(title)
.font(.headline)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
.padding(.vertical, 8)
.background(.ultraThinMaterial)
}
}
// Optimized grouping function
extension Array where Element == Contact {
func groupedByFirstLetter() -> [GroupedContacts] {
// Dictionary for O(n) grouping
var groups: [String: [Contact]] = [:]
for contact in self {
let letter = String(contact.name.prefix(1)).uppercased()
groups[letter, default: []].append(contact)
}
// Sort groups alphabetically
return groups
.map { GroupedContacts(letter: $0.key, contacts: $0.value) }
.sorted { $0.letter < $1.letter }
}
}固定ヘッダー(pinnedViews: [.sectionHeaders])はスクロール中も表示されたままになり、長いリストでのナビゲーションを向上させます。
ページネーションと無限スクロール
大規模データセットの場合、ページネーションによってすべてのデータをメモリに読み込むことを回避できます。実装はユーザーに対して透過的である必要があります。
// ViewModel handling pagination
@Observable
final class PaginatedListViewModel {
private(set) var items: [Contact] = []
private(set) var isLoading = false
private(set) var hasMorePages = true
private var currentPage = 0
private let pageSize = 20
private let dataService: ContactDataService
init(dataService: ContactDataService) {
self.dataService = dataService
}
func loadInitialData() async {
guard items.isEmpty else { return }
await loadNextPage()
}
func loadMoreIfNeeded(currentItem: Contact) async {
// Trigger loading when approaching the end
guard let index = items.firstIndex(where: { $0.id == currentItem.id }) else {
return
}
// Load 5 items before the end
let thresholdIndex = items.count - 5
if index >= thresholdIndex {
await loadNextPage()
}
}
private func loadNextPage() async {
guard !isLoading, hasMorePages else { return }
isLoading = true
defer { isLoading = false }
do {
let newItems = try await dataService.fetchContacts(
page: currentPage,
limit: pageSize
)
items.append(contentsOf: newItems)
currentPage += 1
hasMorePages = newItems.count == pageSize
} catch {
// Handle error
}
}
}
// View with infinite scroll
struct InfiniteContactList: View {
@State private var viewModel: PaginatedListViewModel
init(dataService: ContactDataService) {
_viewModel = State(initialValue: PaginatedListViewModel(dataService: dataService))
}
var body: some View {
List {
ForEach(viewModel.items) { contact in
ContactRow(contact: contact)
.task {
// Check if more loading needed
await viewModel.loadMoreIfNeeded(currentItem: contact)
}
}
// Loading indicator at end of list
if viewModel.isLoading {
HStack {
Spacer()
ProgressView()
Spacer()
}
.padding()
}
}
.task {
await viewModel.loadInitialData()
}
}
}
// Protocol for data service
protocol ContactDataService {
func fetchContacts(page: Int, limit: Int) async throws -> [Contact]
}このパターンはインターフェースをブロックすることなく滑らかな読み込みを保証し、効率的なメモリ管理を可能にします。
iOSの面接対策はできていますか?
インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。
Instrumentsによるプロファイリング
パフォーマンス問題を特定するには正確な測定ツールが必要です。InstrumentsはSwiftUIに適した複数のテンプレートを提供します。
// Measurement points for debugging
struct PerformanceMonitor {
// Measure view creation time
static func measureViewCreation<T: View>(
_ name: String,
@ViewBuilder content: () -> T
) -> T {
let start = CFAbsoluteTimeGetCurrent()
let view = content()
let elapsed = CFAbsoluteTimeGetCurrent() - start
#if DEBUG
if elapsed > 0.016 { // More than 16ms = frame drop
print("⚠️ [\(name)] View creation took \(elapsed * 1000)ms")
}
#endif
return view
}
}
// Extension to trace renders
extension View {
func debugRender(_ label: String) -> some View {
#if DEBUG
let _ = Self._printChanges()
print("🔄 Rendering: \(label)")
#endif
return self
}
func measureRender(_ label: String) -> some View {
modifier(RenderMeasureModifier(label: label))
}
}
struct RenderMeasureModifier: ViewModifier {
let label: String
@State private var renderCount = 0
func body(content: Content) -> some View {
content
.onAppear {
renderCount += 1
#if DEBUG
print("📊 [\(label)] Render count: \(renderCount)")
#endif
}
}
}Instruments最適化チェックリスト
効果的なSwiftUIリストのプロファイリングのために:
- Time Profiler:最もCPUを消費する関数を特定
- Allocations:スクロール中のメモリ増加を確認
- SwiftUI Instrument:body評価を可視化
- Core Animation:フレームドロップを検出
// Instrumented view for profiling
struct ProfiledContactList: View {
let contacts: [Contact]
var body: some View {
let _ = Self._printChanges() // Shows changes triggering re-render
List {
ForEach(contacts) { contact in
ContactRow(contact: contact)
.measureRender("ContactRow-\(contact.id)")
}
}
}
}このSwiftUIデバッグAPIは、どのプロパティが変更されbody再評価をトリガーしたかをコンソールに出力します。不要な再レンダリングを特定するために不可欠です。
drawingGroupによる高度な最適化
多くの視覚効果を含む複雑なビューでは、drawingGroup()がビューをMetalレイヤーにラスタライズすることでパフォーマンスを大幅に向上させます。
// Cell with complex visual effects
struct ComplexVisualRow: View {
let item: Item
var body: some View {
HStack(spacing: 16) {
// Circle with gradient and shadow
Circle()
.fill(
RadialGradient(
colors: [.blue, .purple, .pink],
center: .center,
startRadius: 0,
endRadius: 25
)
)
.frame(width: 50, height: 50)
.shadow(color: .purple.opacity(0.5), radius: 8, y: 4)
VStack(alignment: .leading, spacing: 4) {
Text(item.name)
.font(.headline)
// Progress bar with gradient
GeometryReader { geometry in
Capsule()
.fill(Color.gray.opacity(0.2))
.overlay(alignment: .leading) {
Capsule()
.fill(
LinearGradient(
colors: [.green, .yellow, .orange],
startPoint: .leading,
endPoint: .trailing
)
)
.frame(width: geometry.size.width * item.progress)
}
}
.frame(height: 8)
}
}
.padding()
// Rasterization for performance
.drawingGroup()
}
}
// List using optimized cells
struct OptimizedComplexList: View {
let items: [Item]
var body: some View {
ScrollView {
LazyVStack(spacing: 8) {
ForEach(items) { item in
ComplexVisualRow(item: item)
}
}
.padding()
}
}
}
struct Item: Identifiable {
let id: UUID
let name: String
let progress: Double
}drawingGroup()は、グラデーション、影、ブラー効果を組み合わせたビューに対して特に効果的です。
まとめ
SwiftUIリストの最適化は、Lazyローディング、リサイクル、ビュー比較メカニズムへの深い理解に基づいています。紹介したテクニックを使用することで、60 FPSの滑らかなスクロールを維持しながら数千の要素を処理できるインターフェースを構築できます。
SwiftUIパフォーマンスチェックリスト
- ✅ コレクションには
VStackの代わりにLazyVStackまたはListを使用する - ✅ 安定した一意のIDで
Identifiableを実装する - ✅ 複雑なセルには
Equatableを採用する - ✅ 表示前に画像をキャッシュしてリサイズする
- ✅ インテリジェントなプリフェッチでデータを事前読み込みする
- ✅ インタラクション(スワイプ)には
List、カスタムレイアウトにはLazyVStackを選択する - ✅ セクションヘッダーには
pinnedViewsを使用する - ✅ 大規模データセットにはページネーションを実装する
- ✅ Instrumentsで定期的にプロファイリングする
- ✅ 複雑な視覚効果のあるビューには
drawingGroup()を適用する
今すぐ練習を始めましょう!
面接シミュレーターと技術テストで知識をテストしましょう。
タグ
共有
関連記事

SwiftUIカスタムViewModifier:デザインシステム向け再利用可能パターン
一貫したデザインシステムのためにSwiftUIでカスタムViewModifierを構築します。iOSビューを効率的にスタイリングするためのパターン、ベストプラクティス、実用的な例を紹介します。

SwiftUI @Observable vs @State: 2026年にどちらをいつ使うか
SwiftUIにおける@Observableと@Stateの違いを理解し、iOSアプリに最適な状態管理ツールを選択しましょう。

SwiftUI:iOSのモダンなインターフェース構築ガイド
SwiftUIを使ったモダンなUIの構築方法を解説します。宣言的構文、コンポーネント、アニメーション、iOS 18のベストプラクティスを網羅しています。