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.

Optimisation des performances LazyVStack et listes complexes en SwiftUI

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. Mis à jour pour iOS 27 avec les nouvelles APIs de réordonnancement et d'actions de balayage.

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.

LazyVStackComparison.swiftswift
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)
                }
            }
        }
    }
}

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

Instruments permet de mesurer précisément l'utilisation mémoire et CPU. Voici une vue de test qui illustre la différence :

PerformanceMeasurement.swiftswift
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()
    }
}

En exécutant avec VStack, les 10 000 logs apparaissent immédiatement. Avec LazyVStack, seuls les éléments visibles (environ 15-20 selon la taille de l'écran) sont enregistrés au démarrage, et d'autres apparaissent au fur et à mesure du scrolling.

Comportement de rétention

LazyVStack conserve les vues créées en mémoire après leur apparition. Contrairement à List qui recycle activement les cellules, les vues dans un LazyVStack persistent jusqu'à la destruction du composant parent.

L'importance des identifiants stables

Les identifiants constituent le mécanisme central des mises à jour de listes SwiftUI. Un identifiant instable provoque des recréations de vues inutiles et peut générer des bugs visuels comme des animations incorrectes ou une perte de position de scroll.

StableIdentifiers.swiftswift
// ❌ 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)
            }
        }
    }
}

L'utilisation d'un identifiant unique et persistant garantit que SwiftUI peut correctement différencier les éléments lors des mises à jour, des animations et des comparaisons.

Optimisation des cellules avec Equatable

SwiftUI compare les vues pour déterminer si un nouveau rendu 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.

EquatableOptimization.swiftswift
// 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))
            }
        }
    }
}

Cette optimisation réduit significativement la charge CPU lors du scrolling rapide, particulièrement avec des cellules contenant des calculs ou des images.

Prêt à réussir tes entretiens iOS ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

Gérer le chargement asynchrone des images

Les images constituent souvent le goulot d'étranglement de performance des listes. iOS 27 a introduit le cache HTTP standard pour AsyncImage par défaut, éliminant le besoin de cache manuel dans de nombreux scénarios. Pour les applications ciblant iOS 26 ou versions antérieures, ou nécessitant un contrôle fin sur le comportement du cache, une implémentation personnalisée reste pertinente.

ImageLoadingOptimization.swiftswift
import SwiftUI

// Singleton image cache (for iOS 26 or custom caching needs)
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)
            }
        }
    }
}

Pour iOS 27 et versions ultérieures, AsyncImage standard respecte automatiquement les headers de cache HTTP. Un URLCache personnalisé peut être appliqué via le modificateur asyncImageURLSession(_:) pour des besoins spécifiques.

Prefetching intelligent pour anticiper

Pour les listes très longues, le prefetching charge les images avant qu'elles ne deviennent visibles :

ImagePrefetching.swiftswift
// 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)
            }
        }
    }
}

Choisir entre List et LazyVStack

iOS 27 a considérablement réduit l'écart entre List et LazyVStack. Auparavant, List était la seule option pour les actions de balayage natives et le glisser-déposer pour réordonner. Les nouvelles APIs .swipeActionsContainer() et .reorderable() apportent ces capacités à tout conteneur.

ListVsLazyVStack.swiftswift
// ✅ List: automatic cell recycling, built-in styles
// Best for: standard contacts, settings, data tables
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 with iOS 27 swipe actions
// Best for: custom layouts, cards, feeds
struct CustomFeedWithSwipeActions: View {
    @State private var posts: [Post] = []

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 16) {
                ForEach(posts) { post in
                    PostCard(post: post)
                        .swipeActions(edge: .trailing) {
                            Button(role: .destructive) {
                                deletePost(post)
                            } label: {
                                Label("Delete", systemImage: "trash")
                            }
                        }
                }
            }
            .padding(.horizontal)
            .swipeActionsContainer()
        }
    }

    private func deletePost(_ post: Post) {
        posts.removeAll { $0.id == post.id }
    }
}

Le choix dépend désormais des exigences visuelles plutôt que de la disponibilité des fonctionnalités :

ExigenceConteneur recommandé
Recyclage des cellules pour l'efficacité mémoireList
Espacement et padding personnalisésLazyVStack
Séparateurs et insets natifsList
Layouts type carte ou non standardLazyVStack
Headers de section avec comportement stickyLes deux (utiliser pinnedViews)
Actions de balayageLes deux (iOS 27+)
Glisser-déposer pour réordonnerLes deux (iOS 27+)
Recyclage des cellules

List recycle activement les cellules, ce qui peut causer des problèmes avec l'état local (@State). Les valeurs @State dans les cellules de List peuvent être réutilisées de manière inattendue. Il convient de stocker l'état dans le modèle de données ou dans un ViewModel.

Réordonner les éléments dans LazyVStack (iOS 27)

Avant iOS 27, le glisser-déposer pour réordonner nécessitait List avec onMove(perform:). Les nouvelles APIs reorderable fonctionnent avec n'importe quel conteneur, y compris LazyVStack et LazyVGrid.

ReorderableStack.swiftswift
import SwiftUI

