iOS Development

SwiftUI: Anatomia błędów, które popełnia nawet senior

16 min readRafał Dubiel
#iOS#SwiftUi

Pracując przez lata z różnymi projektami iOS często spotykałem się z róznymi podejściami do robienia widoków i wynikającymi z nich błędami. Zdarzały się projekty pisane w pośpiechu przed deadline'em, "tymczasowe" rozwiązania żyjące latami w produkcji, i kod, który "działa, więc go nie ruszaj". Ale gdy SwiftUI weszło do gry, okazało się, że nawet doświadczeni deweloperzy potrafią wpaść w pułapki, o których istnieniu nawet nie mieli pojęcia.

W tym artykule chciałem zwrócić uwagę na takie pułapki i błędy. To takie głębsze spojrzenie na mechanizmy działania frameworka i błędy, które mogą kosztować Cię godziny debugowania, albo, co gorsza, pozostać niezauważone aż do momentu, gdy użytkownicy zaczną narzekać na lagujący interfejs.


Najczęstsze sygnały, że masz problem z wydajnością SwiftUI

Zanim przejdziemy do konkretnych błędów, sprawdź czy rozpoznajesz któryś z tych symptomów:

🔴 Scroll laguje mimo prostego UI

Lista z 50 elementami nie powinna lagować. Jeśli tak jest, to prawdopodobnie widoki przeliczają się przy każdej klatce.

🔴 Animacje są rwane lub "skaczą"

SwiftUI powinien płynnie interpolować zmiany. Rwane animacje oznaczają, że coś wymusza pełne przebudowanie widoku zamiast aktualizacji.

🔴 body przelicza się setki razy na sekundę

Dodaj let _ = Self._printChanges() do widoku. Jeśli konsola jest zawalona kolejnymi wpisami, to masz problem z zależnościami.

🔴 Lista "gubi" stan lub resetuje pozycję scrolla

To klasyczny objaw utraty tożsamości strukturalnej. Widoki są niszczone i tworzone od nowa zamiast aktualizowane.

🔴 TextField traci focus przy wpisywaniu

Każda litera powoduje przebudowanie rodzica, który niszczy i odtwarza TextField.

🔴 Taski anulują się "bez powodu"

Prawdopodobnie dotykasz stanu przed zakończeniem operacji asynchronicznych, co przebudowuje widok i anuluje powiązany task.

🔴 Aplikacja zużywa nieproporcjonalnie dużo procesora podczas bezczynności

Gdzieś masz timer, observer lub binding, który triggeruje ciągłe przeliczanie.

Jeśli rozpoznajesz choćby jeden z tych symptomów to czytaj dalej. Prawdopodobnie znajdziesz przyczynę.


Jak naprawdę działa SwiftUI?

Zanim przejdziemy do błędów, musimy zrozumieć fundament. SwiftUI to nie jest "lepszy UIKit". To zupełnie inny paradygmat, inne podejście, które wymaga przestawienia sposobu myślenia.

View to nie widok - to przepis na widok

W UIKit UIView to obiekt żyjący w pamięci, ze wskaźnikiem, który go identyfikuje. Możesz go zmutować, przesunąć, ukryć. W SwiftUI View to struct - wartość, nie referencja. To nie jest widok na ekranie. To opis tego, jak widok powinien wyglądać.

Gdy SwiftUI renderuje interfejs, przechodzi przez trzy fazy:

  1. Ewaluacja body - framework wywołuje właściwość body Twojego widoku,
  2. Diffing - porównuje nowy wynik z poprzednim,
  3. Renderowanie - aktualizuje tylko te piksele, które faktycznie się zmieniły

Kluczowa obserwacja: ewaluacja body nie oznacza renderowania. SwiftUI może wywołać body wielokrotnie w jednej klatce animacji, ale faktyczny render na GPU nastąpi tylko raz. Framework jest sprytny - zbiera wszystkie zmiany i commituje je razem.

Dependency Graph - serce SwiftUI

SwiftUI utrzymuje wewnętrzny graf zależności (AttributeGraph). Każdy widok ma swoje zależności - @State, @Binding, @ObservedObject, @Environment. Gdy którakolwiek z nich się zmieni, SwiftUI wie dokładnie, które widoki muszą przeliczyć swoje body.

