Lektion 3.10 · Stufe 3 – Fortgeschritten

Daten laden mit URLSession

ca. 15 Minuten Lesezeit

Lernziel

Nach dieser Lektion kannst du mit URLSession echte Daten von einer Online-API laden, Adressen mit URLComponents sicher zusammenbauen, die Antwort prüfen, sie mit Codable dekodieren und die Netzwerklogik in einen eigenen, wiederverwendbaren Typ auslagern.

Das Problem: Die Daten liegen woanders

Bisher hast du Daten im Code selbst angelegt: Habit.examples, Book.examples. Echte Apps zeigen aber Daten, die woanders liegen: auf einem Server, in einer Datenbank im Internet. Wetter-, Nachrichten- oder Rezept-Apps funktionieren nur, weil sie diese Daten anfragen und empfangen können.

Die Werkzeuge dafür hast du in den letzten Lektionen vorbereitet:

  • async/await (Lektion 3.6), weil Netzwerkanfragen dauern,
  • .task und @MainActor (Lektion 3.7), um im richtigen Moment zu laden und die Oberfläche sicher zu aktualisieren,
  • Codable (Lektion 3.9), um die Antwort in Swift-Typen zu übersetzen.

Jetzt fehlt nur noch das Werkzeug, das die eigentliche Anfrage stellt: URLSession.

Ein Blick auf die API

In dieser Lektion und in Projekt D verwenden wir TheMealDB, eine kostenlose Rezept-Datenbank. Für Lern- und Entwicklungszwecke gibt es einen öffentlichen Testschlüssel, die 1 in der Adresse. Eine Anmeldung ist nicht nötig.

Öffne diese Adresse einmal im Browser:

Text
https://www.themealdb.com/api/json/v1/1/search.php?s=pasta

Du siehst JSON, ungefähr so (stark gekürzt):

JSON
{
  "meals": [
    {
      "idMeal": "52771",
      "strMeal": "Spicy Arrabiata Penne",
      "strCategory": "Vegetarian",
      "strArea": "Italian",
      "strMealThumb": "https://www.themealdb.com/images/media/meals/ustsqw1468250014.jpg"
    }
  ]
}

Eine Adresse besteht aus mehreren Teilen:

TeilBeispielBedeutung
Schemahttpsverschlüsselte Verbindung
Hostwww.themealdb.comder Server
Pfad/api/json/v1/1/search.phpwelche Funktion der API
Abfrage?s=pastaParameter, hier der Suchbegriff

Tipp

Schau dir vor dem Programmieren immer die Antwort einer API im Browser an. So weißt du genau, welche Felder es gibt und wie sie heißen. Die Dokumentation von TheMealDB findest du auf der Website der API.

Das Modell

Aus der Antwort bauen wir die passenden Typen, mit CodingKeys für die ungewohnten Namen (Lektion 3.9):

Swift
struct MealResponse: Decodable {
    let meals: [Meal]?
}

struct Meal: Decodable, Identifiable, Hashable {
    let id: String
    let name: String
    let category: String?
    let area: String?
    let thumbnail: String?

    enum CodingKeys: String, CodingKey {
        case id = "idMeal"
        case name = "strMeal"
        case category = "strCategory"
        case area = "strArea"
        case thumbnail = "strMealThumb"
    }
}

Beachte: meals ist optional. Findet die API nichts, liefert sie nicht etwa ein leeres Array, sondern "meals": null. Ohne Optional würde das Dekodieren dann scheitern.

Die erste Anfrage

Swift
func searchMeals(_ query: String) async throws -> [Meal] {
    guard let url = URL(string: "https://www.themealdb.com/api/json/v1/1/search.php?s=\(query)") else {
        return []
    }

    let (data, _) = try await URLSession.shared.data(from: url)
    let response = try JSONDecoder().decode(MealResponse.self, from: data)
    return response.meals ?? []
}

Drei Schritte:

  1. URL(string:) erzeugt aus einem Text eine Adresse. Das Ergebnis ist ein Optional, denn nicht jeder Text ist eine gültige Adresse.
  2. URLSession.shared.data(from: url) stellt die Anfrage und wartet auf die Antwort. Die Funktion ist async throws und liefert ein Tupel aus den empfangenen Bytes und Informationen über die Antwort. Die brauchen wir hier noch nicht, deshalb _.
  3. JSONDecoder übersetzt die Bytes in unsere Typen.

