Ottimizzazione delle prestazioni SwiftUI per liste lunghe: rimedi pratici
Ottimizzazione delle prestazioni SwiftUI per liste lunghe: soluzioni pratiche per rerender, identità stabile delle righe, paginazione, caricamento immagini e scorrimento fluido su iPhone più vecchi.

Come si presentano le “liste lente” nelle app SwiftUI reali
Una “lista lenta” in SwiftUI di solito non è un bug. È il momento in cui l'interfaccia non riesce a stare al passo con il dito. Lo noti mentre scorri: la lista esita, i frame saltano e tutto sembra pesante.
Segnali tipici:
- Lo scorrimento va a scatti, specialmente su dispositivi più vecchi
- Le righe sfarfallano o mostrano brevemente contenuti sbagliati
- I tap sembrano in ritardo, o le azioni swipe partono tardi
- Il telefono si scalda e la batteria cala più del previsto
- L'uso di memoria cresce quanto più scorri
Le liste lunghe possono sembrare lente anche quando ogni riga appare “piccola”, perché il costo non è solo disegnare pixel. SwiftUI deve comunque capire cosa è ogni riga, calcolare il layout, risolvere font e immagini, eseguire il tuo codice di formattazione e fare il diff quando i dati cambiano. Se uno di questi lavori avviene troppo spesso, la lista diventa un punto critico.
Conviene anche separare due idee. In SwiftUI, un “re-render” spesso significa che il body di una vista viene ricalcolato. Quella parte è di solito economica. Il lavoro costoso è ciò che il ricalcolo innesca: layout pesante, decodifica immagini, misurazione del testo o ricostruzione di molte righe perché SwiftUI pensa che la loro identità sia cambiata.
Immagina una chat con 2.000 messaggi. Arrivano nuovi messaggi ogni secondo e ogni riga formatta timestamp, misura testo multilinea e carica avatar. Anche se aggiungi solo un elemento, un cambiamento di stato con ambito troppo ampio può far riesaminare molte righe e alcune di esse ridisegnare.
L'obiettivo non è la micro-ottimizzazione. Vuoi scorrimento fluido, tap istantanei e aggiornamenti che toccano solo le righe effettivamente cambiate. Le correzioni qui sotto si concentrano su identità stabile, righe più leggere, meno aggiornamenti non necessari e caricamenti controllati.
Le cause principali: identità, lavoro per riga e tempeste di aggiornamenti
Quando una lista SwiftUI sembra lenta, raramente è per “troppi elementi”. È lavoro extra che avviene mentre scorri: ricostruire righe, ricalcolare layout o ricaricare immagini ripetutamente.
La maggior parte delle cause rientra in tre categorie:
- Identità instabile: le righe non hanno un
idcoerente, o usi\.selfper valori che possono cambiare. SwiftUI non riesce ad abbinare le vecchie righe con quelle nuove, quindi ricostruisce più del necessario. - Troppo lavoro per riga: formattazione date, filtri, ridimensionamento immagini o lavoro di rete/disk nella vista di riga.
- Tempeste di aggiornamenti: un cambiamento (digitazione, tick di un timer, aggiornamento di progresso) scatena aggiornamenti di stato frequenti e la lista si rinfresca ripetutamente.
Esempio: hai 2.000 ordini. Ogni riga formatta la valuta, costruisce una stringa attribuita e avvia un fetch immagine. Nel frattempo, un timer "ultimo sync" aggiorna una volta al secondo nella vista padre. Anche se i dati degli ordini non cambiano, quel timer può invalidare la lista abbastanza spesso da rendere lo scorrimento scattoso.
Perché List e LazyVStack possono comportarsi diversamente
List è più di una scroll view. È pensata attorno al comportamento di tabelle/collection e ottimizzazioni di sistema. Spesso gestisce grandi dataset con meno memoria, ma può essere sensibile all'identità e agli aggiornamenti frequenti.
ScrollView + LazyVStack ti dà più controllo sul layout e l'aspetto, ma è anche più facile fare involontariamente lavoro di layout extra o scatenare aggiornamenti costosi. Su dispositivi vecchi, quel lavoro in più si vede prima.
Prima di riscrivere l'interfaccia, misura. Correzioni piccole come ID stabili, spostare il lavoro fuori dalle righe e ridurre il churn di stato spesso risolvono il problema senza cambiare il contenitore.
Correggi l'identità delle righe così SwiftUI può fare diff efficiente
Quando una lista lunga è scattosa, spesso la colpa è l'identità. SwiftUI decide quali righe riusare confrontando gli ID. Se quegli ID cambiano, SwiftUI tratta le righe come nuove, scarta le vecchie e ricostruisce più del necessario. Questo può apparire come re-render casuali, perdita di posizione nello scroll o animazioni che partono senza motivo.
La vittoria più semplice: fai in modo che l'id di ogni riga sia stabile e legato alla tua fonte dati.
Un errore comune è generare l'identità dentro la vista:
ForEach(items) { item in
Row(item: item)
.id(UUID())
}
Questo forza un nuovo ID a ogni render, quindi ogni riga diventa “diversa” ogni volta.
Preferisci ID già presenti nel tuo modello, come una chiave primaria del database, un ID del server o uno slug stabile. Se non ne hai uno, crealo una sola volta quando crei il modello — non dentro la vista.
struct Item: Identifiable {
let id: Int
let title: String
}
List(items) { item in
Row(item: item)
}
Fai attenzione agli indici. ForEach(items.indices, id: \.self) lega l'identità alla posizione. Se inserisci, cancelli o riordini, le righe “si muovono” e SwiftUI può riusare la vista sbagliata per i dati sbagliati. Usa gli indici solo per array veramente statici.
Se usi id: \.self, assicurati che il valore Hashable sia stabile nel tempo. Se l'hash cambia quando un campo viene aggiornato, anche l'identità della riga cambia. Una regola sicura per Equatable e Hashable: basali su un singolo ID stabile, non su proprietà modificabili come name o isSelected.
Controlli di buon senso:
- Gli ID provengono dalla fonte dati (non da
UUID()nella vista) - Gli ID non cambiano quando il contenuto della riga cambia
- L'identità non dipende dalla posizione dell'array a meno che la lista non venga mai riordinata
Riduci i re-render rendendo le righe più leggere
Una lista lunga spesso sembra lenta perché ogni riga fa troppo lavoro ogni volta che SwiftUI ricalcola il suo body. L'obiettivo è semplice: rendere ogni riga economica da ricostruire.
Un costo nascosto comune è passare valori “grandi” nella riga. Struct pesanti, modelli profondamente annidati o proprietà calcolate costose possono scatenare lavoro extra anche quando l'interfaccia sembra immutata. Potresti ricostruire stringhe, parsare date, ridimensionare immagini o produrre alberi di layout complessi più spesso di quanto immagini.
Sposta il lavoro pesante fuori dal body
Se qualcosa è lento, non ricostruirlo dentro il body della riga più e più volte. Precomputalo quando arrivano i dati, cachalo nel view model o memoizzalo in un piccolo helper.
Costi a livello di riga che si sommano in fretta:
- Creare un nuovo
DateFormatteroNumberFormatterper ogni riga - Formattazione pesante di stringhe in
body(join, regex, parsing markdown) - Costruire array derivati con
.mapo.filterdentrobody - Leggere blob grandi e convertirli (es. decodifica JSON) nella vista
- Layout troppo complesso con molte VStack/HStack annidate e conditionals
Un esempio semplice: tieni i formatter statici e passa stringhe già formattate nella riga.
enum Formatters {
static let shortDate: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .none
return f
}()
}
struct OrderRow: View {
let title: String
let dateText: String
var body: some View {
HStack {
Text(title)
Spacer()
Text(dateText).foregroundStyle(.secondary)
}
}
}
Spezza le righe e usa Equatable quando conviene
Se cambia solo una piccola parte (come un badge), isolala in una sotto-vista così il resto della riga resta stabile.
Per UI guidate da valori, rendere una sotto-vista Equatable (o avvolgerla con EquatableView) può aiutare SwiftUI a saltare lavoro quando gli input non sono cambiati. Mantieni gli input equatabili piccoli e specifici — non l'intero modello.
Controlla gli aggiornamenti di stato che innescano il refresh dell'intera lista
A volte le righe vanno bene, ma qualcosa continua a dire a SwiftUI di rinfrescare l'intera lista. Durante lo scorrimento, anche piccoli aggiornamenti extra possono trasformarsi in scatti, specialmente su dispositivi vecchi.
Una causa comune è ricreare il modello troppo spesso. Se una vista padre viene ricostruita e hai usato @ObservedObject per un view model che la vista possiede, SwiftUI può ricrearlo, resettare le subscription e scatenare nuove pubblicazioni. Se la vista possiede il modello, usa @StateObject in modo che venga creato una sola volta e resti stabile. Usa @ObservedObject per oggetti iniettati da fuori.
Un altro killer silenzioso sono le publish troppo frequenti. Timer, pipeline Combine e aggiornamenti di progresso possono sparare molte volte al secondo. Se una proprietà pubblicata influenza la lista (o risiede in un ObservableObject condiviso dallo schermo), ogni tick può invalidare la lista.
Esempio: hai un campo di ricerca che aggiorna query a ogni battuta e filtra 5.000 elementi. Se filtri immediatamente, la lista si ridiffa costantemente mentre l'utente digita. Debounce per la query e aggiorna l'array filtrato dopo una breve pausa.
Pattern che generalmente aiutano:
- Tieni valori che cambiano rapidamente fuori dall'oggetto che guida la lista (usa oggetti più piccoli o
@Statelocale) - Debounce per ricerca e filtraggio in modo che la lista si aggiorni dopo una pausa di digitazione
- Evita publish ad alta frequenza; aggiorna meno spesso o solo quando il valore è effettivamente cambiato
- Mantieni lo stato per riga locale (es.
@Statenella riga) invece che un valore globale che cambia costantemente - Separa i modelli grandi: un
ObservableObjectper i dati della lista, un altro per lo stato UI a livello di schermo
L'idea è semplice: rendi il tempo di scorrimento tranquillo. Se nulla di importante è cambiato, la lista non dovrebbe essere chiamata a fare lavoro.
Scegli il contenitore giusto: List vs LazyVStack
Il contenitore che scegli influisce su quanto lavoro fa iOS per te.
List è in genere la scelta più sicura quando la tua UI somiglia a una tabella standard: righe con testo, immagini, azioni swipe, selezione, separatori, edit mode e accessibilità. Sotto il cofano beneficia di ottimizzazioni di piattaforma che Apple ha raffinato per anni.
Una ScrollView con LazyVStack è ottima quando ti servono layout personalizzati: card, blocchi di contenuto misto, header speciali o un feed stile social. “Lazy” significa che costruisce le righe quando entrano in vista, ma non offre lo stesso comportamento di List in ogni caso. Con dataset molto grandi questo può comportare un maggiore uso di memoria e scorrimento più scattoso su dispositivi datati.
Una regola semplice:
- Usa
Listper schermate tabellari classiche: impostazioni, inbox, ordini, liste admin - Usa
ScrollView+LazyVStackper layout personalizzati e contenuti misti - Se hai migliaia di elementi e ti serve solo una tabella, parti con
List - Se ti serve controllo pixel-perfect, prova
LazyVStacke poi misura memoria e frame drops
Fai attenzione anche a stili che rallentano silenziosamente lo scorrimento. Effetti per riga come shadow, blur e overlay complessi possono forzare lavoro di rendering extra. Se vuoi profondità, applica effetti pesanti a elementi piccoli (es. un'icona) invece che all'intera riga.
Esempio concreto: una schermata “Ordini” con 5.000 righe spesso resta fluida in List perché le righe vengono riutilizzate. Se passi a LazyVStack e costruisci righe in stile card con grandi ombre e overlay multipli, potresti vedere jank anche se il codice sembra pulito.
Paginazione che scorre bene ed evita picchi di memoria
La paginazione mantiene le liste lunghe veloci perché renderizzi meno righe, tieni meno modelli in memoria e dai a SwiftUI meno lavoro di diff.
Inizia con un contratto di paging chiaro: una dimensione pagina fissa (ad esempio 30-60 elementi), un flag “nessun altro risultato” e una riga di caricamento che appare solo mentre fetchi.
Una trappola comune è avviare la pagina successiva solo quando appare l'ultima riga. Spesso è troppo tardi, quindi l'utente arriva alla fine e vede una pausa. Invece, avvia il caricamento quando appare una delle ultime righe.
Pattern semplice:
@State private var items: [Item] = []
@State private var isLoading = false
@State private var reachedEnd = false
func loadNextPageIfNeeded(currentIndex: Int) {
guard !isLoading, !reachedEnd else { return }
let threshold = max(items.count - 5, 0)
guard currentIndex >= threshold else { return }
isLoading = true
Task {
let page = try await api.fetchPage(after: items.last?.id)
await MainActor.run {
let newUnique = page.filter { p in !items.contains(where: { $0.id == p.id }) }
items.append(contentsOf: newUnique)
reachedEnd = page.isEmpty
isLoading = false
}
}
}
Questo evita problemi comuni come righe duplicate (risultati API sovrapposti), condizioni di race da molteplici chiamate onAppear, e caricamento eccessivo.
Se la lista supporta pull-to-refresh, resettare lo stato di paging con cura (svuota items, resetta reachedEnd, cancella task in corso se possibile). Se controlli il backend, ID stabili e paginazione basata su cursor rendono l'interfaccia notevolmente più fluida.
Immagini, testo e layout: mantieni leggero il rendering delle righe
Le liste lunghe raramente sono lente a causa del contenitore. La maggior parte delle volte il problema è la riga. Le immagini sono il colpevole più comune: decodifica, ridimensionamento e disegno possono non tenere il passo con la velocità di scorrimento, soprattutto su dispositivi più vecchi.
Se carichi immagini remote, assicurati che il lavoro pesante non avvenga sul main thread durante lo scroll. Evita anche di scaricare asset in piena risoluzione per una miniatura da 44–80 pt.
Esempio: una schermata “Messaggi” con avatar. Se ogni riga scarica un'immagine 2000x2000, la ridimensiona e applica blur o shadow, la lista andrà a scatti anche se il modello dati è semplice.
Mantieni prevedibile il lavoro sulle immagini
Abitudini ad alto impatto:
- Usa thumbnail generate server-side o pre-generate vicine alla dimensione mostrata
- Decodifica e ridimensiona off the main thread quando possibile
- Cache delle miniature così lo scroll veloce non ri-fetch o ri-decode
- Usa un placeholder che abbia la stessa dimensione finale per evitare flicker e salti di layout
- Evita modificatori costosi sulle immagini nelle righe (ombre pesanti, maschere, blur)
Stabilizza il layout per evitare thrash
SwiftUI può passare più tempo a misurare che a disegnare se l'altezza della riga continua a cambiare. Cerca di mantenere le righe prevedibili: frame fissi per le miniature, limiti di linee coerenti e spaziature stabili. Se il testo può espandersi, limitane le righe (ad es. 1–2) così un singolo aggiornamento non forza misurazioni extra.
I placeholder contano anche qui. Un cerchio grigio che diventa avatar dopo dovrebbe occupare lo stesso frame, così la riga non riformatta a metà scroll.
Come misurare: controlli con Instruments che rivelano i veri colli di bottiglia
Lavorare sulle prestazioni è azzardoso se ti basi solo sul “sento che va a scatti”. Instruments ti dice cosa gira sul main thread, cosa viene allocato durante lo scroll veloce e cosa causa frame persi.
Definisci una baseline su un dispositivo reale (meglio ancora uno più vecchio se lo supporti). Fai un'azione ripetibile: apri la schermata, scorri velocemente dall'inizio alla fine, attiva il caricamento di una pagina, poi scorri indietro. Nota i punti di maggior hitch, il picco di memoria e se l'UI resta responsiva.
Le tre viste di Instruments che pagano
Usale insieme:
- Time Profiler: cerca i picchi sul main thread mentre scorri. Layout, misurazione testo, parsing JSON e decodifica immagine qui spesso spiegano il problema.
- Allocations: osserva le ondate di oggetti temporanei durante lo scroll veloce. Spesso indica formatting ripetuto, nuove attributed string o ricostruzione di modelli per riga.
- Core Animation: conferma i frame persi e i lunghi tempi di frame. Questo aiuta a separare pressione sul rendering da lavoro sui dati.
Quando trovi un picco, clicca nell'albero delle chiamate e chiediti: succede una volta per schermata, o una volta per riga, per ogni scroll? La seconda è quella che rompe lo scorrimento fluido.
Aggiungi signpost per eventi di scroll e paginazione
Molte app fanno lavoro extra durante lo scroll (caricamenti immagini, paginazione, filtraggio). I signpost ti aiutano a vedere quei momenti sulla timeline.
import os
let log = OSLog(subsystem: "com.yourapp", category: "list")
os_signpost(.begin, log: log, name: "LoadMore")
// fetch next page
os_signpost(.end, log: log, name: "LoadMore")
Ritest dopo ogni modifica, una alla volta. Se FPS migliora ma Allocations peggiora, potresti aver scambiato stutter per pressione di memoria. Tieni appunti di baseline e conserva solo le modifiche che muovono i numeri nella direzione giusta.
Errori comuni che uccidono silenziosamente le prestazioni delle liste
Alcuni problemi sono ovvi (immagini grandi, dataset enormi). Altri emergono solo quando i dati crescono, specialmente su dispositivi datati.
1) ID righe instabili
Un errore classico è creare ID dentro la vista, come id: \.self per tipi reference o UUID() nel corpo della riga. SwiftUI usa l'identità per fare il diff. Se l'ID cambia, SwiftUI tratta la riga come nuova, la ricostruisce e può scartare il layout cache.
Usa un ID stabile dal tuo modello (chiave primaria DB, ID server o una UUID memorizzata creata una sola volta quando l'item viene creato). Se non ce l'hai, aggiungilo.
2) Lavoro pesante dentro onAppear
onAppear viene eseguito più spesso di quanto si pensi perché le righe vanno e vengono mentre scorri. Se ogni riga avvia decodifica immagini, parsing JSON o lookup DB in onAppear, otterrai picchi ripetuti.
Sposta il lavoro pesante fuori dalla riga. Precomputa quando carichi i dati, metti in cache i risultati e tieni onAppear limitato ad azioni leggere (come triggerare la paginazione quando sei vicino alla fine).
3) Binding dell'intera lista per modifiche alle righe
Quando ogni riga riceve un @Binding su un grande array, una piccola modifica può sembrare un grande cambiamento. Questo può causare la riesecuzione di molte righe e talvolta il refresh dell'intera lista.
Preferisci passare valori immutabili alla riga e inviare i cambi con una action leggera (es. “toggle favorite per id”). Mantieni lo stato per riga locale solo quando gli appartiene davvero.
4) Troppe animazioni durante lo scroll
Le animazioni sono costose in una lista perché possono scatenare ulteriori passaggi di layout. Applicare animation(.default, value:) in alto (sull'intera lista) o animare ogni piccolo stato cambia può rendere lo scorrimento appiccicoso.
Regole semplici:
- Limita le animazioni alla singola riga che cambia
- Evita di animare durante lo scroll veloce (soprattutto per selezione/highlight)
- Stai attento con le animazioni implicite su valori che cambiano frequentemente
- Preferisci transizioni semplici invece di effetti combinati complessi
Un esempio reale: una lista in stile chat dove ogni riga avvia un fetch in onAppear, usa UUID() per id e anima i cambi di stato “letto”. Quella combinazione crea churn costante. Sistemare l'identità, fare caching del lavoro e limitare le animazioni spesso rende la stessa UI istantaneamente più fluida.
Checklist rapida, un esempio pratico e i passi successivi
Se puoi fare solo poche cose, inizia da qui:
- Usa un
idunico e stabile per ogni riga (non l'indice, non una UUID appena generata) - Mantieni il lavoro della riga piccolo: evita formattazioni pesanti, grandi alberi di vista e proprietà calcolate costose in
body - Controlla le pubblicazioni: non lasciare che stato che cambia rapidamente (timer, digitazione, progresso) invalida l'intera lista
- Carica a pagine e prefetch in modo che la memoria resti piatta
- Misura prima e dopo con Instruments così non stai indovinando
Immagina una inbox di supporto con 20.000 conversazioni. Ogni riga mostra un soggetto, anteprima dell'ultimo messaggio, timestamp, badge non letto e un avatar. Gli utenti possono cercare e arrivano nuovi messaggi mentre scorrono. La versione lenta solitamente fa alcune cose insieme: ricostruisce righe a ogni battuta, rimesura testo troppo spesso e scarica troppe immagini troppo presto.
Un piano pratico che non richiede di buttare via il codebase:
- Baseline: registra uno scroll breve e una sessione di ricerca in Instruments (Time Profiler + Core Animation).
- Sistema l'identità: assicurati che il modello abbia un vero id dal server/database e
ForEachlo usi coerentemente. - Aggiungi paginazione: parti con i più recenti 50–100 elementi, poi carica di più quando l'utente si avvicina alla fine.
- Ottimizza le immagini: usa thumbnail più piccoli, metti in cache i risultati e evita la decodifica sul main thread.
- Riesegui le misure: conferma meno passaggi di layout, meno aggiornamenti di vista e tempi di frame più stabili su dispositivi più vecchi.
Se stai costruendo un prodotto completo (app iOS più backend e pannello admin web), aiuta anche progettare il modello dati e il contratto di paging fin da subito. Piattaforme come AppMaster (appmaster.io) sono pensate per quel workflow full-stack: puoi definire dati e logica di business in modo visuale e comunque generare codice sorgente reale che puoi distribuire o ospitare autonomamente.
FAQ
Inizia correggendo l'identità delle righe. Usa un id stabile preso dal modello e evita di generare ID nella vista, perché cambiare gli ID costringe SwiftUI a trattare le righe come nuove e a ricostruirne molte più del necessario.
Un ricalcolo di body è di solito economico; la parte costosa è ciò che quel ricalcolo innesca. Layout pesante, misurazione del testo, decodifica delle immagini e la ricostruzione di molte righe a causa di identità instabile sono ciò che tipicamente causa i cali di frame.
Non usare UUID() dentro la riga né fare affidamento sugli indici degli array se i dati possono essere inseriti, cancellati o riordinati. Preferisci un ID del server/database o una UUID memorizzata sul modello al momento della sua creazione, così l'ID resta identico negli aggiornamenti.
Può peggiorare le prestazioni, specialmente se l'hash del valore cambia quando campi modificabili vengono aggiornati: SwiftUI può considerarlo come una riga diversa. Se usi Hashable, basalo su un singolo identificatore stabile invece che su proprietà come name, isSelected o testo derivato.
Sposta il lavoro pesante fuori da body. Preformatta date e numeri, evita di creare nuovi formatter per ogni riga, e non costruire grandi array derivati con map/filter dentro la vista; calcola una volta nel modello o nel view model e passa valori piccoli e pronti per la visualizzazione alla riga.
onAppear viene chiamato più spesso di quanto si pensi perché le righe entrano ed escono dallo schermo durante lo scorrimento. Se ogni riga avvia lavoro pesante lì (decodifica immagini, letture database, parsing), otterrai picchi ripetuti; limita onAppear ad azioni leggere come richiamare la paginazione vicino alla fine.
Qualsiasi valore pubblicato ad alta frequenza condiviso con la lista può invalidarla ripetutamente, anche se i dati delle righe non sono cambiati. Tieni timer, stato di digitazione e aggiornamenti di progresso fuori dall'oggetto principale che guida la lista, applica debounce alla ricerca, e suddividi grandi ObservableObject in oggetti più piccoli quando serve.
Usa List quando l'interfaccia è in stile tabella (righe standard, azioni swipe, selezione, separatori) e vuoi le ottimizzazioni di sistema. Usa ScrollView + LazyVStack per layout personalizzati, ma misura memoria e cali di frame: è più facile lì fare lavoro di layout extra che peggiora lo scorrimento.
Inizia prima dell'ultima riga vera e propria: avvia il caricamento quando l'utente raggiunge una soglia vicino alla fine e proteggi da trigger duplicati. Mantieni le dimensioni delle pagine ragionevoli, traccia isLoading e reachedEnd, e deduplica i risultati tramite ID stabili per evitare righe duplicate e diff inutili.
Fai una baseline su un dispositivo reale e usa Instruments per trovare picchi sul main thread e ondate di allocazioni durante lo scroll veloce. Time Profiler mostra cosa blocca lo scorrimento, Allocations rivela churn per riga e Core Animation conferma i frame persi, così sai se il collo di bottiglia è nel rendering o nel lavoro sui dati.


