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,.taskund@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:
https://www.themealdb.com/api/json/v1/1/search.php?s=pastaDu siehst JSON, ungefähr so (stark gekürzt):
{
"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:
| Teil | Beispiel | Bedeutung |
|---|---|---|
| Schema | https | verschlüsselte Verbindung |
| Host | www.themealdb.com | der Server |
| Pfad | /api/json/v1/1/search.php | welche Funktion der API |
| Abfrage | ?s=pasta | Parameter, 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):
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
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:
URL(string:)erzeugt aus einem Text eine Adresse. Das Ergebnis ist ein Optional, denn nicht jeder Text ist eine gültige Adresse.URLSession.shared.data(from: url)stellt die Anfrage und wartet auf die Antwort. Die Funktion istasync throwsund liefert ein Tupel aus den empfangenen Bytes und Informationen über die Antwort. Die brauchen wir hier noch nicht, deshalb_.JSONDecoderübersetzt die Bytes in unsere Typen.
Und in einer View:
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.
(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:
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%A9eKürzer geht es mit einer bestehenden Basisadresse:
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:
| Code | Bedeutung |
|---|---|
| 200–299 | Erfolg |
| 404 | nicht gefunden |
| 401, 403 | keine Berechtigung |
| 500–599 | Fehler auf dem Server |
Auch bei einem 404 kommt eine Antwort an, nur eben keine mit den erwarteten Daten. Deshalb prüft man den Code:
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:
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
fetchenthält die gemeinsame Logik: Adresse bauen, Anfrage stellen, Status prüfen, dekodieren. - Die öffentlichen Methoden
search,meal(id:)undrandom()sind kurz und sprechend. - Die
URLSessionwird im Initializer übergeben, mit.sharedals Standard. Für Tests kann man später eine andere übergeben (Lektion 4.13).
Die View wird dadurch sehr schlicht:
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:
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
Ü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!
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()
}
}Ü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.
Ü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.URLComponentsoderappending(queryItems:)bauen Adressen sicher, auch mit Sonderzeichen.- Den Statuscode über
HTTPURLResponseprüfen: 200–299 ist Erfolg. - Die Antwort mit
JSONDecoderin eigeneDecodable-Typen übersetzen. Felder, die fehlen können, sind Optionals. - Netzwerkcode in einen eigenen Typ wie
MealAPIauslagern. AsyncImagelädt Bilder aus dem Internet mit Platzhalter.- Immer
httpsverwenden.
Spickzettel
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() }