struct ReorderableCardList: View {
    @State private var cards: [Card] = Card.sampleData

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 12) {
                ForEach(cards) { card in
                    CardView(card: card)
                }
                .reorderable()  // Mark content as draggable
            }
            .padding()
            .reorderContainer(for: Card.self) { difference in
                // Apply the reorder to the model
                cards.apply(difference: difference)
            }
        }
    }
}

struct Card: Identifiable {
    let id: UUID
    var title: String
    var color: Color

    static var sampleData: [Card] {
        [
            Card(id: UUID(), title: "Design Review", color: .blue),
            Card(id: UUID(), title: "Code Sprint", color: .green),
            Card(id: UUID(), title: "Testing Phase", color: .orange)
        ]
    }
}

struct CardView: View {
    let card: Card

    var body: some View {
        Text(card.title)
            .font(.headline)
            .frame(maxWidth: .infinity)
            .padding()
            .background(card.color.opacity(0.2))
            .cornerRadius(12)
    }
}

// Extension to apply ReorderDifference
extension Array where Element: Identifiable {
    mutating func apply(difference: ReorderDifference<Element.ID>) {
        // Move items from sources to destination
        let movedItems = difference.sources.compactMap { id in
            self.first { $0.id == id }
        }

        // Remove from original positions
        self.removeAll { item in
            difference.sources.contains(item.id)
        }

        // Insert at destination
        if let destinationIndex = self.firstIndex(where: { $0.id == difference.destination }) {
            self.insert(contentsOf: movedItems, at: destinationIndex)
        } else {
            self.append(contentsOf: movedItems)
        }
    }
}

SwiftUI gère la prévisualisation du glissement, le marqueur d'insertion et l'animation de dépôt. L'objet ReorderDifference fournit les IDs source et la destination, laissant les mises à jour du modèle au code de l'application.

Sections et headers optimisés

Organiser le contenu en sections améliore la lisibilité mais peut impacter les performances si mal implémenté. Les headers épinglés et la gestion des sections nécessitent une attention particulière.

OptimizedSections.swiftswift
// 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 }
    }
}

Les headers épinglés (pinnedViews: [.sectionHeaders]) restent visibles pendant le scrolling, améliorant la navigation dans les listes longues.

Pagination et scroll infini

Pour les données volumineuses, la pagination évite de charger toutes les données en mémoire. L'implémentation doit être transparente pour l'utilisateur.

InfiniteScrolling.swiftswift
// 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]
}

Ce pattern garantit un chargement fluide sans bloquer l'interface et permet une gestion efficace de la mémoire.

Prêt à réussir tes entretiens iOS ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

Profiling avec Instruments

Identifier les problèmes de performance nécessite des outils de mesure précis. Instruments propose plusieurs templates adaptés à SwiftUI.

ProfilingHelpers.swiftswift
// 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
            }
    }
}

Checklist d'optimisation avec Instruments

Pour un profiling efficace des listes SwiftUI :

  1. Time Profiler : identifier les fonctions qui consomment le plus de CPU
  2. Allocations : vérifier la croissance mémoire pendant le scrolling
  3. SwiftUI Instrument : visualiser les évaluations de body
  4. Core Animation : détecter les chutes de frames
InstrumentsExample.swiftswift
// 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()

Cette API de debugging SwiftUI affiche dans la console quelles propriétés ont changé et déclenché une réévaluation du body. Essentiel pour identifier les rendus 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.

DrawingGroupOptimization.swiftswift
// 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() est particulièrement efficace pour les vues combinant dégradés, ombres et effets de flou.

Sources

Conclusion

L'optimisation des listes SwiftUI repose sur une compréhension approfondie des mécanismes de lazy loading, de recyclage et de comparaison des vues. iOS 27 a apporté des améliorations significatives avec le cache natif AsyncImage et les APIs d'actions de balayage et de réordonnancement indépendantes du conteneur.

Checklist de performance SwiftUI

  • Utiliser LazyVStack ou List au lieu de VStack pour les collections
  • Implémenter Identifiable avec des IDs stables et uniques
  • Adopter Equatable pour les cellules complexes
  • Exploiter le cache natif AsyncImage d'iOS 27, ou implémenter un cache personnalisé pour les versions antérieures
  • Précharger les données avec un prefetching intelligent
  • Choisir List pour le recyclage des cellules ou LazyVStack pour les layouts personnalisés (les deux supportent désormais les actions de balayage et le réordonnancement)
  • Utiliser pinnedViews pour les headers de section
  • Implémenter la pagination pour les données volumineuses
  • Faire du profiling régulier avec Instruments
  • Appliquer drawingGroup() aux vues avec des effets visuels complexes

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

Défi du jour

Tu saurais repérer le bug en iOS ?

Un vrai bout de code, un bug caché, une tentative par jour. Sans compte pour essayer.

Anthony Fillion-Maillet

Écrit par

Anthony Fillion-Maillet

Fondateur de SharpSkill

Développeur fullstack depuis plus de 10 ans. Il dirige SharpSkill et répond de tout ce qui y est publié.

Mis à jour le 19 août 2026

Tags

#swiftui
#ios
#performance
#lazyvstack
#swift

Partager

Articles similaires