# SwiftUI-Performance: LazyVStack und komplexe Listen optimieren > Optimierungstechniken für LazyVStack und SwiftUI-Listen. Speicherverbrauch reduzieren, Scroll-Performance verbessern und häufige Fallstricke vermeiden. - Published: 2026-03-06 - Updated: 2026-04-29 - Author: SharpSkill - Tags: swiftui, ios, performance, lazyvstack, swift - Reading time: 14 min --- Listen gehören zu den am häufigsten verwendeten Komponenten in iOS-Anwendungen. SwiftUIs `LazyVStack` und `List` bieten performante Lösungen zur Anzeige von Datensammlungen, doch eine falsche Verwendung kann die Benutzererfahrung schnell beeinträchtigen. Das Verständnis der internen Mechanik dieser Komponenten hilft dabei, häufige Fallstricke zu vermeiden und flüssige Oberflächen zu bauen. > **Was dieser Artikel behandelt** > > Dieser Artikel stellt die wichtigsten Optimierungstechniken für SwiftUI-Listen vor: Lazy Loading, View-Recycling, Identifier-Verwaltung und fortgeschrittene Muster für große Datensätze. ## Lazy Loading in SwiftUI verstehen Lazy Loading instanziiert Views erst dann, wenn sie auf dem Bildschirm sichtbar werden. Anders als `VStack`, das alle Kind-Views sofort erstellt, verzögert `LazyVStack` diese Erstellung und reduziert so drastisch den Speicherverbrauch und die initiale Renderzeit. ```swift // LazyVStackComparison.swift 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) } } } } } ``` Der Performance-Unterschied wird bereits bei wenigen Hundert Elementen deutlich. Bei 10.000 Items kann `VStack` mehrere Sekunden zum Starten benötigen, während `LazyVStack` sofort reagiert. ### Die Auswirkung von Lazy Loading messen Instruments ermöglicht eine präzise Messung von Speicher- und CPU-Auslastung. Hier eine Test-View, die den Unterschied veranschaulicht: ```swift // PerformanceMeasurement.swift 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() } } ``` Bei der Ausführung mit `VStack` erscheinen alle 10.000 Logs sofort. Mit `LazyVStack` werden nur die sichtbaren Elemente (etwa 15-20 je nach Bildschirmgröße) initial geloggt, weitere kommen beim Scrollen hinzu. > **Retention-Verhalten** > > `LazyVStack` behält erstellte Views nach ihrem Erscheinen im Speicher. Anders als `List`, das Zellen aktiv wiederverwendet, bleiben Views in einem `LazyVStack` bestehen, bis die übergeordnete Komponente zerstört wird. ## Die Bedeutung stabiler Identifier Identifier bilden den zentralen Mechanismus für SwiftUI-Listen-Updates. Ein instabiler Identifier verursacht unnötige View-Neuerstellungen und kann visuelle Bugs wie falsche Animationen oder den Verlust der Scroll-Position auslösen. ```swift // StableIdentifiers.swift // ❌ 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) } } } } ``` Ein eindeutiger und persistenter Identifier stellt sicher, dass SwiftUI Elemente bei Updates, Animationen und Vergleichen korrekt unterscheiden kann. ## Zellenoptimierung mit Equatable SwiftUI vergleicht Views, um zu entscheiden, ob ein erneutes Rendering nötig ist. Standardmäßig nutzt dieser Vergleich Reflection, was teuer sein kann. Die Implementierung von `Equatable` ermöglicht einen optimierten und expliziten Vergleich. ```swift // EquatableOptimization.swift // 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)) } } } } ``` Diese Optimierung reduziert die CPU-Last beim schnellen Scrollen erheblich, besonders bei Zellen mit Berechnungen oder Bildern. ## Asynchrones Laden von Bildern verwalten Bilder werden in Listen häufig zum Performance-Bottleneck. Eine falsche Handhabung führt zu Scroll-Rucklern und übermäßigem Speicherverbrauch. ```swift // ImageLoadingOptimization.swift import SwiftUI // Singleton image cache actor ImageCache { static let shared = ImageCache() private var cache = NSCache() 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) } } } } ``` Diese Implementierung kombiniert Speicher-Caching, vorzeitige Größenanpassung und asynchrones Laden für ein flüssiges Scroll-Erlebnis. ### Intelligentes Prefetching zur Vorhersage Für sehr lange Listen lädt Prefetching Bilder, bevor sie sichtbar werden: ```swift // ImagePrefetching.swift // Prefetching coordinator @Observable final class ImagePrefetcher { private var prefetchTasks: [URL: Task] = [:] private let prefetchDistance = 10 // Number of items ahead func prefetchImages(for items: [Contact], visibleRange: Range) { // 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.., 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) } } } } ``` ## Zwischen List und LazyVStack wählen Die Wahl zwischen `List` und `LazyVStack` hängt vom Anwendungsfall ab. Jede Komponente hat spezifische Vorteile, die es zu kennen lohnt. ```swift // ListVsLazyVStack.swift // ✅ 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) } } ``` > **Cell-Recycling** > > `List` recycelt Zellen aktiv, was zu Problemen mit lokalem State (`@State`) führen kann. `@State`-Werte in `List`-Zellen können unerwartet wiederverwendet werden. State sollte besser im Datenmodell oder in einem ViewModel gespeichert werden. ## Optimierte Sektionen und Header Das Organisieren von Inhalten in Sektionen verbessert die Lesbarkeit, kann aber bei schlechter Implementierung die Performance beeinträchtigen. Pinned Headers und Sektionsverwaltung erfordern besondere Aufmerksamkeit. ```swift // OptimizedSections.swift // 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 } } } ``` Pinned Headers (`pinnedViews: [.sectionHeaders]`) bleiben beim Scrollen sichtbar und verbessern die Navigation in langen Listen. ## Pagination und unendliches Scrollen Für große Datensätze vermeidet Pagination das Laden aller Daten in den Speicher. Die Implementierung sollte für den Benutzer transparent sein. ```swift // InfiniteScrolling.swift // 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] } ``` Dieses Muster gewährleistet ein flüssiges Laden ohne die Oberfläche zu blockieren und ermöglicht eine effiziente Speicherverwaltung. ## Profiling mit Instruments Die Identifikation von Performance-Problemen erfordert präzise Messwerkzeuge. Instruments bietet mehrere für SwiftUI geeignete Templates. ```swift // ProfilingHelpers.swift // Measurement points for debugging struct PerformanceMonitor { // Measure view creation time static func measureViewCreation( _ 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-Optimierungs-Checkliste Für effektives Profiling von SwiftUI-Listen: 1. **Time Profiler**: Funktionen mit der höchsten CPU-Last identifizieren 2. **Allocations**: Speicherwachstum während des Scrollens überprüfen 3. **SwiftUI Instrument**: Body-Auswertungen visualisieren 4. **Core Animation**: Frame Drops erkennen ```swift // InstrumentsExample.swift // 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)") } } } } ``` > **Self._printChanges()** > > Diese SwiftUI-Debugging-API gibt in der Konsole aus, welche Eigenschaften sich geändert und eine Body-Neuberechnung ausgelöst haben. Unverzichtbar zur Identifikation unnötiger Renderings. ## Fortgeschrittene Optimierungen mit drawingGroup Für komplexe Views mit vielen visuellen Effekten kann `drawingGroup()` die Performance deutlich verbessern, indem die View in einen Metal-Layer rasterisiert wird. ```swift // DrawingGroupOptimization.swift // 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()` ist besonders effektiv bei Views, die Gradienten, Schatten und Blur-Effekte kombinieren. ## Fazit Die Optimierung von SwiftUI-Listen beruht auf einem tiefen Verständnis der Mechanismen für Lazy Loading, Recycling und View-Vergleich. Die vorgestellten Techniken ermöglichen den Aufbau von Oberflächen, die Tausende von Elementen verarbeiten und dabei flüssiges Scrollen mit 60 FPS beibehalten. ### SwiftUI-Performance-Checkliste - ✅ `LazyVStack` oder `List` statt `VStack` für Sammlungen verwenden - ✅ `Identifiable` mit stabilen, eindeutigen IDs implementieren - ✅ `Equatable` für komplexe Zellen einsetzen - ✅ Bilder vor der Anzeige cachen und in der Größe anpassen - ✅ Daten mit intelligentem Prefetching vorab laden - ✅ `List` für Interaktionen (Swipe) oder `LazyVStack` für eigene Layouts wählen - ✅ `pinnedViews` für Sektions-Header nutzen - ✅ Pagination für große Datensätze implementieren - ✅ Regelmäßig mit Instruments profilen - ✅ `drawingGroup()` auf Views mit komplexen visuellen Effekten anwenden --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/de/blog/ios/swiftui-performance-lazyvstack-complex-lists