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.

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.
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.
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 :
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.
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.
// ❌ 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.
// 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.
Prêt à réussir tes entretiens iOS ?
Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.
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.
import SwiftUI
// Cache d'images singleton
actor ImageCache {
static let shared = ImageCache()
private var cache = NSCache<NSString, UIImage>()
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 :
// Coordinator de prefetching
@Observable
final class ImagePrefetcher {
private var prefetchTasks: [URL: Task<Void, Never>] = [:]
private let prefetchDistance = 10 // Nombre d'items en avance
func prefetchImages(for items: [Contact], visibleRange: Range<Int>) {
// 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..<prefetchEnd {
guard let url = items[index].avatarURL else { continue }
// Éviter les doublons
guard prefetchTasks[url] == nil else { continue }
prefetchTasks[url] = Task {
// Vérifier si déjà en cache
if await ImageCache.shared.image(for: url) != nil {
return
}
// Précharger
do {
let (data, _) = try await URLSession.shared.data(from: url)
if let image = UIImage(data: data) {
await ImageCache.shared.setImage(image, for: url)
}
} catch {
// Ignorer les erreurs de prefetch
}
}
}
// Annuler les prefetch hors plage
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)
}
}
}
}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.
// ✅ 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)
}
}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.
// 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.
// 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.
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 fournit plusieurs templates adaptés à SwiftUI.
// Points de mesure pour le debugging
struct PerformanceMonitor {
// Mesure du temps de création d'une vue
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 { // 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 :
- Time Profiler : identifier les fonctions consommant le plus de CPU
- Allocations : vérifier la croissance mémoire lors du scroll
- SwiftUI Instrument : visualiser les body evaluations
- Core Animation : détecter les frames droppées
// 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)")
}
}
}
}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.
// 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
LazyVStackouListplutôt queVStackpour les collections - ✅ Implémenter
Identifiableavec des IDs stables et uniques - ✅ Adopter
Equatablepour les cellules complexes - ✅ Mettre en cache et redimensionner les images avant affichage
- ✅ Précharger les données avec prefetching intelligent
- ✅ Choisir
Listpour les interactions (swipe) ouLazyVStackpour les layouts custom - ✅ Utiliser
pinnedViewspour 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
Passe à la pratique !
Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.
Tags
Partager
Articles similaires

SwiftUI Custom ViewModifiers : patterns réutilisables pour design system
Créer des ViewModifiers SwiftUI personnalisés pour un design system cohérent. Patterns, bonnes pratiques et exemples concrets pour styliser vos vues iOS efficacement.

SwiftUI @Observable vs @State : quand utiliser quoi en 2026
Comprendre les différences entre @Observable et @State en SwiftUI pour choisir le bon outil de gestion d'état selon le contexte de votre application iOS.

SwiftUI : Construire des interfaces modernes pour iOS
Apprenez à créer des interfaces utilisateur modernes avec SwiftUI : syntaxe déclarative, composants, animations et bonnes pratiques pour iOS 18.