To brzmi świetnie w teorii. A w praktyce? Jeden źle umieszczony @ObservedObject może spowodować kaskadę niepotrzebnych przeliczań.


Błąd #1: Epidemia @ObservedObject

🔍 Problem

struct ProductView: View {
    @ObservedObject var viewModel = ProductViewModel() // ❌

    var body: some View {
        Text(viewModel.name)
    }
}

❓ Dlaczego to błąd

@ObservedObject nie zarządza cyklem życia obiektu. Za każdym razem, gdy parent przelicza swoje body, SwiftUI tworzy nową instancję ProductView - a wraz z nią nowy ProductViewModel.

To nie jest oczywiste, bo struct widoku jest "lekki". Ale inicjalizacja ViewModel może być ciężka: zapytania sieciowe w init, subskrypcje Combine, zapis do cache'u.

✅ Jak to naprawić

struct ProductView: View {
    @StateObject private var viewModel = ProductViewModel() // ✅

    var body: some View {
        Text(viewModel.name)
    }
}

@StateObject tworzy obiekt tylko raz i utrzymuje go przez cały cykl życia widoku. Używaj @ObservedObject tylko gdy obiekt jest wstrzykiwany z zewnątrz.

⚠️ Co się stanie, jeśli to zignorujesz

  • Stan znika przy każdym odświeżeniu rodzica,
  • Zapytanis sieciowe się duplikują,
  • Wycieki pamięci - stare ViewModele mogą nie zostać zwolnione od razu,
  • Subskrypcje Combine się mnożą, prowadząc do "ghost updates"

🎯 Pułapka przy migracji do @Observable

Od iOS 17 mamy makro @Observable, które miało być drop-in replacement dla ObservableObject. Ale jest tutaj mała, subtelna różnica:

// ObservableObject - @autoclosure, lazy init
@StateObject private var viewModel = ProductViewModel()

// @Observable - zwykła wartość, eager init!
@State private var viewModel = ProductViewModel()

@StateObject używa @autoclosure - obiekt jest tworzony tylko raz, leniwie. @State inicjalizuje wartość przy każdym tworzeniu struct widoku, a dopiero potem SwiftUI "przywraca" zachowany stan.

W praktyce: przy @State z @Observable możesz mieć wiele instancji Twojego modelu wiszących w pamięci. Jesse Squires opisał przypadek, gdzie te "duchy" obiektów nadal nasłuchiwały notyfikacji i nadpisywały dane w UserDefaults Link do artykułu.


Błąd #2: ObservableObject z nadmiarem @Published

🔍 Problem

class SignUpViewModel: ObservableObject {
    @Published var email: String = ""
    @Published var password: String = ""
    @Published var confirmPassword: String = ""
    @Published var acceptedTerms: Bool = false
    @Published var isLoading: Bool = false
    @Published var errorMessage: String? = nil
}

❓ Dlaczego to błąd

Masz formularz z 6 polami. Użytkownik wpisuje literę w pole email. Co się dzieje?

Każdy widok obserwujący ten ViewModel dostaje powiadomienie. Nie "widoki używające email". Wszystkie. ObservableObject nie rozróżnia, która właściwość się zmieniła - wysyła jeden sygnał objectWillChange.

✅ Jak to naprawić

Opcja 1: Granularne bindowanie

struct SignUpView: View {
    @StateObject var viewModel = SignUpViewModel()

    var body: some View {
        VStack {
            EmailField(email: $viewModel.email)
            PasswordField(password: $viewModel.password)
        }
    }
}

struct EmailField: View {
    @Binding var email: String

    var body: some View {
        TextField("Email", text: $email)
    }
}

Teraz EmailField dostanie aktualizację tylko gdy zmieni się email.

Opcja 2: Migracja do @Observable (iOS 17+)

@Observable
class SignUpViewModel {
    var email: String = ""
    var password: String = ""
}

@Observable śledzi dostęp do właściwości na poziomie pojedynczego pola. Jeśli widok czyta tylko email, to tylko zmiana email spowoduje przeliczenie body.

⚠️ Co się stanie, jeśli to zignorujesz

  • Każde naciśnięcie klawisza w dowolnym polu odświeża wszystkie pola formularza,
  • Przy 10 widokach obserwujących ViewModel, wpisanie "hello@email.com" to 160 niepotrzebnych przeliczań body,
  • Animacje się rwą, bo CPU jest zajęty diffingiem,
  • Na starszych urządzeniach formularz staje się zauważalnie lagujący.

