Kontekst pracy ze Swiftem na iOS: dlaczego modernizacja ma sens
Jak Swift dojrzewał na iOS – od wersji 1.0 do concurrency
Pierwsze wersje Swifta były przełomowe, ale też pełne ostrych krawędzi: niestabilne ABI, częste zmiany składni, ograniczona interoperacyjność z Objective‑C. Wiele zespołów zbudowało wtedy aplikacje, które dziś działają, ale wewnątrz przypominają muzeum: stare API, callbacki, GCD na każdym kroku, ręcznie pilnowane wątki.
Od Swifta 5 w górę stabilność języka wyraźnie wzrosła, a kolejne wersje (5.5, 5.7, 5.9+) wprowadziły spójny model współbieżności, aktorów i nowoczesne API w iOS SDK. To już nie kosmetyka, ale zmiana sposobu myślenia: bezpieczniejsze typy, strukturalna współbieżność, głębsza integracja z systemem. Migracja „w stronę nowego Swifta” przestała być eksperymentem, a stała się dobrym standardem branżowym.
Jeśli projekt powstawał etapami przez kilka lat, bardzo możliwe, że w jednym repozytorium żyje kilka „epok” Swifta naraz: trochę Objective‑C, trochę starego Swifta 2/3, trochę nowszego, plus biblioteki zewnętrzne. To naturalne, ale bez planu modernizacji taki miks zaczyna coraz mocniej ciążyć.
Presja biznesowa kontra „działający” legacy
Typowy argument: „Aplikacja działa, użytkownicy są zadowoleni, po co ruszać kod?”. Problem w tym, że wymagania biznesowe zmieniają się szybciej niż kiedykolwiek: rozbudowane animacje, live data, synchronizacja w tle, obsługa wielu kont czy integracje z usługami Apple (WidgetKit, App Intents, Live Activities). Dokładanie takich funkcji do starej, callbackowej bazy kodu jest drogie i podatne na błędy.
Legacy samo w sobie nie jest złem. Problem pojawia się, gdy:
- czas wdrażania nowych funkcji rośnie wykładniczo,
- bugi współbieżności (crashe, race conditions) wracają jak bumerang,
- nowe osoby w zespole tygodniami „rozplątują” GCD, completion handlery i zagnieżdżone delegaty, zanim cokolwiek pewnie zmienią,
- testy integracyjne są niestabilne, bo asynchroniczność jest trudna do kontrolowania.
Modernizacja kodu Swifta na iOS to odpowiedź na te konkretne bóle, a nie „refaktoryzacja dla sportu”. Przejście na async/await, aktorów i nowoczesne API zmniejsza złożoność mentalną, a to przekłada się bezpośrednio na czas developmentu i jakość.
Realne korzyści z przejścia na nowoczesne API
Najbardziej namacalne efekty modernizacji to:
- czytelniejszy przepływ asynchroniczny – zamiast piramid callbacków powstaje kod podobny do synchronicznego, który łatwiej prześledzić,
- mniej błędów współbieżności – aktorzy i izolacja danych redukują liczbę wyścigów danych i crashy „tylko w produkcji”,
- łatwiejszy onboarding nowych developerów – współczesny Swift jest dużo bliższy temu, czego uczą tutoriale i dokumentacja Apple, niż „hybryda z 2016 roku”,
- czystsze API aplikacji – zamiast mieszanki delegatów, notification center i callbacków, jedna spójna warstwa dostępu np. z async/await,
- lepsze testowanie – Swift concurrency dobrze współpracuje z Xcode i XCTest, a kod asynchroniczny staje się deterministyczny.
Te korzyści najbardziej widoczne są w dużych projektach, ale nawet w mniejszej aplikacji zamiana „gęstej” sieci callbacków na async/await często zwraca się przy pierwszych większych zmianach w logice biznesowej.
Różne punkty startowe: mała aplikacja, legacy monster, hybryda Obj‑C/Swift
Sposób podejścia do migracji będzie inny, gdy:
- aplikacja jest niewielka, powstała głównie w Swift, ale używa starych API,
- projekt to wieloletni „monolit” w Objective‑C, z doklejonymi modułami w Swifcie,
- zespół ma rozbudowaną architekturę z RxSwift/Combine i chce wejść w Swift concurrency bez przepisywania wszystkiego.
W każdym z tych scenariuszy da się zaplanować stopniowe przejście: od izolowania nowego kodu w modułach Swift Package Manager, przez tworzenie fasad nad Objective‑C, aż po bezbolesne „przerabianie” endpointów sieciowych moduł po module. Kluczowe, by kubek po kubku rozbierać dług technologiczny, zamiast rzucać się na wielomiesięczną rewolucję.

