Performance SwiftUI: Otimização de LazyVStack e Listas Complexas

Técnicas de otimização para LazyVStack e listas SwiftUI. Reduzir o consumo de memória, melhorar a performance de scroll e evitar armadilhas comuns.

Otimização de performance de SwiftUI LazyVStack e listas complexas

As listas representam um dos componentes mais utilizados em aplicativos iOS. LazyVStack e List do SwiftUI oferecem soluções performáticas para exibir coleções de dados, mas o uso incorreto pode degradar rapidamente a experiência do usuário. Compreender o funcionamento interno desses componentes ajuda a evitar armadilhas frequentes e construir interfaces fluidas.

O que este artigo aborda

Este artigo apresenta as técnicas essenciais de otimização para listas SwiftUI: lazy loading, reciclagem de views, gestão de identificadores e padrões avançados para grandes conjuntos de dados.

Compreender o Lazy Loading no SwiftUI

O lazy loading instancia as views apenas quando elas se tornam visíveis na tela. Diferente do VStack que cria todas as views filhas imediatamente, o LazyVStack adia essa criação, reduzindo drasticamente o consumo de memória e o tempo de renderização inicial.

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)
                }
            }
        }
    }
}

A diferença de performance se torna significativa com apenas algumas centenas de elementos. Com 10.000 itens, o VStack pode levar vários segundos para iniciar enquanto o LazyVStack permanece instantâneo.

Medir o Impacto do Lazy Loading

O Instruments permite medir com precisão o uso de memória e CPU. Aqui está uma view de teste que ilustra a diferença:

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()
    }
}

Ao executar com VStack, todos os 10.000 logs aparecem imediatamente. Com LazyVStack, apenas os elementos visíveis (cerca de 15-20 dependendo do tamanho da tela) são registrados inicialmente, com mais aparecendo conforme o scroll.

Comportamento de retenção

O LazyVStack mantém as views criadas em memória após elas aparecerem. Diferente do List que recicla ativamente as células, as views em um LazyVStack persistem até que o componente pai seja destruído.

A Importância dos Identificadores Estáveis

Os identificadores formam o mecanismo central das atualizações de listas SwiftUI. Um identificador instável causa recriações desnecessárias de views e pode gerar bugs visuais como animações incorretas ou perda da posição do 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)
            }
        }
    }
}

Usar um identificador único e persistente garante que o SwiftUI possa diferenciar corretamente os elementos durante atualizações, animações e comparações.

Otimização de Células com Equatable

O SwiftUI compara as views para determinar se uma nova renderização é necessária. Por padrão, essa comparação utiliza reflection, o que pode ser custoso. Implementar Equatable permite uma comparação otimizada e explícita.

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))
            }
        }
    }
}

Essa otimização reduz significativamente a carga de CPU durante o scroll rápido, particularmente com células que contêm cálculos ou imagens.

Pronto para mandar bem nas entrevistas de iOS?

Pratique com nossos simuladores interativos, flashcards e testes tecnicos.

Gestão do Carregamento Assíncrono de Imagens

As imagens frequentemente se tornam o gargalo de performance em listas. Uma gestão incorreta provoca travamentos no scroll e consumo excessivo de memória.

ImageLoadingOptimization.swiftswift
import SwiftUI

// Singleton image cache
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)
            }
        }
    }
}

Essa implementação combina cache de memória, redimensionamento preventivo e carregamento assíncrono para uma experiência de scroll fluida.

Prefetching Inteligente para Antecipação

Para listas muito longas, o prefetching carrega imagens antes que elas se tornem visíveis:

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)
            }
        }
    }
}

Escolher entre List e LazyVStack

A escolha entre List e LazyVStack depende do caso de uso. Cada componente apresenta vantagens específicas que vale a pena conhecer.

ListVsLazyVStack.swiftswift
// ✅ List: ideal for interactive content
// - Automatic cell recycling
// - Native swipe actions support
// - Built-in separators and styles
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: ideal for custom layouts
// - Full control over spacing and padding
// - No imposed styles
// - Better performance for simple display
struct CustomFeedView: View {
    let posts: [Post]

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 16) {
                ForEach(posts) { post in
                    PostCard(post: post)
                }
            }
            .padding(.horizontal)
        }
    }
}

// Post model for example
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()
            }

            // Content
            Text(post.content)

            // Optional image
            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)
    }
}
Reciclagem de células

O List recicla ativamente as células, o que pode causar problemas com estado local (@State). Os valores @State em células de List podem ser reutilizados de forma inesperada. Convém armazenar o estado no modelo de dados ou em um ViewModel.

Seções e Cabeçalhos Otimizados

Organizar o conteúdo em seções melhora a legibilidade mas pode impactar a performance se mal implementado. Os cabeçalhos fixados e a gestão de seções exigem atenção particular.

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 }
    }
}

Os cabeçalhos fixados (pinnedViews: [.sectionHeaders]) permanecem visíveis durante o scroll, melhorando a navegação em listas longas.

Paginação e Scroll Infinito

Para grandes conjuntos de dados, a paginação evita carregar todos os dados em memória. A implementação deve ser transparente para o usuário.

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]
}

Esse padrão garante um carregamento fluido sem bloquear a interface e permite uma gestão eficiente da memória.

Pronto para mandar bem nas entrevistas de iOS?

Pratique com nossos simuladores interativos, flashcards e testes tecnicos.

Profiling com Instruments

Identificar problemas de performance exige ferramentas de medição precisas. O Instruments oferece diversos templates adequados ao 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 de Otimização com Instruments

Para um profiling efetivo de listas SwiftUI:

  1. Time Profiler: identificar as funções que mais consomem CPU
  2. Allocations: verificar o crescimento de memória durante o scroll
  3. SwiftUI Instrument: visualizar as avaliações de body
  4. Core Animation: detectar quedas 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()

Essa API de debugging do SwiftUI imprime no console quais propriedades mudaram e dispararam uma reavaliação do body. Essencial para identificar renderizações desnecessárias.

Otimizações Avançadas com drawingGroup

Para views complexas com muitos efeitos visuais, o drawingGroup() pode melhorar significativamente a performance ao rasterizar a view em uma camada 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
}

O drawingGroup() é particularmente eficaz para views que combinam gradientes, sombras e efeitos blur.

Conclusão

A otimização de listas SwiftUI se baseia em uma compreensão profunda dos mecanismos de lazy loading, reciclagem e comparação de views. As técnicas apresentadas permitem construir interfaces capazes de manipular milhares de elementos mantendo um scroll fluido a 60 FPS.

Checklist de Performance SwiftUI

  • ✅ Usar LazyVStack ou List em vez de VStack para coleções
  • ✅ Implementar Identifiable com IDs estáveis e únicos
  • ✅ Adotar Equatable para células complexas
  • ✅ Cachear e redimensionar imagens antes da exibição
  • ✅ Pré-carregar dados com prefetching inteligente
  • ✅ Escolher List para interações (swipe) ou LazyVStack para layouts personalizados
  • ✅ Usar pinnedViews para cabeçalhos de seção
  • ✅ Implementar paginação para grandes conjuntos de dados
  • ✅ Fazer profiling regular com Instruments
  • ✅ Aplicar drawingGroup() em views com efeitos visuais complexos

Comece a praticar!

Teste seus conhecimentos com nossos simuladores de entrevista e testes tecnicos.

Tags

#swiftui
#ios
#performance
#lazyvstack
#swift

Compartilhar

Artigos relacionados