Błąd #3: Conditional View Modifiers

🔍 Problem

extension View {
    @ViewBuilder
    func applyIf<Content: View>(
        _ condition: Bool,
        transform: (Self) -> Content
    ) -> some View {
        if condition {
            transform(self)
        } else {
            self
        }
    }
}

// Użycie
Text("Hello")
    .applyIf(isHighlighted) { view in
        view.background(Color.yellow)
    }

❓ Dlaczego to błąd

Wygląda elegancko, ale problem tkwi w tym, co dzieje się pod spodem.

SwiftUI używa strukturalnej tożsamości (structural identity). if statement w @ViewBuilder tworzy _ConditionalContent<A, B> - widok, który przełącza między dwoma różnymi typami widoków.

Gdy isHighlighted zmieni się z false na true:

  1. SwiftUI niszczy widok z gałęzi false,
  2. Tworzy od nowa widok z gałęzi true,
  3. Odpala onDisappear + onAppear,
  4. Traci cały wewnętrzny stan.

✅ Jak to naprawić

Użyj warunku wewnątrz modyfikatora, a nie warunkowego modyfikatora:

// ❌ Źle - dwa różne typy widoków
if isHighlighted {
    Text("Hello").background(Color.yellow)
} else {
    Text("Hello")
}

// ✅ Dobrze - ten sam typ, inna wartość
Text("Hello")
    .background(isHighlighted ? Color.yellow : Color.clear)

SwiftUI ma "inert modifiers" - .opacity(1), .padding(0), .background(Color.clear), które nie zmieniają struktury widoku.

⚠️ Co się stanie, jeśli to zignorujesz

  • TextField traci focus przy zmianie warunku,
  • Pozycja scrolla resetuje się do zera,
  • Animacje "skaczą" zamiast płynnie przechodzić,
  • @State wewnątrz widoku resetuje się do wartości początkowej,
  • onAppear odpala się wielokrotnie, potencjalnie triggerując zduplikowane zapytania sieciowe.

Błąd #4: Closure capturing w body

🔍 Problem

struct ItemList: View {
    @ObservedObject var store: ItemStore

    var body: some View {
        List(store.items) { item in
            Button(item.name) {
                store.select(item) // Closure przechwytuje `store`
            }
        }
    }
}

❓ Dlaczego to błąd

SwiftUI używa refleksji do porównywania właściwości widoku i decydowania, czy body musi być przeliczone. Problem? Closures są praktycznie nieporównywalne.

Gdy rodzic widoku się odświeża, SwiftUI widzi nowy closure (który wygląda identycznie, ale technicznie jest nową instancją) i stwierdza: "coś się zmieniło, muszę przeliczyć body".

✅ Jak to naprawić

Wyciągnij closure do osobnego widoku z porównywalnymi właściwościami:

struct ItemList: View {
    @ObservedObject var store: ItemStore

    var body: some View {
        List(store.items) { item in
            ItemRow(item: item, onTap: store.select)
        }
    }
}

struct ItemRow: View {
    let item: Item
    let onTap: (Item) -> Void

    var body: some View {
        Button(item.name) {
            onTap(item)
        }
    }
}

Teraz ItemRow ma porównywalne właściwości (item konformuje do Equatable), a closure jest przekazywany jako referencja do metody, a nie tworzony na nowo.

⚠️ Co się stanie, jeśli to zignorujesz

  • Każdy wiersz listy przelicza body przy każdej zmianie w rodzicu,
  • Przy 100 wierszach i 60 FPS to potencjalnie 6000 przeliczań body na sekundę,
  • Scroll hitches - ramki są porzucane, bo CPU nie nadąża,
  • Bateria wyczerpuje się znacznie szybciej niż powinna.

Błąd #5: Nieznajomość EquatableView

🔍 Problem

Masz skomplikowany widok z dużą ilością logiki renderowania. Przelicza body za każdym razem, gdy rodzic się odświeża, mimo że jego własne dane się nie zmieniły.

struct ExpensiveChartView: View {
    let data: ChartData
    let configuration: ChartConfig

