Propagacja błędów — mechanizm rozprzestrzeniania błędu w górę stosu wywołań od miejsca wystąpienia do obsługi. Gdy funkcja nie może samodzielnie obsłużyć błędu, przekazuje go stronie wywołującej poprzez wyjątek (exception), deklarację throws lub typ zwracany. Prawidłowa implementacja propagacji jest krytyczna dla stabilności aplikacji mobilnych: nieobsłużone lub nieprawidłowo przekazane błędy prowadzą do awarii. Według Apple Swift Documentation (2026), automatyczna propagacja przez throws w Swift pozwala przekazywać błąd na dowolny poziom bez kodu boilerplate.
Najważniejsze
Propagacja błędów (rozprzestrzenianie błędu) — to proces przekazywania obiektu błędu od funkcji, w której powstał, w górę łańcucha wywołań do najbliższego odpowiedniego obsługiwacza. Wyobraź sobie stos wywołań: ViewController wywołuje ViewModel, ViewModel wywołuje Repository, Repository wywołuje API. Jeśli API zwraca błąd sieci, musi on przejść przez Repository i ViewModel do ViewController, który pokaże komunikat użytkownikowi. Każda funkcja pośrednia decyduje: obsłużyć błąd czy przekazać dalej (propagować).
Istnieją dwa podejścia do propagacji: automatyczne i ręczne. Przy podejściu automatycznym (Swift throws, Java checked exceptions) kompilator wymusza na programiście albo obsłużenie błędu, albo zadeklarowanie propagacji w sygnaturze. Przy podejściu ręcznym (typ Result, Kotlin Try) błąd jest przekazywany jako wartość — programista jawnie pisze kod przekazania lub transformacji błędu. Według Kotlin Result Docs (2026), Result<T> w Kotlin nie jest przeznaczony do propagacji przez granice funkcji bezpośrednio — należy go transformować lub obsługiwać na każdym poziomie, co czyni propagację bardziej świadomą, ale też bardziej rozwlekłą.
Wybór podejścia zależy od architektury aplikacji i języka. W Swift dominuje automatyczna propagacja przez throws, w Kotlin — mieszanka wyjątków (dla nieoczekiwanych błędów) i kontenerów podobnych do Result (dla oczekiwanych). Ważne jest zrozumienie: propagacja to nie cel, ale konieczność. Idealna architektura minimalizuje głębokość propagacji, obsługując błędy na najniższym możliwym poziomie, gdzie jest wystarczający kontekst do podjęcia decyzji.
W Swift propagacja przez throws odbywa się automatycznie: jeśli funkcja A z throws wywołuje funkcję B z throws, a A nie obsługuje błędu B w do-catch, błąd jest automatycznie przekazywany stronie wywołującej A. To eliminuje kod boilerplate charakterystyczny dla Java checked exceptions, gdzie throws trzeba deklarować w każdej metodzie łańcucha. Swift stosuje zasadę „jedna funkcja throws w łańcuchu = cały łańcuch staje się throws, jeśli nie obsługuje się na poziomach pośrednich”.
struct UserRepository {
func fetchUser(id: Int) throws -> User {
let data = try networkService.request(path: "/users/\(id)")
return try parseUser(from: data)
}
}
class UserViewModel {
let repo = UserRepository()
func loadUser(id: Int) throws -> User {
return try repo.fetchUser(id: id)
}
}
// ViewController — końcowy obsługiwacz
func onButtonTap() {
let vm = UserViewModel()
do {
let user = try vm.loadUser(id: 42)
updateUI(user)
} catch {
showError("Nie udało się załadować użytkownika")
}
}
Łańcuch propagacji: networkService.request -> fetchUser -> loadUser -> onButtonTap. Każda funkcja pośrednia jest oznaczona throws i nie zawiera do-catch — błąd jest automatycznie przekazywany w górę. ViewController onButtonTap jest końcowym obsługiwaczem z do-catch. Gdyby ViewModel zdecydował się przekształcić błąd (opakować w inny typ), mógłby użyć do-catch i nowego throw. Automatyczna propagacja skraca kod: Repository nie musi wiedzieć, jak obsłużyć błąd — to odpowiedzialność ViewController, który ma dostęp do UI do pokazania komunikatu użytkownikowi.
W Kotlin propagacja przez wyjątki nie wymaga deklaracji throws w sygnaturze (wszystkie wyjątki są unchecked). Wyjątek automatycznie wznosi się po stosie, aż napotka try-catch. Jednak brak throws w sygnaturze czyni propagację niejawną: programista nie widzi z sygnatury funkcji, że może ona rzucić wyjątek. To zaleta (mniej boilerplate) i wada (łatwiej zapomnieć o obsłudze) jednocześnie. Kotlin rozwiązuje ten problem przez konwencje i wzorce architektoniczne, a nie przez język.
class UserRepository(
private val api: ApiService,
private val db: Database
) {
suspend fun getUser(id: String): User {
return try {
api.fetchUser(id)
} catch (e: IOException) {
db.getCachedUser(id) ?: throw AppException("User unavailable")
}
}
}
class UserViewModel(private val repo: UserRepository) {
private val _state = MutableStateFlow<UiState<User>>(UiState.Loading)
val state: StateFlow<UiState<User>> = _state
fun loadUser(id: String) {
viewModelScope.launch {
try {
val user = repo.getUser(id)
_state.value = UiState.Success(user)
} catch (e: AppException) {
_state.value = UiState.Error(e.message ?: "Unknown")
}
}
}
}
W Repository propagacja z transformacją: przy IOException (sieć niedostępna) funkcja próbuje pobrać dane z pamięci podręcznej z bazy danych. Jeśli pamięć podręczna jest pusta, rzuca AppException — propagacja kontynuuje z nowym typem błędu. ViewModel przechwytuje AppException i tłumaczy na UiState.Error — błąd nie idzie dalej, propagacja zakończona na poziomie warstwy UI. Kotlin Coroutines dodają cechy szczególne: wyjątki w launch automatycznie rozprzestrzeniają się przez CoroutineExceptionHandler, a w async — tylko przy wywołaniu await(). Należy to uwzględnić przy projektowaniu propagacji w korutynach — SupervisorJob zapobiega anulowaniu korutyny nadrzędnej przy błędzie w potomnej.
Alternatywa dla wyjątków — propagacja przez typ-kontener, który przekazuje sukces lub błąd jako wartość. W tym podejściu funkcja zwraca nie wartość, ale opakowanie: Result<T, E> w Swift, Result<T> w Kotlin, Either<L, R> w Dart (z pakietu fpdart lub dartz). Błąd nie rozwija stosu — po prostu leży w kontenerze, a następny poziom decyduje, co z nim zrobić. To czyni propagację bardziej jawną i kontrolowaną.
data class HttpResult<out T>(
val data: T?,
val error: AppError?
) {
val isSuccess: Boolean get() = data != null
val isError: Boolean get() = error != null
}
sealed class AppError {
data class Network(val message: String) : AppError()
data class Auth(val message: String) : AppError()
}
fun fetchUser(id: String): HttpResult<User> {
return try {
val response = api.get("/users/$id")
HttpResult(data = parseUser(response), error = null)
} catch (e: IOException) {
HttpResult(data = null, error = AppError.Network("No internet"))
}
}
HttpResult<T> — prosty kontener z polami data i error. Sealed class AppError definiuje typy błędów (Network, Auth). Funkcja fetchUser zwraca HttpResult, propagacja nie wymaga rozwijania stosu — strona wywołująca po prostu sprawdza isSuccess/isError. Takie podejście jest szczególnie przydatne w Clean Architecture, gdzie każda warstwa (data, domain, presentation) może przekształcać błąd: IOError -> DomainError -> UiError. Propagacja przez kontener czyni te transformacje jawnymi i testowalnymi, w przeciwieństwie do wyjątków, gdzie łańcuch przekształceń nie jest widoczny w sygnaturach funkcji.
Jedną z kluczowych decyzji w projektowaniu obsługi błędów jest wybór między propagacją (przekazanie wyżej) a obsługą (obsłużenie tutaj). Zasada podejmowania decyzji: obsługuj błąd na tym poziomie, gdzie jest wystarczający kontekst do znaczącego działania. Jeśli masz dostęp do UI — pokaż komunikat użytkownikowi. Jeśli masz dostęp do pamięci podręcznej — spróbuj odzyskać. Jeśli nie masz ani jednego, ani drugiego — propaguj.
| Scenariusz | Działanie | Uzasadnienie |
|---|---|---|
| Błąd sieci w Repository | Propaguj | Repository nie wie, czy użytkownik chce ponowić żądanie |
| Błąd parsowania w Repository | Obsłuż (zwróć wartość domyślną) | Repository zna format, może zwrócić wartość zastępczą |
| Przekroczenie czasu w ViewModel | Obsłuż (UiState.Error) | ViewModel zarządza UiState, wie, jak przetłumaczyć błąd |
| Błąd autoryzacji w Interceptor | Obsłuż (odśwież token) | Interceptor ma dostęp do tokenów i może przywrócić sesję |
| Nieznany błąd w UseCase | Propaguj | UseCase nie ma kontekstu UI — tylko logika biznesowa |
Złota zasada: minimum propagacji, maksimum obsługi na niższych poziomach. Jeśli Repository może odzyskać dane z pamięci podręcznej — powinno to zrobić, nie przekazując błędu wyżej. Jeśli ViewModel może pokazać Snackbar — niech pokaże, nie wymagając od ViewController dodatkowego kodu. Każdy poziom propagacji zwiększa powiązanie i utrudnia testowanie. Według Google Android Architecture Guide (2026), zaleca się minimalizowanie propagacji przez granice warstw, używając sealed class UiState do reprezentowania wszystkich możliwych stanów (Loading, Success, Error) na poziomie ViewModel i nieprzekazywanie wyjątków bezpośrednio do warstwy UI.
Nieprawidłowa propagacja jest źródłem trudnych do wyśledzenia błędów w aplikacjach mobilnych. Rozważmy pięć głównych problemów, z którymi spotykają się programiści, oraz sposoby ich rozwiązania.
Najczęstszy problem: podczas propagacji wyjątek jest przechwytywany, logowany i rzucany nowy bez oryginalnego wyjątku. Programista traci StackTrace i nie może zrozumieć, gdzie dokładnie wystąpił błąd. W Swift używaj error chaining: throw MyError(context: originalError). W Kotlin: throw AppException(cause = originalException). W Dart: throw AppException(message, originalException). Nigdy nie twórz nowego wyjątku bez przekazania przyczyny/błędu źródłowego.
catch (e: Exception) { /* nic */ } — antywzorzec, który prowadzi do tego, że aplikacja kontynuuje działanie w nieprawidłowym stanie. Jeśli jesteś pewien, że błąd można zignorować — dodaj komentarz z uzasadnieniem. W Swift do opcjonalnego ignorowania używaj try? (błąd -> nil). W Kotlin — Result<T>.onFailure { /* log */ }. Nie wyciszaj wyjątków bez logowania.
Jeśli błąd przechodzi przez 5+ poziomów bez obsługi, architektura wymaga przeglądu. Każdy poziom propagacji to zależność od sygnatury throws funkcji leżących niżej. Rozwiązanie: używaj kontenerów Failure (sealed class Result { Success, Error }) na granicach warstw, aby propagacja była jawna i ograniczona. Im krótszy łańcuch propagacji, tym łatwiej testować i debugować kod.
W Kotlin Coroutines wyjątek w launch domyślnie anuluje korutynę nadrzędną i wszystkie siblings (potomne tego samego scope). Jeśli jedno z 10 równoległych zadań upadnie, pozostałe 9 zostanie anulowanych, co często jest niepożądane. Używaj SupervisorJob lub supervisorScope do izolacji błędów: błąd w jednym child nie anuluje siblings. ViewModelScope domyślnie używa SupervisorJob, co chroni przed tym problemem w Androidzie.
W API opartym na callback błąd często jest przekazywany jako parametr callbacka. Jeśli callback nie obsługuje błędu (lub obsługuje nieprawidłowo), propagacja staje się niejawna i łatwo się gubi. Rozwiązanie: migruj na async/await (Swift) lub korutyny (Kotlin), gdzie propagacja działa przez standardowe mechanizmy try-catch. Jeśli callback jest nieunikniony — używaj Either<Error, T> lub Result<T> do wymuszenia obsługi obu przypadków.
Często zadawane pytania
Throw to jednorazowe działanie wyrzucenia wyjątku. Propagacja błędów to cały proces przekazywania błędu przez kilka poziomów stosu, od throw do catch. Propagacja obejmuje throw, automatyczne lub ręczne przekazywanie przez funkcje pośrednie i końcową obsługę. To szersze pojęcie opisujące cykl życia błędu.
Używaj obiektów mock, które rzucają wyjątki w zadanych scenariuszach. Sprawdzaj, czy funkcja prawidłowo propaguje lub obsługuje błąd przez assertThrows (Kotlin/JUnit) lub XCTAssertThrowsError (Swift/XCTest). Dla propagacji opartej na Result sprawdzaj isSuccess/isError i wartości w obu przypadkach.
Propagacja przez Result jest preferowana dla oczekiwanych błędów (nieprawidłowe dane, reguły biznesowe) w ramach jednej granicy architektonicznej. Wyjątki są lepsze dla nieoczekiwanych błędów (utrata sieci, błędy wejścia-wyjścia), które powinny być obsłużone na wysokim poziomie. Wynik z błędem nie przerywa przepływu wykonania, wyjątek — przerywa.
W Kotlin Coroutines wyjątek w launch automatycznie propaguje przez CoroutineScope z anulowaniem siblings. Używaj supervisorScope lub SupervisorJob do izolacji: błąd w jednej korutynie nie anuluje innych. Dla async błąd należy jawnie obsłużyć przez try-catch przy wywołaniu await(), w przeciwnym razie zostanie połknięty.
Idealny końcowy obsługiwacz to warstwa UI (ViewController, Fragment/Composable). Tylko ona ma dostęp do interfejsu użytkownika i może pokazać komunikat, Snackbar lub dialog. Warstwy pośrednie (Repository, UseCase, ViewModel) propagują błąd, przekształcając go w razie potrzeby na bardziej abstrakcyjny typ domenowy.
Podsumowanie
Opracujemy aplikację mobilną pod klucz
IT Sectr tworzy aplikacje na iOS i Androida dla startupów i firm od 2017 roku. Doradzimy Ci i zaproponujemy najlepsze rozwiązanie.
Przeczytaj również