# SwiftUI Performance : optimiser LazyVStack et listes complexes > Techniques d'optimisation pour LazyVStack et listes SwiftUI. Réduire la consommation mémoire, améliorer le scrolling et éviter les pièges de performance courants. - Published: 2026-03-06 - Updated: 2026-03-28 - Author: SharpSkill - Tags: swiftui, ios, performance, lazyvstack, swift - Reading time: 14 min --- Les listes représentent l'un des composants les plus utilisés dans les applications iOS. Avec SwiftUI, `LazyVStack` et `List` offrent des solutions performantes pour afficher des collections de données, mais leur utilisation incorrecte peut rapidement dégrader l'expérience utilisateur. Comprendre les mécanismes internes de ces composants permet d'éviter les pièges courants et de construire des interfaces fluides. > **Ce que couvre cet article** > > Cet article présente les techniques d'optimisation essentielles pour les listes SwiftUI : lazy loading, recyclage des vues, gestion des identifiants et patterns avancés pour les données volumineuses. ## Comprendre le lazy loading en SwiftUI Le principe du lazy loading repose sur l'instanciation des vues uniquement lorsqu'elles deviennent visibles à l'écran. Contrairement à `VStack` qui crée immédiatement toutes ses vues enfants, `LazyVStack` diffère cette création, réduisant drastiquement la consommation mémoire et le temps de rendu initial. ```swift // LazyVStackComparison.swift import SwiftUI // ❌ Problème : VStack instancie les 10 000 vues immédiatement struct NonLazyListView: View { let items = (1...10000).map { "Item \($0)" } var body: some View { ScrollView { VStack { ForEach(items, id: \.self) { item in // Chaque vue est créée au lancement ExpensiveRowView(title: item) } } } } } // ✅ Solution : LazyVStack crée les vues à la demande struct LazyListView: View { let items = (1...10000).map { "Item \($0)" } var body: some View { ScrollView { LazyVStack { ForEach(items, id: \.self) { item in // Seules les vues visibles sont créées ExpensiveRowView(title: item) } } } } } ``` La différence de performance devient significative dès quelques centaines d'éléments. Avec 10 000 items, `VStack` peut prendre plusieurs secondes au lancement tandis que `LazyVStack` reste instantané. ### Mesurer l'impact du lazy loading Pour quantifier les gains de performance, Instruments permet de mesurer précisément l'utilisation mémoire et CPU. Voici une vue de test qui illustre la différence : ```swift // PerformanceMeasurement.swift struct ExpensiveRowView: View { let title: String // Simulation d'une initialisation coûteuse init(title: String) { self.title = title // Log pour visualiser quand la vue est créée print("Creating row: \(title)") } var body: some View { HStack { // Image avec traitement Circle() .fill( LinearGradient( colors: [.blue, .purple], startPoint: .topLeading, endPoint: .bottomTrailing ) ) .frame(width: 50, height: 50) VStack(alignment: .leading) { Text(title) .font(.headline) Text("Sous-titre avec calcul") .font(.caption) .foregroundStyle(.secondary) } Spacer() } .padding() } } ``` En exécutant avec `VStack`, les 10 000 logs s'affichent immédiatement. Avec `LazyVStack`, seuls les éléments visibles (environ 15-20 selon la taille d'écran) sont loggés, puis les suivants au fur et à mesure du scroll. > **Comportement de rétention** > > `LazyVStack` conserve les vues déjà créées en mémoire après leur apparition. Contrairement à `List` qui recycle activement les cellules, les vues d'un `LazyVStack` persistent jusqu'à la destruction du composant parent. ## Importance des identifiants stables Les identifiants constituent le mécanisme central de mise à jour des listes SwiftUI. Un identifiant instable provoque des recréations inutiles de vues et peut causer des bugs visuels comme des animations incorrectes ou des pertes de position de scroll. ```swift // StableIdentifiers.swift // ❌ Problème : utiliser l'index comme identifiant struct UnstableIdentifierView: View { @State private var items = ["A", "B", "C", "D"] var body: some View { List { // L'index change si un élément est supprimé ForEach(items.indices, id: \.self) { index in Text(items[index]) } } } } // ❌ Problème : utiliser UUID() dans le ForEach struct RegeneratedIdentifierView: View { let items = ["A", "B", "C", "D"] var body: some View { List { // UUID() génère un nouvel ID à chaque render ForEach(items, id: \.self) { item in // Problème subtil si items contient des doublons Text(item) } } } } // ✅ Solution : modèle avec identifiant stable struct Item: Identifiable { let id: UUID // Créé une seule fois 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 est stable pour toute la durée de vie de l'item ForEach(items) { item in Text(item.name) } } } } ``` L'utilisation d'un identifiant unique et persistant garantit que SwiftUI peut correctement différencier les éléments lors des mises à jour, animations et comparaisons. ## Optimisation des cellules avec Equatable SwiftUI compare les vues pour déterminer si un re-render est nécessaire. Par défaut, cette comparaison utilise la réflexion, ce qui peut être coûteux. Implémenter `Equatable` permet une comparaison optimisée et explicite. ```swift // EquatableOptimization.swift // Modèle de données struct Contact: Identifiable, Equatable { let id: UUID var name: String var email: String var avatarURL: URL? var lastActivity: Date // Comparaison personnalisée : ignorer lastActivity // si les autres propriétés sont identiques static func == (lhs: Contact, rhs: Contact) -> Bool { lhs.id == rhs.id && lhs.name == rhs.name && lhs.email == rhs.email && lhs.avatarURL == rhs.avatarURL // lastActivity volontairement exclu } } // Vue de cellule optimisée struct ContactRow: View, Equatable { let contact: Contact // Comparaison explicite pour éviter re-renders inutiles static func == (lhs: ContactRow, rhs: ContactRow) -> Bool { lhs.contact == rhs.contact } var body: some View { HStack(spacing: 12) { // Avatar asynchrone 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()) // Informations contact VStack(alignment: .leading, spacing: 2) { Text(contact.name) .font(.body.weight(.medium)) Text(contact.email) .font(.caption) .foregroundStyle(.secondary) } Spacer() } .padding(.vertical, 4) } } // Liste utilisant EquatableView struct ContactListView: View { let contacts: [Contact] var body: some View { List { ForEach(contacts) { contact in // EquatableView empêche les re-renders si contact inchangé EquatableView(content: ContactRow(contact: contact)) } } } } ``` Cette optimisation réduit significativement la charge CPU lors du scroll rapide, particulièrement avec des cellules contenant des calculs ou des images. ## Gérer le chargement d'images asynchrone Les images constituent souvent le goulot d'étranglement des performances de liste. Une gestion incorrecte provoque des saccades au scroll et une consommation mémoire excessive. ```swift // ImageLoadingOptimization.swift import SwiftUI // Cache d'images singleton actor ImageCache { static let shared = ImageCache() private var cache = NSCache() private init() { // Limite mémoire : 50 Mo 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) { // Estimation du coût : bytes de l'image let cost = Int(image.size.width * image.size.height * 4) cache.setObject(image, forKey: url.absoluteString as NSString, cost: cost) } } // Vue d'image optimisée avec cache 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 } // Vérifier le cache if let cached = await ImageCache.shared.image(for: url) { self.image = cached return } isLoading = true defer { isLoading = false } // Télécharger et redimensionner do { let (data, _) = try await URLSession.shared.data(from: url) // Redimensionner pour économiser la mémoire if let original = UIImage(data: data), let resized = await resizeImage(original, to: size) { await ImageCache.shared.setImage(resized, for: url) self.image = resized } } catch { // Gérer l'erreur silencieusement } } private func resizeImage(_ image: UIImage, to size: CGSize) async -> UIImage? { // Utiliser le scale de l'écran 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) } } } } ``` Cette implémentation combine cache mémoire, redimensionnement préalable et chargement asynchrone pour une expérience de scroll fluide. ### Prefetching intelligent pour anticipation Pour les listes très longues, le prefetching permet de charger les images avant qu'elles ne deviennent visibles : ```swift // ImagePrefetching.swift // Coordinator de prefetching @Observable final class ImagePrefetcher { private var prefetchTasks: [URL: Task] = [:] private let prefetchDistance = 10 // Nombre d'items en avance func prefetchImages(for items: [Contact], visibleRange: Range) { // Calculer la plage à précharger let prefetchStart = max(0, visibleRange.lowerBound - prefetchDistance) let prefetchEnd = min(items.count, visibleRange.upperBound + prefetchDistance) // Lancer le prefetch pour les items dans la plage 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) } } } } ``` ## Utiliser List vs LazyVStack selon le contexte Le choix entre `List` et `LazyVStack` dépend du cas d'usage. Chaque composant possède des avantages spécifiques qu'il convient de comprendre. ```swift // ListVsLazyVStack.swift // ✅ List : idéal pour les contenus interactifs // - Recyclage automatique des cellules // - Support natif de swipe actions // - Séparateurs et styles prédéfinis 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("Supprimer", systemImage: "trash") } } .swipeActions(edge: .leading) { Button { favoriteContact(contact) } label: { Label("Favori", systemImage: "star") } .tint(.yellow) } } } .listStyle(.plain) } private func deleteContact(_ contact: Contact) { contacts.removeAll { $0.id == contact.id } } private func favoriteContact(_ contact: Contact) { // Logique de favori } } // ✅ LazyVStack : idéal pour les layouts personnalisés // - Contrôle total sur le spacing et padding // - Pas de styles imposés // - Meilleure performance pour l'affichage simple struct CustomFeedView: View { let posts: [Post] var body: some View { ScrollView { LazyVStack(spacing: 16) { ForEach(posts) { post in PostCard(post: post) } } .padding(.horizontal) } } } // Modèle Post pour l'exemple 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() } // Contenu Text(post.content) // Image optionnelle 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) } } ``` > **Recyclage des cellules** > > `List` recycle activement les cellules, ce qui peut causer des problèmes avec les états locaux (`@State`). Les valeurs `@State` dans les cellules de `List` peuvent être réutilisées de manière inattendue. Préférer stocker l'état dans le modèle de données ou un ViewModel. ## Sections et headers optimisés L'organisation en sections améliore la lisibilité mais peut impacter les performances si mal implémentée. Les headers pinnés et la gestion des sections demandent une attention particulière. ```swift // OptimizedSections.swift // Modèle de données groupées struct GroupedContacts { let letter: String let contacts: [Contact] } // Vue avec sections optimisées struct SectionedContactList: View { let groupedContacts: [GroupedContacts] var body: some View { ScrollView { LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { ForEach(groupedContacts, id: \.letter) { group in Section { // Contenu de la section ForEach(group.contacts) { contact in ContactRow(contact: contact) .padding(.horizontal) .padding(.vertical, 8) // Séparateur personnalisé if contact.id != group.contacts.last?.id { Divider() .padding(.leading, 68) } } } header: { // Header pinné optimisé SectionHeader(title: group.letter) } } } } } } // Header léger pour 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) } } // Fonction de groupement optimisée extension Array where Element == Contact { func groupedByFirstLetter() -> [GroupedContacts] { // Dictionnaire pour groupement O(n) var groups: [String: [Contact]] = [:] for contact in self { let letter = String(contact.name.prefix(1)).uppercased() groups[letter, default: []].append(contact) } // Trier les groupes alphabétiquement return groups .map { GroupedContacts(letter: $0.key, contacts: $0.value) } .sorted { $0.letter < $1.letter } } } ``` Les headers pinnés (`pinnedViews: [.sectionHeaders]`) restent visibles lors du scroll, améliorant la navigation dans les longues listes. ## Pagination et chargement infini Pour les données volumineuses, la pagination évite de charger l'intégralité des données en mémoire. L'implémentation doit être transparente pour l'utilisateur. ```swift // InfiniteScrolling.swift // ViewModel gérant la 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 { // Déclencher le chargement quand on approche de la fin guard let index = items.firstIndex(where: { $0.id == currentItem.id }) else { return } // Charger 5 items avant la fin 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 { // Gérer l'erreur } } } // Vue avec scroll infini 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 { // Vérifier si besoin de charger plus await viewModel.loadMoreIfNeeded(currentItem: contact) } } // Indicateur de chargement en fin de liste if viewModel.isLoading { HStack { Spacer() ProgressView() Spacer() } .padding() } } .task { await viewModel.loadInitialData() } } } // Protocole pour le service de données protocol ContactDataService { func fetchContacts(page: Int, limit: Int) async throws -> [Contact] } ``` Ce pattern assure un chargement fluide sans bloquer l'interface et permet une gestion efficace de la mémoire. ## Profiling avec Instruments Identifier les problèmes de performance nécessite des outils de mesure précis. Instruments fournit plusieurs templates adaptés à SwiftUI. ```swift // ProfilingHelpers.swift // Points de mesure pour le debugging struct PerformanceMonitor { // Mesure du temps de création d'une vue 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 { // Plus de 16ms = frame drop print("⚠️ [\(name)] View creation took \(elapsed * 1000)ms") } #endif return view } } // Extension pour tracer les 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 } } } ``` ### Checklist d'optimisation Instruments Pour profiler efficacement une liste SwiftUI : 1. **Time Profiler** : identifier les fonctions consommant le plus de CPU 2. **Allocations** : vérifier la croissance mémoire lors du scroll 3. **SwiftUI Instrument** : visualiser les body evaluations 4. **Core Animation** : détecter les frames droppées ```swift // InstrumentsExample.swift // Vue instrumentée pour le profiling struct ProfiledContactList: View { let contacts: [Contact] var body: some View { let _ = Self._printChanges() // Affiche les changements déclenchant un render List { ForEach(contacts) { contact in ContactRow(contact: contact) .measureRender("ContactRow-\(contact.id)") } } } } ``` > **Self._printChanges()** > > Cette API de debugging SwiftUI affiche dans la console les propriétés ayant changé et déclenché une réévaluation du body. Indispensable pour identifier les re-renders inutiles. ## Optimisations avancées avec drawingGroup Pour les vues complexes avec de nombreux effets visuels, `drawingGroup()` peut améliorer significativement les performances en rastérisant la vue dans une couche Metal. ```swift // DrawingGroupOptimization.swift // Cellule avec effets visuels complexes struct ComplexVisualRow: View { let item: Item var body: some View { HStack(spacing: 16) { // Cercle avec dégradé et ombre 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) // Barre de progression avec dégradé 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() // Rastérisation pour performance .drawingGroup() } } // Liste utilisant les cellules optimisées 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()` est particulièrement efficace pour les vues avec dégradés, ombres et effets de flou combinés. ## Conclusion L'optimisation des listes SwiftUI repose sur une compréhension approfondie des mécanismes de lazy loading, de recyclage et de comparaison de vues. Les techniques présentées permettent de construire des interfaces capables de gérer des milliers d'éléments tout en maintenant une expérience de scroll fluide à 60 FPS. ### Checklist performance SwiftUI - ✅ Utiliser `LazyVStack` ou `List` plutôt que `VStack` pour les collections - ✅ Implémenter `Identifiable` avec des IDs stables et uniques - ✅ Adopter `Equatable` pour les cellules complexes - ✅ Mettre en cache et redimensionner les images avant affichage - ✅ Précharger les données avec prefetching intelligent - ✅ Choisir `List` pour les interactions (swipe) ou `LazyVStack` pour les layouts custom - ✅ Utiliser `pinnedViews` pour les headers de section - ✅ Implémenter la pagination pour les données volumineuses - ✅ Profiler régulièrement avec Instruments - ✅ Appliquer `drawingGroup()` aux vues avec effets visuels complexes --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/fr/blog/ios/swiftui-performance-lazyvstack-listes-complexes