    var body: some View {
        // Skomplikowane obliczenia, wiele warstw...
    }
}

❓ Dlaczego to błąd

SwiftUI domyślnie porównuje wszystkie właściwości widoku przez refleksję. Dla prostych typów to szybkie. Ale dla złożonych struktur, zagnieżdżonych array'ów, czy obiektów z closures, diffing może być droższy niż samo przeliczenie body.

✅ Jak to naprawić

struct ExpensiveChartView: View, Equatable {
    let data: ChartData
    let configuration: ChartConfig

    static func == (lhs: Self, rhs: Self) -> Bool {
        // Własna, tania logika porównania
        lhs.data.id == rhs.data.id &&
        lhs.data.version == rhs.data.version
    }

    var body: some View {
        // Skomplikowane obliczenia...
    }
}

// Użycie
ExpensiveChartView(data: chartData, configuration: config)
    .equatable() // 👈 Magiczny modifier

.equatable() mówi SwiftUI: "użyj mojej implementacji == zamiast defaultowego diffingu". Jeśli zwrócisz true, body nie zostanie przeliczone.

Airbnb opublikowało case study: dodanie .equatable() zmniejszyło scroll hitches o 15% na głównym ekranie wyszukiwania Link do artykułu

⚠️ Co się stanie, jeśli to zignorujesz

  • Widok przelicza się przy każdym odświeżeniu rodzica, nawet gdy dane się nie zmieniły,
  • Złożone widoki (wykresy, mapy, rich text) mogą dropować klatki,
  • Battery wyczerpuje się szybciej od ciągłych, niepotrzebnych obliczeń.

Uwaga: EquatableView nie działa dobrze z wewnętrznymi property wrapperami jak @State. Takie właściwości nie uczestniczą w porównaniu.


Błąd #6: Task modifier i niespodziewane anulowanie

🔍 Problem

struct ProfileView: View {
    @StateObject var viewModel = ProfileViewModel()

    var body: some View {
        List { /* ... */ }
        .refreshable {
            viewModel.isRefreshing = true // ❌
            await viewModel.refresh()
            viewModel.isRefreshing = false
        }
    }
}

❓ Dlaczego to błąd

.refreshable tworzy Task powiązany z widokiem. Gdy widok się przebudowuje, task jest anulowany. A co powoduje przebudowanie widoku? Zmiana @Published property!

Sekwencja:

  1. Ustawiasz isRefreshing = true,
  2. ViewModel wysyła objectWillChange,
  3. Widok się przebudowuje,
  4. Task jest anulowany,
  5. Network request nigdy się nie kończy❗

✅ Jak to naprawić

Nie dotykaj stanu przed zakończeniem operacji asynchronicznej:

.refreshable {
    await viewModel.refresh() // Najpierw wykonaj operację
    // Stan zaktualizuj w refresh(), PO zakończeniu
}

// W ViewModel:
func refresh() async {
    // Nie ustawiaj isRefreshing tutaj!
    let data = await fetchData()
    self.items = data
    // Teraz możesz zaktualizować stan UI
}

Lub użyj task(id:) dla automatycznego zarządzania:

.task(id: selectedCategory) {
    await loadItems(for: selectedCategory)
}

Gdy selectedCategory się zmieni, SwiftUI automatycznie anuluje poprzedni task i uruchomi nowy.

⚠️ Co się stanie, jeśli to zignorujesz

  • Pull-to-refresh "nie działa" - spinner kręci się w nieskończoność,
  • Dane się nie aktualizują mimo poprawnego API,
  • Użytkownicy zgłaszają bugi "aplikacja się zawiesiła",
  • Debugowanie jest koszmarem, bo problem jest przerywany.

Błąd #7: modyfikator id() - broń obosieczna

🔍 Problem

List(items) { item in
    ItemRow(item: item)
        .id(UUID()) // ❌ Katastrofa!
}

❓ Dlaczego to błąd

id() nadaje widokowi explicit identity. Gdy ID się zmienia, SwiftUI traktuje to jako zupełnie nowy widok - niszczy stary, tworzy nowy od zera.

UUID() generuje nowy identyfikator przy każdym wywołaniu. Czyli przy każdym odświeżeniu listy, każdy wiersz jest "nowy".

✅ Jak to naprawić

