Ajuste de desempenho SwiftUI para listas longas: soluções práticas
Ajustes de desempenho SwiftUI para listas longas: soluções práticas para re-renders, identidade estável de linhas, paginação, carregamento de imagens e rolagem suave em iPhones mais antigos.

Como “listas lentas” aparecem em apps SwiftUI reais
Uma “lista lenta” no SwiftUI geralmente não é um bug. É o momento em que sua interface não acompanha o movimento do dedo. Você percebe isso ao rolar: a lista hesita, frames são perdidos e tudo fica pesado.
Sinais típicos:
- Rolagem engasga, especialmente em aparelhos mais antigos
- Linhas piscam ou mostram conteúdo errado por um momento
- Toques parecem atrasados, ou ações de swipe começam tarde
- O telefone esquenta e a bateria acaba mais rápido do que o esperado
- Uso de memória cresce quanto mais você rola
Listas longas podem parecer lentas mesmo quando cada linha parece “pequena”, porque o custo não é só desenhar pixels. O SwiftUI ainda precisa descobrir qual é cada linha, calcular layout, resolver fontes e imagens, rodar seu código de formatação e diffar atualizações quando os dados mudam. Se qualquer um desses trabalhos acontecer com muita frequência, a lista vira um ponto problemático.
Também ajuda separar duas ideias. No SwiftUI, um “re-render” muitas vezes significa que o body de uma view é recalculado. Essa parte normalmente é barata. O trabalho caro é o que a recomputação aciona: layout pesado, decodificação de imagem, medição de texto ou reconstruir muitas linhas porque o SwiftUI acha que a identidade mudou.
Imagine um chat com 2.000 mensagens. Novas mensagens chegam a cada segundo e cada linha formata timestamps, mede texto multilinha e carrega avatares. Mesmo se você só adicionar um item, uma mudança de estado mal localizada pode fazer muitas linhas reavaliarem e algumas delas redesenharem.
O objetivo não é micro-otimização. Você quer rolagem suave, toques instantâneos e atualizações que afetem apenas as linhas que realmente mudaram. As correções abaixo focam em identidade estável, linhas mais simples, menos atualizações desnecessárias e carregamento controlado.
As causas principais: identidade, trabalho por linha e tempestades de atualização
Quando uma lista SwiftUI parece lenta, raramente é por ter “muitas linhas”. É trabalho extra acontecendo enquanto você rola: reconstruir linhas, recalcular layout ou recarregar imagens repetidamente.
A maioria das causas se encaixa em três grupos:
- Identidade instável: linhas não têm um
idconsistente, ou você usa\\.selfpara valores que podem mudar. O SwiftUI não consegue casar linhas antigas com novas, então reconstrói mais do que o necessário. - Trabalho demais por linha: formatação de data, filtragem, redimensionamento de imagens ou trabalho de rede/disco dentro da view da linha.
- Tempestades de atualização: uma mudança (digitação, um timer, progresso) dispara atualizações frequentes de estado e a lista refresca repetidamente.
Exemplo: você tem 2.000 pedidos. Cada linha formata moeda, monta um attributed string e inicia um fetch de imagem. Enquanto isso, um timer de “último sincronizado” atualiza uma vez por segundo na view pai. Mesmo que os dados do pedido não mudem, esse timer pode invalidar a lista o suficiente para deixar a rolagem travada.
Por que List e LazyVStack podem se comportar diferente
List é mais do que uma scroll view. Foi projetada em torno do comportamento de tabela/coleção e otimizações do sistema. Muitas vezes lida com grandes conjuntos de dados com menos memória, mas pode ser sensível à identidade e a atualizações frequentes.
ScrollView + LazyVStack dá mais controle sobre layout e visuais, mas também é mais fácil fazer trabalho de layout extra acidentalmente ou disparar atualizações caras. Em aparelhos antigos, esse trabalho extra aparece mais rápido.
Antes de reescrever sua UI, meça primeiro. Pequenas correções como IDs estáveis, tirar trabalho das linhas e reduzir churn de estado frequentemente resolvem o problema sem trocar o container.
Corrija a identidade das linhas para o SwiftUI diffar de forma eficiente
Quando uma lista longa fica travada, a identidade costuma ser a culpada. O SwiftUI decide quais linhas podem ser reutilizadas comparando IDs. Se esses IDs mudam, o SwiftUI trata as linhas como novas, descarta as antigas e reconstrói mais do que precisa. Isso pode parecer re-renders aleatórios, perda da posição de rolagem ou animações acontecendo sem motivo.
O ganho mais simples: faça o id de cada linha estável e ligado à sua fonte de dados.
Um erro comum é gerar identidade dentro da view:
ForEach(items) { item in
Row(item: item)
.id(UUID())
}
Isso força um novo ID a cada render, então cada linha fica “diferente” a cada vez.
Prefira IDs que já existem no seu modelo, como a chave primária do banco, um ID do servidor ou um slug estável. Se você não tem um, crie-o uma vez quando o modelo for criado — não dentro da view.
struct Item: Identifiable {
let id: Int
let title: String
}
List(items) { item in
Row(item: item)
}
Cuidado com índices. ForEach(items.indices, id: \\.self) liga identidade à posição. Se você inserir, apagar ou ordenar, linhas “se movem” e o SwiftUI pode reutilizar a view errada para os dados errados. Use índices apenas para arrays verdadeiramente estáticos.
Se usar id: \\.self, assegure que o valor Hashable do elemento seja estável ao longo do tempo. Se o hash muda quando um campo é atualizado, a identidade da linha também muda. Uma regra segura para Equatable e Hashable: baseie-os em um único ID estável, não em propriedades editáveis como name ou isSelected.
Verificações práticas:
- IDs vêm da fonte de dados (não de
UUID()na view) - IDs não mudam quando o conteúdo da linha muda
- Identidade não depende da posição no array, a menos que a lista nunca seja reordenada
Reduza re-renders tornando as views de linha mais leves
Uma lista longa muitas vezes parece lenta porque cada linha faz trabalho demais toda vez que o SwiftUI reavalia seu body. O alvo é simples: torne cada linha barata de reconstruir.
Um custo oculto comum é passar valores “grandes” para a linha. Structs grandes, modelos profundamente aninhados ou propriedades computadas pesadas podem disparar trabalho extra mesmo quando a UI parece inalterada. Você pode estar reconstruindo strings, parseando datas, redimensionando imagens ou produzindo árvores de layout complexas com mais frequência do que imagina.
Tire trabalho caro do body
Se algo é lento, não o reconstrua dentro do body da linha repetidamente. Pré-compute quando os dados chegarem, armazene em cache no seu view model ou memoize em um pequeno helper.
Custos por linha que somam rápido:
- Criar um
DateFormatterouNumberFormatternovo por linha - Formatação de string pesada em
body(joins, regex, parsing markdown) - Construir arrays derivados com
.mapou.filterdentro dobody - Ler blobs grandes e convertê-los (como decodificar JSON) na view
- Layout excessivamente complexo com muitas
Stacks aninhadas e condicionais
Um exemplo simples: mantenha formatters estáticos e passe strings pré-formatadas para a linha.
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)
}
}
}
Divida as linhas e use Equatable quando fizer sentido
Se apenas uma pequena parte muda (como um contador), isole-a em uma subview para que o resto da linha permaneça estável.
Para UI realmente dirigida por valores, tornar uma subview Equatable (ou envolvê-la com EquatableView) pode ajudar o SwiftUI a pular trabalho quando as entradas não mudaram. Mantenha as entradas equatáveis pequenas e específicas — não o modelo inteiro.
Controle atualizações de estado que disparam refreshs na lista inteira
Às vezes as linhas estão boas, mas algo continua dizendo ao SwiftUI para atualizar toda a lista. Durante a rolagem, até pequenas atualizações extras podem virar engasgos, especialmente em dispositivos antigos.
Uma causa comum é recriar seu modelo com muita frequência. Se uma view pai reconstrói e você usou @ObservedObject para um view model que a view possui, o SwiftUI pode recriá-lo, resetar subscriptions e disparar publishes frescos. Se a view é dona do modelo, use @StateObject para que ele seja criado uma vez e permaneça estável. Use @ObservedObject para objetos injetados de fora.
Outro assassino silencioso de performance é publicar com muita frequência. Timers, pipelines do Combine e updates de progresso podem disparar muitas vezes por segundo. Se uma propriedade publicada afeta a lista (ou fica em um ObservableObject compartilhado usado pela tela), cada tick pode invalidar a lista.
Exemplo: você tem um campo de busca que atualiza query a cada tecla e então filtra 5.000 itens. Se filtrar imediatamente, a lista se re-diffa constantemente enquanto o usuário digita. Faça debounce no query e atualize o array filtrado após uma pequena pausa.
Padrões que costumam ajudar:
- Mantenha valores de mudança rápida fora do objeto que dirige a lista (use objetos menores ou
@Statelocal) - Faça debounce em busca e filtragem para que a lista atualize após pausas na digitação
- Evite publishes de timer com alta frequência; atualize menos ou só quando um valor realmente mudar
- Mantenha estado por linha local (como
@Statena linha) em vez de um valor global que muda constantemente - Divida modelos grandes: um
ObservableObjectpara dados da lista e outro para estado da tela
A ideia é simples: mantenha o tempo de rolagem silencioso. Se nada importante mudou, a lista não deveria ser solicitada a fazer trabalho.
Escolha o container certo: List vs LazyVStack
O container que você escolhe afeta quanto trabalho o iOS faz por você.
List é geralmente a escolha mais segura quando sua UI parece uma tabela padrão: linhas com texto, imagens, ações de swipe, seleção, separadores, modo de edição e acessibilidade. Por baixo dos panos, ela se beneficia de otimizações de plataforma que a Apple aperfeiçoou por anos.
Um ScrollView com LazyVStack é ótimo quando você precisa de layout customizado: cards, blocos de conteúdo misto, headers especiais ou um feed. “Lazy” significa que constrói linhas conforme elas aparecem na tela, mas não entrega o mesmo comportamento do List em todos os casos. Com datasets muito grandes, isso pode significar maior uso de memória e rolagem mais travada em dispositivos antigos.
Uma regra simples:
- Use
Listpara telas tipo tabela: configurações, inbox, pedidos, listas administrativas - Use
ScrollView+LazyVStackpara layouts customizados e conteúdo misto - Se tiver milhares de itens e só precisar de uma tabela, comece com
List - Se precisar de controle pixel-perfect, tente
LazyVStacke depois meça memória e quedas de frame
Também tome cuidado com estilos que silenciosamente atrasam a rolagem. Efeitos por linha como shadow, blur e overlays complexos podem forçar trabalho extra de renderização. Se quiser profundidade, aplique efeitos pesados a pequenos elementos (como um ícone) em vez de à linha inteira.
Exemplo concreto: uma tela “Orders” com 5.000 linhas costuma permanecer suave em List porque as linhas são reutilizadas. Se você mudar para LazyVStack e construir rows em estilo card com sombras grandes e múltiplos overlays, pode ver engasgos mesmo que o código pareça limpo.
Paginação que parece suave e evita picos de memória
Paginação mantém listas longas rápidas porque você renderiza menos linhas, mantém menos modelos em memória e dá menos trabalho de diffing ao SwiftUI.
Comece com um contrato de paginação claro: tamanho de página fixo (por exemplo 30 a 60 itens), uma flag de “sem mais resultados” e uma linha de carregamento que aparece só enquanto você busca.
Uma armadilha comum é disparar a próxima página só quando a última linha aparece. Isso costuma ser tarde demais, então o usuário atinge o fim e vê uma pausa. Em vez disso, comece a carregar quando uma das últimas linhas aparecer.
Aqui vai um padrão simples:
@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
}
}
}
Isso evita problemas comuns como linhas duplicadas (resultados sobrepostos da API), condições de corrida por múltiplos onAppear e carregar demais de uma vez.
Se sua lista suporta pull to refresh, reinicie o estado de paginação com cuidado (limpe items, resete reachedEnd, cancele tasks em voo se possível). Se você controla o backend, IDs estáveis e paginação baseada em cursor tornam a UI perceptivelmente mais suave.
Imagens, texto e layout: mantenha o render da linha leve
Listas longas raramente ficam lentas por causa do container. Na maioria das vezes, é a linha. Imagens são o vilão usual: decodificação, redimensionamento e desenho podem ficar atrás da velocidade de rolagem, especialmente em dispositivos antigos.
Se você carrega imagens remotas, garanta que trabalho pesado não aconteça na main thread durante a rolagem. Também evite baixar ativos em resolução total para uma miniatura de 44–80 pt.
Exemplo: uma tela “Messages” com avatares. Se cada linha baixa uma imagem 2000x2000, escala para baixo e aplica blur ou shadow, a lista vai travar mesmo que seu modelo de dados seja simples.
Mantenha o trabalho com imagens previsível
Hábitos de alto impacto:
- Use thumbnails gerados no servidor ou pré-gerados com tamanho próximo ao exibido
- Decodifique e redimensione fora da main thread quando possível
- Faça cache das thumbnails para que a rolagem rápida não re-baixe ou re-decode
- Use um placeholder com o mesmo tamanho final para evitar flicker e saltos de layout
- Evite modificadores caros nas imagens nas linhas (sombras pesadas, masks, blur)
Estabilize o layout para evitar thrash
O SwiftUI pode gastar mais tempo medindo do que desenhando se a altura da linha ficar mudando. Tente manter linhas previsíveis: frames fixos para thumbnails, limites consistentes de linhas e espaçamento estável. Se o texto pode expandir, limite-o (por exemplo, 1 a 2 linhas) para que uma única atualização não force muito trabalho de medição.
Placeholders importam também. Um círculo cinza que depois vira avatar deve ocupar o mesmo espaço, para que a linha não reflow durante a rolagem.
Como medir: cheques no Instruments que revelam gargalos reais
Trabalhar performance no achismo é arriscado. Instruments diz o que roda na thread principal, o que é alocado durante a rolagem rápida e o que causa frames perdidos.
Defina uma linha de base em um dispositivo real (um mais antigo se você der suporte). Faça uma ação repetível: abra a tela, role do topo ao fim rápido, dispare o load-more uma vez e depois role de volta. Observe os piores pontos de hitch, pico de memória e se a UI fica responsiva.
As três views do Instruments que valem ouro
Use-as em conjunto:
- Time Profiler: procure por picos na main thread enquanto rola. Layout, medição de texto, parsing JSON e decodificação de imagem aqui costumam explicar o engasgo.
- Allocations: observe surtos de objetos temporários durante rolagem rápida. Isso aponta para formatações repetidas, novos attributed strings ou reconstrução de modelos por linha.
- Core Animation: confirme frames perdidos e tempos longos de frame. Isso ajuda a separar pressão de renderização de trabalho de dados lento.
Quando achar um pico, clique na call tree e pergunte: isso acontece uma vez por tela ou por linha, por rolagem? A segunda opção é o que quebra a rolagem suave.
Adicione signposts para eventos de rolagem e paginação
Muitos apps fazem trabalho extra durante a rolagem (carregam imagens, paginação, filtragem). Signposts ajudam a ver esses momentos na 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")
Reteste após cada mudança, uma por vez. Se o FPS melhorar mas Allocations piorar, você pode ter trocado travamento por pressão de memória. Mantenha notas da baseline e só mantenha mudanças que movam os números na direção certa.
Erros comuns que matam a performance das listas silenciosamente
Alguns problemas são óbvios (imagens grandes, datasets enormes). Outros só aparecem quando os dados crescem, especialmente em dispositivos antigos.
1) IDs de linha instáveis
Um erro clássico é criar IDs dentro da view, como id: \\.self para reference types ou UUID() no corpo da linha. O SwiftUI usa identidade para diffar atualizações. Se o ID muda, o SwiftUI trata a linha como nova, reconstrói e pode descartar layout em cache.
Use um ID estável do seu modelo (chave primária do banco, ID do servidor, ou um UUID armazenado criado uma vez quando o item é criado). Se não tiver, adicione um.
2) Trabalho pesado dentro de onAppear
onAppear roda mais vezes do que se pensa porque linhas entram e saem da tela enquanto você rola. Se cada linha iniciar decodificação de imagem, parsing JSON ou leitura de banco em onAppear, você terá picos repetidos.
Tire trabalho pesado da linha. Pré-compute quando possível quando os dados chegam, cacheie resultados e mantenha onAppear limitado a ações baratas (como disparar paginação quando estiver perto do fim).
3) Fazer bind da lista inteira a edições de linha
Quando cada linha recebe um @Binding para um array grande, uma pequena edição pode parecer uma grande mudança. Isso pode fazer muitas linhas re-avaliarem e às vezes toda a lista refrescar.
Prefira passar valores imutáveis para a linha e enviar mudanças de volta com uma ação leve (por exemplo, “toggle favorite for id”). Mantenha estado por linha dentro da própria linha quando realmente pertencer a ela.
4) Animações demais durante a rolagem
Animações são caras em uma lista porque podem disparar passes extras de layout. Aplicar animation(.default, value:) no topo (na lista inteira) ou animar cada pequena mudança de estado pode deixar a rolagem pegajosa.
Mantenha simples:
- Escopo animações para a linha que muda
- Evite animar durante rolagem rápida (especialmente para seleção/highlight)
- Cuidado com animações implícitas em valores que mudam frequentemente
- Prefira transições simples em vez de efeitos combinados complexos
Um exemplo real: um chat onde cada linha inicia fetch de rede em onAppear, usa UUID() para id e anima mudanças de status “seen”. Essa combinação cria churn constante. Corrigir identidade, cachear trabalho e limitar animações frequentemente faz a mesma UI ficar instantaneamente mais suave.
Checklist rápido, um exemplo prático e próximos passos
Se você só fizer algumas coisas, comece por aqui:
- Use um id único estável para cada linha (não índice do array, não um UUID recém-gerado)
- Mantenha o trabalho por linha pequeno: evite formatações pesadas, árvores de view grandes e propriedades computadas caras no
body - Controle publishes: não deixe estado que muda rápido (timers, digitação, progresso) invalidar a lista inteira
- Carregue em páginas e faça prefetch para manter a memória estável
- Meça antes e depois com Instruments para não ficar no achismo
Imagine uma caixa de suporte com 20.000 conversas. Cada linha mostra um assunto, preview da última mensagem, timestamp, badge de não lidas e um avatar. Usuários podem buscar e novas mensagens chegam enquanto rolam. A versão lenta normalmente faz algumas coisas ao mesmo tempo: reconstrói linhas a cada tecla, re-mede texto demais e baixa muitas imagens cedo demais.
Um plano prático que não exige destruir sua base de código:
- Baseline: grave uma rolagem curta e uma sessão de busca no Instruments (Time Profiler + Core Animation).
- Corrija identidade: garanta que seu modelo tenha um id real do servidor/banco e que o
ForEacho use consistentemente. - Adicione paginação: comece com os mais novos 50–100 itens e carregue mais quando o usuário se aproximar do fim.
- Otimize imagens: use thumbnails menores, faça cache e evite decodificação na thread principal.
- Re-meça: confirme menos passes de layout, menos atualizações de view e tempos de frame mais estáveis em dispositivos mais antigos.
Se você está construindo um produto completo (app iOS mais backend e um painel admin web), também ajuda projetar o modelo de dados e o contrato de paginação desde cedo. Plataformas como AppMaster (appmaster.io) são feitas para esse fluxo full-stack: você pode definir dados e regras visuais e ainda gerar código fonte real para deploy ou self-host.
FAQ
Comece corrigindo a identidade das linhas. Use um id estável vindo do seu modelo e evite gerar IDs na view, porque IDs que mudam fazem o SwiftUI tratar as linhas como novas e reconstruí-las muito mais do que o necessário.
Recomputar o body normalmente é barato; o caro é o que essa recomputação aciona. Layout pesado, medição de texto, decodificação de imagem e reconstrução de muitas linhas por causa de identidade instável são o que tipicamente causa quedas de frames.
Não use UUID() dentro da view nem confie em índices de array como identidade se os dados puderem ser inseridos, removidos ou reordenados. Prefira um ID do servidor/banco de dados ou um UUID armazenado no modelo quando ele for criado, para que o ID permaneça igual entre atualizações.
Sim. Especialmente se o hash do valor mudar quando campos editáveis mudam, porque o SwiftUI pode enxergá-lo como uma linha diferente. Se precisar usar Hashable, baseie-o em um identificador único e estável em vez de propriedades como name, isSelected ou texto derivado.
Tire trabalho caro de dentro do body. Pré-formate datas e números, evite criar novos formatters por linha e não construa grandes arrays derivados com map/filter dentro da view; compute uma vez no modelo ou view model e passe valores pequenos e prontos para exibição para a linha.
onAppear é chamado com muita mais frequência do que se pensa, porque as linhas aparecem e desaparecem conforme você rola. Se cada linha iniciar decodificação de imagem, leituras de banco ou parsing pesado em onAppear, você terá picos repetidos; mantenha onAppear para ações leves, como disparar paginação quando estiver perto do fim.
Qualquer valor publicado que mude rapidamente e seja compartilhado com a lista pode invalidá-la repetidamente, mesmo que os dados das linhas não tenham mudado. Mantenha timers, estado de digitação e updates de progresso fora do objeto principal que dirige a lista, faça debounce em buscas e divida grandes ObservableObjects quando necessário.
Use List quando sua UI for parecida com uma tabela (linhas padrão, ações de swipe, seleção, separadores) e você quiser otimizações do sistema. Use ScrollView + LazyVStack quando precisar de layouts customizados, mas meça memória e quedas de frame, porque é mais fácil acabar fazendo trabalho de layout extra.
Comece a carregar antes de chegar na última linha: dispare o carregamento quando o usuário alcançar um limiar próximo ao fim e proteja contra triggers duplicados. Mantenha tamanhos de página razoáveis, controle isLoading e reachedEnd, e dedupe resultados por IDs estáveis para evitar linhas duplicadas e diffs extras.
Teste em um dispositivo real e use Instruments para encontrar picos na main thread e surtos de alocação durante a rolagem rápida. Time Profiler mostra o que bloqueia a rolagem, Allocations revela churn por linha, e Core Animation confirma frames perdidos, assim você sabe se o gargalo é renderização ou trabalho de dados.