Und in einer View:

Swift
struct MealSearchView: View {
    @State private var meals: [Meal] = []

    var body: some View {
        List(meals) { meal in
            Text(meal.name)
        }
        .task {
            meals = (try? await searchMeals("pasta")) ?? []
        }
    }
}

Erwartetes Ergebnis

Nach einem kurzen Moment erscheint eine Liste mit Nudelgerichten aus der Datenbank. Echte Daten aus dem Internet.

Screenshot folgt: Liste mit Pasta-Rezepten aus TheMealDB

(try? await ...) ?? [] wandelt einen Fehler in ein leeres Array um (Lektion 1.17). Für den Anfang reicht das. In der nächsten Lektion zeigen wir Fehler richtig an.

Adressen sicher bauen: URLComponents

Was passiert, wenn der Nutzer nach „Apple Pie“ sucht, mit Leerzeichen? Oder nach „Crème brûlée“? Solche Zeichen sind in Adressen nicht erlaubt und müssen umkodiert werden. URL(string:) liefert dann womöglich nil.

Sicherer ist URLComponents, das die Teile einzeln zusammensetzt und die Sonderzeichen automatisch korrekt umwandelt:

Swift
func makeSearchURL(for query: String) -> URL? {
    var components = URLComponents()
    components.scheme = "https"
    components.host = "www.themealdb.com"
    components.path = "/api/json/v1/1/search.php"
    components.queryItems = [
        URLQueryItem(name: "s", value: query)
    ]
    return components.url
}

print(makeSearchURL(for: "Crème brûlée") ?? "ungültig")
// https://www.themealdb.com/api/json/v1/1/search.php?s=Cr%C3%A8me%20br%C3%BBl%C3%A9e

Kürzer geht es mit einer bestehenden Basisadresse:

Swift
let base = URL(string: "https://www.themealdb.com/api/json/v1/1/search.php")!
let url = base.appending(queryItems: [URLQueryItem(name: "s", value: query)])

Tipp

Das ! bei der festen Basisadresse ist eine der seltenen Stellen, an denen ein Force Unwrap vertretbar ist (Lektion 1.8, 🟡-FAQ): Die Adresse steht fest im Code und ist garantiert gültig. Wäre sie es nicht, wäre das ein Programmierfehler, den man sofort bemerken will.

Die Antwort prüfen

Eine Antwort vom Server ist nicht automatisch eine erfolgreiche Antwort. Server melden Probleme über einen Statuscode:

CodeBedeutung
200–299Erfolg
404nicht gefunden
401, 403keine Berechtigung
500–599Fehler auf dem Server

Auch bei einem 404 kommt eine Antwort an, nur eben keine mit den erwarteten Daten. Deshalb prüft man den Code:

Swift
enum APIError: Error {
    case invalidURL
    case badStatus(Int)
}

func searchMeals(_ query: String) async throws -> [Meal] {
    let base = URL(string: "https://www.themealdb.com/api/json/v1/1/search.php")!
    let url = base.appending(queryItems: [URLQueryItem(name: "s", value: query)])

    let (data, response) = try await URLSession.shared.data(from: url)

    if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
        throw APIError.badStatus(http.statusCode)
    }

    return try JSONDecoder().decode(MealResponse.self, from: data).meals ?? []
}

response as? HTTPURLResponse versucht, die allgemeine Antwort als HTTP-Antwort zu lesen. as? liefert ein Optional (Lektion 1.8): Klappt die Umwandlung nicht, ist das Ergebnis nil.

Netzwerkcode auslagern

Netzwerkcode in Views zu verteilen, macht sie unübersichtlich und schwer testbar (Lektion 3.5). Bewährt hat sich ein eigener Typ, der alle Anfragen an eine API bündelt:

Swift
struct MealAPI {
    private let baseURL = URL(string: "https://www.themealdb.com/api/json/v1/1/")!
    private let session: URLSession

    init(session: URLSession = .shared) {
        self.session = session
    }

    func search(_ query: String) async throws -> [Meal] {
        try await fetch("search.php", query: ["s": query])
    }

    func meal(id: String) async throws -> Meal? {
        try await fetch("lookup.php", query: ["i": id]).first
    }