Podstawy nowoczesnego Swifta pod iOS – fundament przed concurrency
Bezpieczeństwo typów: opcjonalne, wartości i immutability
Swift z definicji promuje bezpieczeństwo: opcjonalne, typy wartości, kontrola mutowalności. Zanim kod aplikacji wejdzie głębiej w concurrency, dobrze jest doprowadzić te fundamenty do porządku. Kod pełen !, niekonsekwentnie wykorzystywane opcjonalne albo nadmierne używanie klas to prosta droga do dziwnych błędów – także w środowisku współbieżnym.
Przykładowa transformacja typowa dla modernizacji:
// Stary styl
class User {
var id: String!
var name: String!
var age: Int?
}
// Nowocześniej i bezpieczniej
struct User {
let id: String
let name: String
let age: Int?
}
Zmiana na struct i wyeliminowanie wymuszonych opcjonalnych to nie tylko estetyka. Typy wartości są domyślnie bezpieczniejsze w kontekście współbieżności – kopiowanie zamiast współdzielenia stanu redukuje ryzyko wyścigów danych, gdy model krąży między różnymi zadaniami async.
Protokoły, extension i wygodne API zamiast ciężkich klas
W nowoczesnym Swifcie API projektuje się głównie w oparciu o protokoły i kompozycję. Uproszczony przykład warstwy sieciowej:
protocol Networking {
func get<T: Decodable>(_ path: String) async throws -> T
}
struct URLSessionNetworking: Networking {
let baseURL: URL
func get<T: Decodable>(_ path: String) async throws -> T {
let url = baseURL.appendingPathComponent(path)
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(T.self, from: data)
}
}
W starszych projektach często można spotkać potężne singletony, które robią „wszystko” – pobierają dane, parsują JSON, cache’ują, obsługują błędy. Rozbicie ich na protokoły i małe implementacje upraszcza migrację do async/await, bo nowy kod można wprowadzać krok po kroku, podmieniając konkretne implementacje bez grzebania w całej aplikacji.
Nowoczesne wzorce: dependency injection i kompozycja
Kiedy kontroler widoku sam tworzy swoje zależności, migracja staje się trudna. Każda zmiana interfejsu warstwy sieciowej wymaga dotykania wielu ekranów naraz. Wprowadzenie prostego dependency injection (manualnego, bez ciężkich frameworków) jest praktycznym krokiem przed wejściem w concurrency.
final class ProfileViewModel {
private let networking: Networking
init(networking: Networking) {
self.networking = networking
}
func loadProfile() async throws -> UserProfile {
try await networking.get("/me")
}
}
Gdy API Networking zyska async/await, wystarczy zmienić jedną definicję protokołu. Dla świata zewnętrznego interfejs ProfileViewModel pozostanie stabilny, a migracja przejdzie niemal niezauważalnie.
Modularizacja: frameworki i SPM jako przygotowanie pola
Rozbicie aplikacji na moduły (frameworki, pakiety SPM) pomaga w kontrolowaniu zakresu migracji. Zamiast „przepisywać wszystko”, łatwiej ustalić, że:
- moduł
NetworkingiAuthprzechodzą na async/await w pierwszej kolejności, - moduł
UIComponentsna razie zostaje na starych API, - komunikacja między modułami odbywa się przez jasno zdefiniowane protokoły.
Swift Package Manager ułatwia takie podejście. Wystarczy wydzielić istniejące foldery w osobne pakiety i zadbać o jawne zależności. Przy okazji ujawniają się cykle w importach, które utrudniają migrację – to dobry moment, by je rozwiązać.
Wspólny styl kodu przed refaktoryzacją
Duża modernizacja bez wspólnego stylu kodu szybko zamienia się w chaos. Jedna osoba migruje do async/await, inna wprowadza Combine, a trzecia nadal dopisuje funkcje z completion handlerami. Zanim prace ruszą na dobre, warto ustalić kilka prostych reguł:
- kiedy używamy
throws, a kiedyResult, - jak nazywamy funkcje asynchroniczne (np. bez dopisków
Async, bo informacją jest słowo kluczoweasync), - które nowe moduły muszą już korzystać z async/await,
- jak podchodzimy do przejściowych „mostów” między starym a nowym kodem.
Linter (SwiftLint) i proste wytyczne w repozytorium (README, CONTRIBUTING) mocno zmniejszają tarcie komunikacyjne w zespole, gdy wiele osób dotyka tych samych fragmentów kodu w trakcie migracji.