ID musi być stabilne, powiązane z danymi, nie z renderowaniem:

List(items) { item in
    ItemRow(item: item)
        .id(item.id) // ✅ Stabilne ID z modelu
}

Gdy id() jest naprawdę potrzebne (wymuszenie resetu stanu):

TextField("Search", text: $query)
    .id(resetCounter) // Zmiana resetCounter czyści tekst

⚠️ Co się stanie, jeśli to zignorujesz

  • Scroll jumpy - lista "skacze" przy każdym odświeżeniu,
  • Animacje są chaotyczne - SwiftUI nie wie co animować,
  • Utrata stanu - rozszerzone komórki się zwijają, zaznaczenie znika,
  • Massive memory churn - ciągłe alokacje i dealokacje,
  • Na listach z obrazkami: migotanie, bo obrazki są ładowane od nowa.

Błąd #8: Kosztowne operacje w body

🔍 Problem

var body: some View {
    let filtered = items.filter { $0.category == selectedCategory }
                        .sorted { $0.date > $1.date }

    List(filtered) { item in
        ItemRow(item: item)
    }
}

❓ Dlaczego to błąd

body może być wywoływane dziesiątki razy na sekundę podczas animacji. Filtrowanie i sortowanie przy każdym wywołaniu? To operacja o złożoności O(n log n), wykonywana potencjalnie 60 razy na sekundę.

✅ Jak to naprawić

Opcja 1: Computed property (dla prostych przypadków)

struct ItemListView: View {
    let items: [Item]
    let selectedCategory: Category

    private var filteredItems: [Item] {
        items.filter { $0.category == selectedCategory }
             .sorted { $0.date > $1.date }
    }

    var body: some View {
        List(filteredItems) { item in
            ItemRow(item: item)
        }
    }
}

Opcja 2: Cache w ViewModel (dla złożonych przypadków)

@Observable
class ItemListViewModel {
    var items: [Item] = []
    var selectedCategory: Category = .all

    // @Observable sprawia, że to jest efektywnie cache'owane
    var filteredItems: [Item] {
        items.filter { $0.category == selectedCategory }
             .sorted { $0.date > $1.date }
    }
}

Opcja 3: Explicit memoization

@State private var filteredItems: [Item] = []

var body: some View {
    List(filteredItems) { item in
        ItemRow(item: item)
    }
    .onChange(of: items) { updateFilteredItems() }
    .onChange(of: selectedCategory) { updateFilteredItems() }
}

private func updateFilteredItems() {
    filteredItems = items.filter { $0.category == selectedCategory }
                         .sorted { $0.date > $1.date }
}

⚠️ Co się stanie, jeśli to zignorujesz

  • Animacje się rwą - CPU jest zajęty sortowaniem zamiast renderowaniem,
  • Scroll hitches na listach,
  • Zwiększone zużycie baterii,
  • Na większych zbiorach danych (1000+ elementów) aplikacja staje się nieużywalna.

Błąd #9: AnyView - type erasure za wszelką cenę

🔍 Problem

func makeView(for type: ContentType) -> AnyView {
    switch type {
    case .text: return AnyView(TextView())
    case .image: return AnyView(ImageView())
    case .video: return AnyView(VideoView())
    }
}

❓ Dlaczego to błąd

AnyView wymazuje typ widoku w czasie kompilacji. SwiftUI traci informacje potrzebne do optymalizacji diffingu, nie wie jaki typ widoku jest w środku, więc musi zakładać najgorsze.

✅ Jak to naprawić

Użyj @ViewBuilder:

@ViewBuilder
func makeView(for type: ContentType) -> some View {
    switch type {
    case .text: TextView()
    case .image: ImageView()
    case .video: VideoView()
    }
}

@ViewBuilder generuje _ConditionalContent, który zachowuje informacje o typach. SwiftUI może inteligentnie przełączać się między gałęziami.

⚠️ Co się stanie, jeśli to zignorujesz

  • Każda zmiana typu wymusza pełne przebudowanie hierarchii,
  • Animacje przejścia nie działają poprawnie,
  • Wydajność degraduje się proporcjonalnie do głębokości hierarchii,
  • Testy w Instruments pokażą nieproporcjonalnie dużo czasu spędzonego na diffingu.

Błąd #10: Environment objects - globalne zło