    func random() async throws -> Meal? {
        try await fetch("random.php", query: [:]).first
    }

    private func fetch(_ path: String, query: [String: String]) async throws -> [Meal] {
        let items = query.map { URLQueryItem(name: $0.key, value: $0.value) }
        let url = baseURL.appending(path: path).appending(queryItems: items)

        let (data, response) = try await session.data(from: url)

        if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
            throw APIError.badStatus(http.statusCode)
        }

        return try JSONDecoder().decode(MealResponse.self, from: data).meals ?? []
    }
}
  • Eine private Hilfsfunktion fetch enthält die gemeinsame Logik: Adresse bauen, Anfrage stellen, Status prüfen, dekodieren.
  • Die öffentlichen Methoden search, meal(id:) und random() sind kurz und sprechend.
  • Die URLSession wird im Initializer übergeben, mit .shared als Standard. Für Tests kann man später eine andere übergeben (Lektion 4.13).

Die View wird dadurch sehr schlicht:

Swift
struct MealSearchView: View {
    @State private var meals: [Meal] = []
    private let api = MealAPI()

    var body: some View {
        List(meals) { meal in
            Text(meal.name)
        }
        .task {
            meals = (try? await api.search("pasta")) ?? []
        }
    }
}

Bilder aus dem Internet: AsyncImage

Jedes Rezept hat ein Bild, als Adresse in thumbnail. Für Bilder aus dem Internet gibt es eine fertige View:

Swift
AsyncImage(url: URL(string: meal.thumbnail ?? "")) { image in
    image
        .resizable()
        .scaledToFill()
} placeholder: {
    ProgressView()
}
.frame(width: 60, height: 60)
.clipShape(.rect(cornerRadius: 8))

AsyncImage lädt das Bild selbst, zeigt währenddessen den Platzhalter und dann das Bild. Mehr Möglichkeiten, etwa für Fehler, lernst du in Projekt D.

Typische Fehler

Kein Internet im Simulator

Der Simulator verwendet die Internetverbindung deines Mac. Ist der Mac offline, scheitern alle Anfragen mit einem Fehler wie The Internet connection appears to be offline.

http statt https

Apps dürfen standardmäßig nur verschlüsselte Verbindungen nutzen. Eine http://-Adresse wird blockiert, mit einer Meldung zur App Transport Security in der Konsole. Verwende immer https://.

keyNotFound beim Dekodieren

Das Modell passt nicht zur Antwort. Vergleich die Namen in CodingKeys mit dem JSON im Browser (Lektion 3.9).

Leere Liste ohne Fehler

try? verschluckt Fehler. Wenn nichts erscheint, ersetze es kurz durch do/catch und gib den Fehler mit print(error) aus. Dann siehst du, was wirklich passiert.

FAQ

Anfänger

Was bedeutet das überhaupt?

Kostet jede Anfrage etwas? Bei TheMealDB nicht. Viele andere APIs haben aber Grenzen, wie viele Anfragen pro Tag erlaubt sind, oder verlangen einen persönlichen Schlüssel. Lies die Bedingungen einer API, bevor du sie in einer App verwendest.

Wo speichere ich einen API-Schlüssel? Nie fest im Code einer App, die du veröffentlichst: Jeder könnte ihn aus der App auslesen. Für ernsthafte Projekte läuft der Zugriff deshalb meist über einen eigenen Server, der den Schlüssel geheim hält. Für Lernprojekte mit kostenlosen Testschlüsseln ist das kein Problem.

Funktioniert das auch ohne Internet? Nein. Wie du Daten zwischenspeicherst und ohne Netz anzeigst, lernst du mit SwiftData ab Lektion 3.14.

Fortgeschritten

Warum verhält es sich so?

Wie schicke ich Daten an einen Server? Mit einem URLRequest: var request = URLRequest(url: url), request.httpMethod = "POST", request.httpBody = try JSONEncoder().encode(wert) und request.setValue("application/json", forHTTPHeaderField: "Content-Type"). Dann try await URLSession.shared.data(for: request).