Swift concurrency – model myślenia zamiast tylko „nowe słowa kluczowe”
Co zmienia Swift concurrency względem GCD i callbacków
GCD jest niskopoziomowym narzędziem: zarządza kolejkami, wątkami, blokami kodu. Programista ręcznie decyduje, co gdzie i kiedy wykonać. Swift concurrency wprowadza warstwę abstrakcji nad tym mechanizmem.
Różnice w skrócie:
| Aspekt | GCD / callbacki | Swift concurrency |
|---|---|---|
| Styl kodu | Piramidy callbacków, dispatch_async | Linearny przepływ z async/await |
| Współbieżność | Ręczne zarządzanie kolejkami | Strukturalna współbieżność, hierarchia zadań |
| Bezpieczeństwo danych | Dowolny dostęp z wielu wątków | Aktorzy, izolacja, @MainActor |
| Anulowanie zadań | Brak spójnego modelu | Wbudowane wsparcie, propagacja w dół |
| Testowanie | Często skomplikowane i kruche | Wsparcie w XCTest, deterministyczne ścieżki |
Najważniejsza zmiana: zamiast myśleć o „wątkach” i „kolejkach”, myślimy o zadaniach i przepływie danych. W większości aplikacji iOS w ogóle nie trzeba dotykać poziomu wątków – wystarczy pilnować izolacji danych (aktorzy) i poprawnego użycia @MainActor dla UI.
Async/await jako prosty sposób na asynchroniczność
Zagnieżdżone completion handlery to najczęstsze źródło bólu w starszych projektach. Porównanie podejścia:
// Stary kod z callbackami
func loadUser(id: String, completion: @escaping (Result<User, Error>) -> Void) {
apiClient.fetchUser(id: id) { result in
switch result {
case .success(let user):
cache.save(user: user) { cacheResult in
switch cacheResult {
case .success:
completion(.success(user))
case .failure(let error):
completion(.failure(error))
}
}
case .failure(let error):
completion(.failure(error))
}
}
}
// Po migracji na async/await
func loadUser(id: String) async throws -> User {
let user = try await apiClient.fetchUser(id: id)
try await cache.save(user: user)
return user
}
Wersja z async/await jest nie tylko krótsza. Przede wszystkim jest czytelna: widać, że użytkownik jest pobierany, cache’owany i zwracany. Błędy propagują się przez throws, a Xcode wymusza ich obsługę, więc ryzyko pominięcia edge case’ów spada.
Strukturalna współbieżność: Task i TaskGroup
Strukturalna współbieżność oznacza, że zadania asynchroniczne „należą” do konkretnego kontekstu i kończą się, zanim ten kontekst się zakończy. Przykładowo: funkcja, która równolegle pobiera kilka zasobów:
struct DashboardData {
let user: User
let notifications: [Notification]
let stats: Stats
}
func loadDashboard() async throws -> DashboardData {
async let user = apiClient.fetchUser()
async let notifications = apiClient.fetchNotifications()
async let stats = apiClient.fetchStats()
return try await DashboardData(
user: user,
notifications: notifications,
stats: stats
)
}
System sam zadba o równoległe wykonanie zadań, a w przypadku błędu przerwie całą grupę. Nie trzeba manualnie używać DispatchGroup ani liczyć, które zapytanie już wróciło. Dodatkowo, anulowanie zadania nadrzędnego (np. użytkownik opuścił ekran) propaguje się w dół do podzadań.
Aktorzy i @MainActor – izolacja danych i bezpieczeństwo UI
Aktorzy w praktyce: stan współdzielony bez wyścigów danych
Najtrudniejszy element migracji do współbieżności to fragmenty, gdzie wiele części aplikacji dotyka tego samego stanu: cache, sesja użytkownika, kolejka zadań w tle. To typowe miejsca na aktora.
actor UserSession {
private(set) var currentUser: User?
func update(user: User) {
self.currentUser = user
}
func logout() {
self.currentUser = nil
}
}
Każdy dostęp do aktora jest z natury asynchroniczny:
let session = UserSession()
func handleLoginSuccess(user: User) async {
await session.update(user: user)
// ...
}
Nie ma tu DispatchQueue, nie ma manualnych locków. Kompilator wymusza użycie await, gdy sięgasz po stan aktora z zewnątrz. To jasny sygnał: „tu może dojść do zawieszenia, inne zadania też będą miały szansę wykonać swoje operacje”.
Częsta obawa przy migracji: „aktor spowolni aplikację, bo wszystko będzie async”. W praktyce zyskujesz przewidywalność. Zamiast losowych crashy po kilku dniach testów, masz klarowny, serializowany dostęp do wrażliwego stanu.
@MainActor i integracja z UIKit / SwiftUI
UI potrzebuje gwarancji, że jego stan jest modyfikowany wyłącznie z głównego wątku. @MainActor daje formalny kontrakt między modelem a widokiem.
@MainActor
final class ProfileViewController: UIViewController {
private let viewModel: ProfileViewModel
init(viewModel: ProfileViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
// ...
func showProfile(_ profile: UserProfile) {
// Modyfikacje UI bez ręcznego DispatchQueue.main.async
nameLabel.text = profile.name
}
}
Jeśli z innego kontekstu spróbujesz wywołać metodę @MainActor bez await, kompilator zaprotestuje. To duża pomoc przy stopniowej migracji z miejsc, gdzie dawniej wrzucało się „na wszelki wypadek” DispatchQueue.main.async w wielu losowych miejscach.
W SwiftUI oznaczenie widoku lub modelu widoku @MainActor to prosty sposób, by powiązany z UI stan zawsze był poprawnie izolowany:
@MainActor
final class DashboardViewModel: ObservableObject {
@Published private(set) var state: State = .idle
private let networking: Networking
init(networking: Networking) {
self.networking = networking
}
func load() async {
state = .loading
do {
let data: DashboardData = try await networking.get("/dashboard")
state = .loaded(data)
} catch {
state = .error(error)
}
}
}
Łączenie starego kodu z nowym: mosty między callbackami a async/await
Migracja rzadko oznacza przepisanie wszystkiego. Częściej przechodzisz przez etap, gdzie część modułów żyje jeszcze w świecie callbacków lub Combine. Wtedy pomocne są małe „mosty”, które otulają stare API.
Callback → async/await
Dla funkcji z completion handlerem można stworzyć nakładkę używając withCheckedThrowingContinuation:
func loadImage(url: URL,
completion: @escaping (Result<UIImage, Error>) -> Void) {
legacyImageLoader.load(url: url, completion: completion)
}
func loadImage(url: URL) async throws -> UIImage {
try await withCheckedThrowingContinuation { continuation in
loadImage(url: url) { result in
switch result {
case .success(let image):
continuation.resume(returning: image)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
W nowym kodzie używasz wyłącznie wersji asynchronicznej, a starsze fragmenty nadal wołają funkcję z callbackiem. To prosty sposób, by zmniejszyć obszar „starej asynchroniczności” bez jednorazowego, dużego refaktoringu.
Async/await → callback
Czasem integrujesz się z biblioteką, która wymaga callbacków (np. starsze SDK). Można wtedy „odwrócić” kierunek:
func fetchUser(id: String,
completion: @escaping (Result<User, Error>) -> Void) {
Task {
do {
let user = try await apiClient.fetchUser(id: id)
completion(.success(user))
} catch {
completion(.failure(error))
}
}
}
Czytelne rozdzielenie: stary świat rozmawia przez callbacki, nowy świat ma czyste async. Nie trzeba nachodzić się po projekcie i zmieniać wszystkich wywołań jednocześnie.
Concurrency a Combine i RxSwift – spokojne współistnienie
W wielu aplikacjach obecne są już Combine lub RxSwift. Zwykle nie ma sensu wyrzucać całej reaktywnej infrastruktury tylko po to, by „wszędzie mieć async/await”. Dobrze działa podejście hybrydowe.
Dla Combine można wprowadzić cienką warstwę adaptującą strumienie do async/await:
import Combine
extension Publisher {
func values() -> AsyncThrowingStream<Output, Error> {
AsyncThrowingStream { continuation in
let cancellable = self.sink(
receiveCompletion: { completion in
switch completion {
case .finished:
continuation.finish()
case .failure(let error):
continuation.finish(throwing: error)
}
},
receiveValue: { value in
continuation.yield(value)
}
)
continuation.onTermination = { _ in
cancellable.cancel()
}
}
}
}
Później można zużyć taki publisher jak strumień asynchroniczny:
for try await value in somePublisher.values() {
// logika async
}
Podobne podejście można zastosować do RxSwift przy użyciu AsyncThrowingStream lub dopasowanych helperów. Mieszane architektury są w porządku, dopóki granica między „reaktywnym światem” a „async/await światem” jest jasna i utrzymywana w jednym miejscu, a nie rozlana po całym kodzie.
Migracja istniejących API krok po kroku
Przepisywanie dużego API w całości jest ryzykowne. Bezpieczniej wprowadzać async/await warstwami, zaczynając od miejsc, które:
- mają czytelne, publiczne protokoły (np.
Networking,Auth,Storage), - są często używane, co da szybki zwrot z inwestycji,
- są względnie „odizolowane” od reszty (mało zależności zwrotnych).
Przykładowy plan migracji warstwy sieciowej:
- Dodanie nowych metod
asyncobok istniejących metod z callbackami w protokole, oznaczenie starych jako@available(*, deprecated). - Stopniowa wymiana wywołań w modułach domenowych: każdy nowy kod używa już async/await, istniejący kod sukcesywnie przepisywany przy okazji zmian.
- Gdy odsetek starych wywołań spadnie do niewielkiego poziomu, usunięcie starych metod z protokołu, pozostawienie adaptera dla naprawdę trudnych przypadków.
Taki scenariusz minimalizuje jednorazowy koszt – refaktoryzacja odbywa się „przy okazji”, bez paraliżowania developmentu na tygodnie.
Bezpieczne użycie Task: ogień kontrolowany
Task { } kusi, bo łatwo nim „odpalić coś w tle”. Problem zaczyna się, gdy takie wywołania pojawiają się wszędzie i przestajesz kontrolować cykl życia zadań.
Dobre zasady praktyczne:
- w widokach i kontrolerach preferować
Task { [weak self] in ... }powiązane z cyklem życia obiektu, - dla dłużej żyjących zadań (np. synchronizacja w tle) używać
Task.detachedświadomie, często z dedykowanym menedżerem, - przy długich pętlach (np. pobieranie strumienia danych) regularnie sprawdzać
Task.isCancelledlub używać funkcji weryfikujących anulowanie.
func startSync() {
syncTask = Task {
do {
while !Task.isCancelled {
try await syncOnce()
try await Task.sleep(nanoseconds: 5 * 1_000_000_000)
}
} catch is CancellationError {
// zadanie zostało anulowane – można posprzątać
} catch {
// log błędu
}
}
}
func stopSync() {
syncTask?.cancel()
syncTask = nil
}
Takie podejście przypomina „kontrolowany ogień”: zadanie jest jasno wytłumaczone, gdzie powstaje, komu „należy” i kto je zatrzyma.
Testowanie kodu opartego na async/await
Async/await mocno upraszcza testy, ale początkowo może onieśmielać. Klucz tkwi w tym, by traktować funkcje asynchroniczne tak samo jak synchroniczne – z wyraźnymi zależnościami wstrzykiwanymi przez konstruktor.
final class ProfileViewModelTests: XCTestCase {
func testLoadProfileSuccess() async throws {
let networking = NetworkingStub(result: .success(UserProfile.mock))
let sut = ProfileViewModel(networking: networking)
let profile = try await sut.loadProfile()
XCTAssertEqual(profile.id, UserProfile.mock.id)
}
}
struct NetworkingStub: Networking {
let result: Result<UserProfile, Error>
func get<T>(_ path: String) async throws -> T where T : Decodable {
switch result {
case .success(let profile as T):
return profile
case .success:
fatalError("Unexpected type")
case .failure(let error):
throw error
}
}
}
Testy async można normalnie odpalać z poziomu Xcode, a framework zadba o to, by poczekać na zakończenie zadań. W wielu zespołach pojawia się miłe zaskoczenie: znikają dziwne oczekiwania z expectation i ręcznym timeoutem.
Migracja ze starego Objective‑C i mieszanych projektów
Spora część dojrzałych aplikacji iOS ma wciąż moduły w Objective‑C. Dobra wiadomość: nie trzeba ich od razu przepisywać na Swift, żeby skorzystać z concurrency w nowych częściach.
Typowy, bezpieczny schemat:
- wystawienie cienkiej warstwy w Swifcie nad kluczowymi klasami Objective‑C (np.
LegacyAPIClient), - owinięcie metod z blokami w funkcje
asyncprzy pomocy kontynuacji, - stopniowa rezygnacja z bezpośrednich importów nagłówków Objective‑C w nowych modułach na rzecz tej warstwy pośredniej.
// Objective-C
@interface LegacyAPIClient : NSObject
- (void)fetchUserWithId:(NSString *)userId
completion:(void (^)(User * _Nullable, NSError * _Nullable))completion;
@end
// Swift wrapper
struct ModernAPIClient {
private let client: LegacyAPIClient
init(client: LegacyAPIClient) {
self.client = client
}
func fetchUser(id: String) async throws -> User {
try await withCheckedThrowingContinuation { continuation in
client.fetchUser(withId: id) { user, error in
if let error {
continuation.resume(throwing: error)
} else if let user {
continuation.resume(returning: user)
} else {
continuation.resume(throwing: UnexpectedNilError())
}
}
}
}
}
Nowe moduły widzą już tylko ModernAPIClient. Kod Objective‑C powoli „spycha się” do roli detalu implementacyjnego zamiast centralnego elementu architektury.
Planowanie migracji: od łatwych zwycięstw do głębokich zmian
Zespoły często odkładają modernizację, bo boją się „dużego projektu bez końca”. Rozsądniej jest rozbić pracę na małe, widoczne kroki i traktować je jak zwykłe zadania rozwojowe.
Przykładowa ścieżka na kilka iteracji:
- Wprowadzenie prostego dependency injection i rozdzielenie dużych singletonów na protokoły + implementacje.
- Modularizacja fragmentów aplikacji (np.
Networking,Auth,Persistence), rozpisanie zależności. - Dodanie pierwszych aktorów tam, gdzie od dawna są „tajemnicze crashe” związane z wielowątkowością.
- Migracja pojedynczego przepływu użytkownika (np. logowanie) w całości na async/await, łącznie z widokami.
- Poszerzanie zakresu o kolejne przepływy, jednocześnie upraszczając stare API przez adaptery.
Takie tempo pozwala jednocześnie utrzymywać produkt, poprawiać dług technologiczny i uczyć się concurrency na żywym projekcie, bez wrażenia „rewolucji z dnia na dzień”.
Nowoczesne API wokół URLSession i URLSessionWebSocketTask
Warstwa sieciowa to jedno z miejsc, gdzie zysk z concurrency jest odczuwalny najszybciej. Nawet jeśli w projekcie żyje jeszcze stary wrapper na URLSession z callbackami, można stopniowo wprowadzać nowe, asynchroniczne API obok niego.
Podstawowy przykład pobrania danych z HTTP przy użyciu wbudowanego wsparcia:
protocol HTTPClient {
func get(_ url: URL) async throws -> (Data, HTTPURLResponse)
}
struct URLSessionHTTPClient: HTTPClient {
let session: URLSession
func get(_ url: URL) async throws -> (Data, HTTPURLResponse) {
let (data, response) = try await session.data(from: url)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkingError.invalidResponse
}
return (data, httpResponse)
}
}
Wyżej można dobudować lepsze, domenowe API, które od razu zwraca zdekodowane modele i sprawdza status kodu:
protocol Networking {
func get<T: Decodable>(_ endpoint: Endpoint) async throws -> T
}
struct DefaultNetworking: Networking {
let client: HTTPClient
let decoder: JSONDecoder
func get<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
let (data, response) = try await client.get(endpoint.url)
guard (200..<300).contains(response.statusCode) else {
throw NetworkingError.http(code: response.statusCode)
}
do {
return try decoder.decode(T.self, from: data)
} catch {
throw NetworkingError.decoding(error)
}
}
}
W istniejących miejscach, gdzie wciąż działa stary wrapper, można dodać tylko cienki adapter nowego API na stare, a nie odwrotnie. Dzięki temu każda nowa funkcja korzysta z async/await, a stare endpointy powoli „dołączają do peletonu”.
Podobny manewr da się zrobić dla WebSocketów. Systemowy URLSessionWebSocketTask można zamodelować jako asynchroniczny strumień wiadomości:
struct WebSocketMessageStream: AsyncSequence {
typealias Element = URLSessionWebSocketTask.Message
struct AsyncIterator: AsyncIteratorProtocol {
let task: URLSessionWebSocketTask
mutating func next() async throws -> Element? {
guard task.state == .running else {
return nil
}
let message = try await task.receive()
return message
}
}
let task: URLSessionWebSocketTask
func makeAsyncIterator() -> AsyncIterator {
task.resume()
return AsyncIterator(task: task)
}
}
let webSocketTask = urlSession.webSocketTask(with: url)
let stream = WebSocketMessageStream(task: webSocketTask)
Task {
do {
for try await message in stream {
// obsługa przychodzących wiadomości
}
} catch {
// obsługa błędu połączenia
}
}Dzięki takiemu podejściu logika przestaje myśleć w kategoriach „callbacków na receive”, a zaczyna „czytać” z asynchronicznego strumienia, jak z pliku czy kolejki.
Projektowanie nowych API „pod concurrency”
Gdy pojawia się okazja napisania nowego modułu od zera, pojawia się też pokusa odwzorowania starych wzorców jeden do jednego. Dużo zdrowiej jest od razu myśleć o async/await jako o pierwszym obywatelu, a nie doklejonej warstwie.
Kilka praktyk, które dobrze się sprawdzają:
- publiczne protokoły niech eksponują przede wszystkim funkcje
async, a nie callbacki, - przeciążenia z completion handlerem udostępniaj w warstwie adapterów, blisko legacy,
- zamiast zwracać „półprodukt” (np.
URLRequestdo samodzielnego odpalenia) zwracaj gotowe efekty, o ile nie potrzebujesz większej elastyczności.
protocol AuthService {
func login(email: String, password: String) async throws -> UserSession
func logout() async throws
func refreshTokenIfNeeded() async throws -> UserSession
}Jeśli później wyjdzie na jaw, że część systemu nadal żyje w świecie callbacków (np. biblioteka zewnętrzna), można dorobić małą nakładkę:
extension AuthService {
func login(
email: String,
password: String,
completion: @escaping (Result<UserSession, Error>) -> Void
) {
Task {
do {
let session = try await login(email: email, password: password)
completion(.success(session))
} catch {
completion(.failure(error))
}
}
}
}W efekcie rdzeń aplikacji zaczyna „myśleć” w async/await, a callbacki pozostają jedynie pobocznym sposobem integracji z resztą świata.
Architektura ViewModeli i widoków pod async/await
Przesiadka z callbacków lub Rx na async/await często budzi obawę: jak teraz aktualizować UI, jak reagować na kolejne stany? Zamiast skomplikowanych drzew sygnałów, można zastosować prosty wzorzec z asynchronicznymi metodami w ViewModelach i obserwowanym stanem.
@MainActor
final class ProfileViewModel: ObservableObject {
@Published private(set) var state: State = .idle
private let networking: Networking
init(networking: Networking) {
self.networking = networking
}
enum State {
case idle
case loading
case loaded(UserProfile)
case error(String)
}
func load() async {
state = .loading
do {
let profile: UserProfile = try await networking.get(.profile)
state = .loaded(profile)
} catch {
state = .error("Nie udało się pobrać profilu")
}
}
}W SwiftUI taki ViewModel komponuje się naturalnie:
struct ProfileScreen: View {
@StateObject var viewModel: ProfileViewModel
var body: some View {
content
.task {
await viewModel.load()
}
}
@ViewBuilder
private var content: some View {
switch viewModel.state {
case .idle, .loading:
ProgressView()
case .loaded(let profile):
Text(profile.name)
case .error(let message):
Text(message)
}
}
}W UIKit można uzyskać podobny efekt, korzystając z Task w kontrolerach widoków i aktualizując UI na głównym aktorze:
final class ProfileViewController: UIViewController {
private let viewModel: ProfileViewModel
private var loadTask: Task<Void, Never>?
// ...
override func viewDidLoad() {
super.viewDidLoad()
loadTask = Task { [weak self] in
await self?.viewModel.load()
}
}
deinit {
loadTask?.cancel()
}
}Dzięki oznaczeniu ViewModelu adnotacją @MainActor łatwo zachować bezpieczeństwo względem UI, bez ręcznego pilnowania DispatchQueue.main.async w każdym miejscu.
Izolowanie stanu w aktorach domenowych
Spornym punktem bywa „gdzie używać aktorów”. Wbrew pozorom nie trzeba oklejać adnotacją actor połowy projektu. Sporo zyskują przede wszystkim te fragmenty, które:
- mają wewnętrzny stan łatwy do popsucia przez wiele wątków,
- były już kiedyś źródłem trudnych do odtworzenia crashy,
- obsługują kolejkę zadań, cache, licznik lub „małą bazę” w pamięci.
Przykład: prosty cache z automatyczną synchronizacją z dyskiem.
actor UserCache {
private var users: [String: User] = [:]
private let storage: UserStorage
init(storage: UserStorage) {
self.storage = storage
}
func user(id: String) async throws -> User {
if let cached = users[id] {
return cached
}
let stored = try await storage.loadUser(id: id)
users[id] = stored
return stored
}
func save(_ user: User) async throws {
users[user.id] = user
try await storage.saveUser(user)
}
func clear() async throws {
users.removeAll()
try await storage.deleteAll()
}
}Wywołujący nie musi się zastanawiać, czy UserCache jest thread-safe – aktor gwarantuje sekwencyjny dostęp do stanu, a jednocześnie pozwala klientom pracować asynchronicznie.
Mapowanie wzorców z GCD i OperationQueue na structured concurrency
W dojrzałych projektach często widać rozbudowane konstrukcje oparte na GCD albo OperationQueue. Nie ma sensu przepisywać ich na siłę, ale przy okazji zmian można przekładać konkretne fragmenty na bardziej czytelne odpowiedniki.
Typowy przykład: sekwencja kroków wykonywana kiedyś na kolejce szeregowej.
// dawniej
queue.async {
loadConfig { result in
switch result {
case .success(let config):
validate(config) { isValid in
if isValid {
startApp(with: config)
} else {
showError()
}
}
case .failure:
showError()
}
}
}Tę samą logikę można zapisać wprost, w jednym miejscu:
func bootstrapApp() async {
do {
let config = try await loadConfig()
try await validate(config)
try await startApp(with: config)
} catch {
await showError()
}
}Bardziej rozbudowane zależności między zadaniami da się odwzorować przy pomocy async let lub grup zadań.
func loadInitialData() async throws -> (User, [Project], [Notification]) {
async let user = userService.currentUser()
async let projects = projectService.fetchProjects()
async let notifications = notificationService.fetchNotifications()
return try await (user, projects, notifications)
}Jeśli w projekcie istnieje rozbudowana hierarchia Operation, część zadań można zacząć opisywać jako zwykłe funkcje async, a kolejki używać jedynie tam, gdzie rzeczywiście potrzebne są funkcje specyficzne dla Operation (priorities, dependencies z zewnętrznymi komponentami itd.).
Obsługa błędów w świecie async/await
Przejście na concurrency często odświeża podejście do błędów. Zamiast przekazywać błąd w callbacku, można go normalnie „wyrzucić” i złapać na wyższym poziomie. To upraszcza ścieżki sukcesu i porządkuje ścieżki awarii.
enum AuthError: Error {
case invalidCredentials
case network(Error)
case server(String)
}
protocol AuthAPI {
func login(email: String, password: String) async throws -> AuthToken
}
struct DefaultAuthAPI: AuthAPI {
let networking: Networking
func login(email: String, password: String) async throws -> AuthToken {
do {
return try await networking.post(.login(email, password))
} catch NetworkingError.http(let code) where code == 401 {
throw AuthError.invalidCredentials
} catch let NetworkingError.decoding(error) {
throw AuthError.server("Niepoprawny format odpowiedzi: (error)")
} catch {
throw AuthError.network(error)
}
}
}Wyżej można zagregować błędy domenowe i przetłumaczyć je na komunikaty do użytkownika lub zdarzenia analityczne:
@MainActor
final class LoginViewModel: ObservableObject {
@Published var errorMessage: String?
private let authAPI: AuthAPI
init(authAPI: AuthAPI) {
self.authAPI = authAPI
}
func login(email: String, password: String) async {
do {
_ = try await authAPI.login(email: email, password: password)
// sukces – przejście dalej
} catch let error as AuthError {
switch error {
case .invalidCredentials:
errorMessage = "Nieprawidłowy e-mail lub hasło."
case .network:
errorMessage = "Brak połączenia z siecią."
case .server(let message):
errorMessage = message
}
} catch {
errorMessage = "Wystąpił nieoczekiwany błąd."
}
}
}Asynchroniczny kod przestaje być „osobnym światem” z własną konwencją błędów, a zaczyna podlegać tym samym zasadom co reszta logiki domenowej.
Nieliniowe przepływy użytkownika i asynchroniczne koordynatory
W aplikacjach produktowych rzadko chodzi tylko o pobranie danych i ich wyświetlenie. Pojawiają się złożone przepływy: logowanie dwustopniowe, kreatory, płatności, onboarding. Strukturalna współbieżność dobrze się łączy z „koordynatorami”, które opisują scenariusze jako sekwencje asynchronicznych kroków.
@MainActor
protocol LoginFlowCoordinator {
func start() async
}
final class DefaultLoginFlowCoordinator: LoginFlowCoordinator {
private unowned let navigationController: UINavigationController
private let authAPI: AuthAPI
init(navigationController: UINavigationController, authAPI: AuthAPI) {
self.navigationController = navigationController
self.authAPI = authAPI
}
func start() async {
// krok 1: dane logowania
let credentials = await presentCredentialsScreen()
// krok 2: próba logowania
let token = try? await authAPI.login(
email: credentials.email,
password: credentials.password
)
guard let token else {
await presentErrorAndRetry()
return await start()
}
// krok 3: ewentualne 2FA
if token.requiresTwoFactor {
let code = await presentTwoFactorScreen()
try? await authAPI.confirmTwoFactor(code: code)
}
// krok 4: zakończenie
await finish()
}
private func presentCredentialsScreen() async -> Credentials {
// prezentacja kontrolera, czekanie na wynik
}
private func presentTwoFactorScreen() async -> String {
// podobnie
}
private func presentErrorAndRetry() async {
// komunikat i czekanie na decyzję użytkownika
}
private func finish() async {
// przejście do głównego ekranu
}
}Asynchroniczne metody w koordynatorze opisują naturalny przebieg scenariusza. Poszczególne ekrany mogą zwracać wynik za pomocą CheckedContinuation lub prostych kanałów komunikacji, zamiast rozsyłać powiadomienia po całej aplikacji.
Stopniowa rezygnacja z globalnych singletonów
Concurrency uwypukla problemy z globalnym stanem. Jeden współdzielony APIClient.shared czy SessionManager.shared utrudnia kontrolę nad dostępem i cyklem życia zasobów. Nie trzeba usuwać ich w jeden dzień, ale można powoli zamykać je w strukturze zależności.
Praktyczny kierunek zmian:
- zacząć wstrzykiwać singletony przez inicjalizatory w nowych klasach,
Najczęściej zadawane pytania (FAQ)
Czy naprawdę opłaca się modernizować działającą aplikację iOS na Swift?
Tak, szczególnie jeśli aplikacja rozwijana jest od kilku lat i rośnie liczba funkcji. „Działający” legacy często oznacza coraz wolniejsze dostarczanie zmian, rosnącą liczbę błędów współbieżności i trudny onboarding nowych osób. To nie wychodzi na jaw od razu, ale mocno podnosi koszt każdej kolejnej funkcji.
Przejście na nowoczesne API (async/await, aktorzy, lepsze modele danych) obniża złożoność kodu. Łatwiej debugować, testy są stabilniejsze, a developerzy nie tracą czasu na rozplątywanie callbacków i GCD. Zwykle pierwsze większe zadanie biznesowe na nowym kodzie „spłaca” znaczną część inwestycji w modernizację.
Od czego zacząć migrację starego projektu Swift/Objective‑C na nowoczesny Swift?
Bezpieczny start to porządkowanie fundamentów, zanim w grę wejdzie concurrency. Najpierw uporządkuj typy: usuń wymuszone opcjonalne (!), zamieniaj klasy na struct tam, gdzie model jest tylko „danymi”, wprowadź spójne zasady immutability (let zamiast var, gdy obiekt nie musi się zmieniać).
Kolejne kroki, które dobrze się sprawdzają w praktyce:
- wydzielenie modułów (np. Networking, Auth) do osobnych frameworków lub pakietów SPM,
- wprowadzenie prostego dependency injection, żeby kontrolery nie tworzyły wszystkiego same,
- dodanie cienkich fasad nad Objective‑C, tak by nowy kod w Swifcie widział już „czyste” API.
Dopiero na takim fundamencie migracja do async/await i aktorów jest spokojniejsza i mniej ryzykowna.
Jak migrować z callbacków i GCD do async/await w iOS bez przepisywania całej aplikacji?
Najbezpieczniej robić to warstwowo. Zamiast usuwać wszystkie completion handlery naraz, twórz małe, dobrze zdefiniowane interfejsy (protokoły), które po kolei zyskują async/await. Przykład: zaczynasz od modułu sieciowego, opakowując istniejące metody z callbackami w funkcje async, a reszta aplikacji nadal może korzystać z „starego” API.
Praktyczny schemat wygląda tak:
- zdefiniuj protokół np.
Networkingz metodami async, - w implementacji użyj starego kodu na GCD/callbackach, mostkując go do async/await (np. przez
withCheckedContinuation), - stopniowo podmieniaj miejsca wywołań w aplikacji, aż callbacki przestaną być potrzebne.
Dzięki temu unikasz „big bang refactoru” i cały czas masz działającą aplikację.
Jakie realne korzyści daje przejście na Swift concurrency (async/await, aktorzy)?
Najbardziej odczuwalna zmiana to uproszczenie myślenia o przepływie asynchronicznym. Kod, który wcześniej był piramidą callbacków i zagnieżdżonych delegatów, staje się liniowy i dużo bardziej czytelny. To zmniejsza ryzyko błędów i przyspiesza pracę całego zespołu.
Do tego dochodzą konkretne efekty:
- mniej crashy związanych z race conditions, dzięki izolacji danych i aktorom,
- stabilniejsze testy asynchroniczne w XCTest, łatwiejsze mockowanie i kontrola czasu,
- krótszy onboarding nowych osób, bo kod wygląda jak we współczesnych przykładach Apple, a nie jak „hybryda z 2016 roku”.
Jak pracować ze starą hybrydą Objective‑C/Swift podczas migracji na nowe API?
Duże, kilkuletnie projekty rzadko da się „odciąć” od Objective‑C. Zamiast walczyć z tym na siłę, skuteczniej jest wydzielić jasne granice: Objective‑C zostaje głębiej w warstwie „systemowej” lub legacy, a Swift dostaje czyste, dobrze zdefiniowane fasady (np. protokoły, struktury DTO), które izolują go od szczegółów implementacji.
Dobrą praktyką jest:
- tworzenie nowych modułów w całości w Swifcie (najlepiej jako pakiety SPM),
- udostępnianie tylko minimalnego, spójnego API do Objective‑C przez bridging headers,
- planowanie „wyciągania” krytycznych fragmentów z Objective‑C do Swifta moduł po module, a nie klasy po klasie bez planu.
Czy małe aplikacje i proste projekty też zyskują na async/await i nowym Swifcie?
Tak, choć skala odczuć bywa inna niż w korporacyjnym monolicie. Nawet w niewielkiej aplikacji już pierwszy większy feature, który wymaga złożonej logiki sieciowej, odświeżania w tle czy synchronizacji wielu źródeł danych, pokazuje różnicę między callbackami a async/await.
Dodatkowa korzyść to przyszłość projektu: jeśli dziś aplikacja jest mała, ale ma perspektywę rozwoju, wczesne wejście w nowoczesny Swift pozwala uniknąć późniejszej „wielkiej migracji pod presją”. Kod od początku rośnie w lepszym kierunku, co obniża dług technologiczny.
Jak zmiana klas na struct i uporządkowanie opcjonalnych wpływa na współbieżność w Swift?
Typy wartości (struct) są z natury bezpieczniejsze współbieżnie, bo domyślnie są kopiowane, a nie współdzielone między wątkami. Gdy model użytkownika, posta czy konfiguracji jest structem, trudniej przypadkiem zmodyfikować ten sam obiekt jednocześnie w kilku taskach, co redukuje ryzyko wyścigów danych.
Uporządkowanie opcjonalnych ma równie praktyczny wymiar: eliminacja wymuszonych ! i jasne modelowanie tego, co może być nil, zmniejsza liczbę losowych crashy „tylko w produkcji”. Gdy kod przechodzi na async/await i działa w wielu taskach jednocześnie, takie drobne „niewiadome” w typach szybko zaczynają boleć, więc lepiej je usunąć na starcie.
Najważniejsze wnioski
- Modernizacja Swifta na iOS nie jest fanaberią, tylko reakcją na rosnącą złożoność wymagań biznesowych – dokładanie nowych funkcji do bazy opartej na callbackach, GCD i starych API robi się coraz droższe i bardziej ryzykowne.
- Od Swifta 5 wzwyż język i iOS SDK oferują stabilny, spójny model współbieżności (async/await, aktorzy), który realnie redukuje błędy współbieżności i „niewytłumaczalne” crashe pojawiające się wyłącznie na produkcji.
- Przejście na nowoczesne API upraszcza myślenie o kodzie: przepływ asynchroniczny wygląda jak synchroniczny, modele są bezpieczniejsze typowo (struct zamiast klasy z !), a zespół nie musi śledzić zagnieżdżonych delegatów i completion handlerów.
- Lepsza architektura to też lepszy onboarding – nowi developerzy dostają kod zbliżony do aktualnych przykładów Apple, zamiast „skansenu” z mieszanką Objective‑C, Swifta 2/3 i własnych konwencji, więc szybciej zaczynają dowozić funkcje.
- Swift concurrency oraz nowoczesne podejście do API (protokoły, kompozycja, async/await) poprawiają testowalność: asynchroniczne przypadki są bardziej deterministyczne, a integracja z Xcode i XCTest ułatwia pisanie stabilnych testów.
- Migracja nie wymaga rewolucji – można ją prowadzić moduł po module: izolować nowy kod w SPM, tworzyć fasady nad Objective‑C, krokowo przepisywać endpointy sieciowe, dzięki czemu dług technologiczny maleje bez zatrzymywania rozwoju produktu.
Bibliografia
- The Swift Programming Language (Swift 5.9 Edition). Apple Inc. (2023) – Oficjalna dokumentacja języka Swift, typy wartości, opcjonalne, protokoły
- Swift Concurrency. Apple Developer Documentation – Opis async/await, aktorów, strukturalnej współbieżności w Swift na iOS
- Modern Concurrency in Swift. Apple Worldwide Developers Conference (2021) – Prezentacja WWDC o modelu współbieżności, aktorach i bezpieczeństwie danych
- Refactoring: Improving the Design of Existing Code (2nd Edition). Addison‑Wesley Professional (2018) – Klasyczne techniki refaktoryzacji i redukcji długu technologicznego







Bardzo ciekawy artykuł na temat używania języka Swift do tworzenia aplikacji na iOS. Bardzo podoba mi się, że autor skupił się na nowoczesnych API i sposobach obsługi concurrency, co na pewno pomoże programistom w efektywnym tworzeniu aplikacji. Bardzo wartościowa jest też część dotycząca migracji ze starego kodu, co może być bardzo pomocne dla tych, którzy chcą zaktualizować swoje projekty. Jednakże, brakuje mi bardziej konkretnych przykładów oraz głębszego zrozumienia problemów, na jakie można napotkać podczas migracji. Moim zdaniem, dodanie kilku case studies czy konkretnych wskazówek mogłoby jeszcze bardziej ułatwić proces migracji dla czytelników. Ogólnie jednak, artykuł jest solidny i wartościowy dla programistów iOS.
Wreszcie porządny artykuł na temat Swifta dla iOS! Cieszę się, że autor poruszył kwestię nowoczesnych API oraz migracji ze starego kodu. To naprawdę istotne zagadnienia dla wielu deweloperów pracujących w środowisku iOS. Dodatkowo, concurrency to kwestia, której nie można bagatelizować, więc super, że została poruszona w artykule. Mam nadzieję, że autorzy będą kontynuować w tej samej tematyce, bo naprawdę można się wiele dowiedzieć i usprawnić swój rozwój w programowaniu aplikacji na iOS.
Możliwość dodawania komentarzy nie jest dostępna.