🔍 Problem

@main
struct MyApp: App {
    @StateObject var appState = AppState() // 50 propercji

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(appState)
        }
    }
}

❓ Dlaczego to błąd

AppState z 50 właściwościami, wstrzykiwany na samej górze hierarchii. Każda zmiana dowolnej właściwości wysyła objectWillChange do wszystkich widoków, które go obserwują.

Nawet jeśli ProfileView używa tylko appState.user, dostanie powiadomienie gdy zmieni się appState.cart, appState.notifications, czy appState.theme.

✅ Jak to naprawić

Opcja 1: Rozbij jeden wielki AppState na wyspecjalizowane obiekty

@StateObject var userSettings = UserSettings()
@StateObject var cartManager = CartManager()
@StateObject var navigationState = NavigationState()

ContentView()
    .environmentObject(userSettings)
    .environmentObject(cartManager)
    .environmentObject(navigationState)

Opcja 2: Dedykowane Environment Keys dla globalnych wartości

private struct ThemeKey: EnvironmentKey {
    static let defaultValue = Theme.light
}

extension EnvironmentValues {
    var theme: Theme {
        get { self[ThemeKey.self] }
        set { self[ThemeKey.self] = newValue }
    }
}

// Użycie
@Environment(\.theme) var theme

Opcja 3: Migracja do @Observable (iOS 17+)

Z @Observable widoki reagują tylko na właściwości, które faktycznie czytają. Nawet duży obiekt stanu nie jest problemem.

⚠️ Co się stanie, jeśli to zignorujesz

  • Każda zmiana stanu odświeża potencjalnie setki widoków,
  • Aplikacja staje się wolniejsza wraz ze wzrostem bazy kodu,
  • Debugowanie staje się niemal niemożliwe - wszystko wpływa na wszystko,
  • Memory pressure rośnie od ciągłego tworzenia nowych widoków.

Debugging - jak znaleźć źródło problemu?

Self._printChanges()

var body: some View {
    let _ = Self._printChanges()
    // ...
}

To nieudokumentowane API, które wypisuje do konsoli, co spowodowało przeliczenie body:

ContentView: @self changed.
ContentView: _selectedItem changed.

⚠️ Usuń je przed releasem, bo to wewnętrzne API.

Instruments - SwiftUI template

  1. Product → Profile (⌘I)
  2. Wybierz SwiftUI template
  3. Nagraj interakcję

Zobaczysz:

  • View Body: ile razy każdy widok przeliczał body,
  • Core Animation Commits: faktyczne rendery na GPU,
  • Time Profiler: gdzie spędzasz czas,

Interpretacja:

  • ✅ View Body wysoki, Core Animation niski → SwiftUI skutecznie optymalizuje,
  • ❌ Oba wysokie → masz realny problem z wydajnością,

Random color debugging

extension View {
    func debugRandomBackground() -> some View {
        self.background(Color(
            red: .random(in: 0...1),
            green: .random(in: 0...1),
            blue: .random(in: 0...1)
        ))
    }
}

Dodaj do widoku i obserwuj. Jeśli kolor zmienia się bez interakcji użytkownika, to widok jest niepotrzebnie przeliczany.


Podsumowanie

SwiftUI to potężny framework, ale wymaga dogłębnego zrozumienia jak działa pod spodem. Większość problemów z wydajnością sprowadza się do:

ProblemRozwiązanie
Zbyt szerokie zależnościGranularne @Binding, migracja do @Observable
Utrata tożsamości strukturalnejWarunki wewnątrz modyfikatorów, stabilne ID
Niepotrzebna praca w bodyCache, computed properties, memoization
Nieznajomość narzędziEquatableView, task(id:), @ViewBuilder

Pamiętaj: SwiftUI jest deklaratywny, a Ty musisz deklarować mądrze. Framework zrobi swoją robotę, o ile mu w tym nie przeszkodzisz.

Rafał Dubiel

Rafał Dubiel

Senior iOS Developer z ponad 8-letnim doświadczeniem w tworzeniu aplikacji mobilnych. Pasjonat czystej architektury, Swift i dzielenia się wiedzą ze społecznością.

Udostępnij artykuł:

SwiftUI: Anatomia błędów, które popełnia nawet senior | FractalDev Blog