Lädt AsyncImage ein Bild jedes Mal neu? AsyncImage nutzt den eingebauten Zwischenspeicher von URLSession, hält Bilder aber nicht zuverlässig im Speicher. In langen Listen kann es beim Scrollen zu erneutem Laden kommen. Für anspruchsvolle Apps verwendet man deshalb einen eigenen Bild-Zwischenspeicher, etwa in einem Actor (Lektion 3.8).

Profi

Was passiert unter der Haube?

Läuft das Dekodieren auf dem Main Thread? Wenn MealAPI aus einer Main-Actor-View aufgerufen wird und der neue Standard aktiv ist (Lektion 3.8), ja. Für kleine Antworten ist das völlig in Ordnung. Bei sehr großen JSON-Antworten lohnt es sich, das Dekodieren in eine @concurrent-Funktion auszulagern. Die Modelltypen müssen dann nonisolated und Sendable sein.

Was ist der Unterschied zwischen URLSession.shared und einer eigenen Session? shared ist eine fertig konfigurierte Session für einfache Anfragen. Mit URLSession(configuration:) kannst du eigene Einstellungen wie Zeitlimits, Zwischenspeicher oder zusätzliche Header festlegen. Für Tests lässt sich über die Konfiguration sogar ein eigenes URLProtocol einhängen, das vorbereitete Antworten liefert, ganz ohne echtes Netzwerk.

Übungen

Anfänger

Übung 1: Zufallsrezept

Baue eine View, die beim Erscheinen mit random.php ein zufälliges Rezept lädt und Name, Kategorie und Herkunft (strArea) anzeigt. Ein Button „Anderes Rezept“ lädt ein neues.

Lösung Übung 1Erst selbst probieren!
Swift
struct RandomMealView: View {
    @State private var meal: Meal?
    private let api = MealAPI()

    var body: some View {
        VStack(spacing: 12) {
            if let meal {
                Text(meal.name).font(.title).bold()
                Text("\(meal.category ?? "–") · \(meal.area ?? "–")")
                    .foregroundStyle(.secondary)
            } else {
                ProgressView()
            }

            Button("Anderes Rezept") {
                Task { await load() }
            }
            .buttonStyle(.borderedProminent)
        }
        .multilineTextAlignment(.center)
        .padding()
        .task { await load() }
    }

    private func load() async {
        meal = try? await api.random()
    }
}
Fortgeschritten

Übung 2: Kategorien

Die Adresse https://www.themealdb.com/api/json/v1/1/categories.php liefert alle Kategorien. Schau dir die Antwort im Browser an, baue passende Decodable-Typen (Feldnamen beginnen mit str), ergänze MealAPI um eine Methode categories() und zeig die Kategorien mit Bild und Namen in einer Liste.

Profi

Übung 3: Fehler sichtbar machen

Ändere die Basisadresse absichtlich auf eine falsche Domain und dann auf einen falschen Pfad. Fang die Fehler mit do/catch ab und gib aus, um welche Art von Fehler es sich handelt: URLError (Netzwerk), APIError.badStatus (Server) oder DecodingError (Daten). Welcher Fehler tritt bei welcher Änderung auf?

Zusammenfassung

  • URLSession.shared.data(from: url) lädt Daten aus dem Internet, async throws.
  • URLComponents oder appending(queryItems:) bauen Adressen sicher, auch mit Sonderzeichen.
  • Den Statuscode über HTTPURLResponse prüfen: 200–299 ist Erfolg.
  • Die Antwort mit JSONDecoder in eigene Decodable-Typen übersetzen. Felder, die fehlen können, sind Optionals.
  • Netzwerkcode in einen eigenen Typ wie MealAPI auslagern.
  • AsyncImage lädt Bilder aus dem Internet mit Platzhalter.
  • Immer https verwenden.

Spickzettel

Swift
let base = URL(string: "https://www.themealdb.com/api/json/v1/1/")!
let url = base.appending(path: "search.php")
              .appending(queryItems: [URLQueryItem(name: "s", value: query)])

let (data, response) = try await URLSession.shared.data(from: url)
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
    throw APIError.badStatus(http.statusCode)
}
let meals = try JSONDecoder().decode(MealResponse.self, from: data).meals ?? []

AsyncImage(url: URL(string: meal.thumbnail ?? "")) { image in
    image.resizable().scaledToFill()
} placeholder: { ProgressView() }

Lektion geschafft?

Markiere sie als erledigt, damit du deinen Fortschritt siehst.