Tutorial API filme Swift cu SwiftUI
Acest tutorial construiește o aplicație iOS 16 în Swift 5.9 care arată filmele în tendințe ale săptămânii într-o listă SwiftUI cu postere, ani și scoruri. Folosește URLSession cu async/await și AsyncImage, tratează 401 și 429 și explică de ce o aplicație de producție ar trebui să apeleze amodb printr-un server propriu.
Ce construiești
Construiești Trending, o aplicație iOS cu un singur ecran: o listă a filmelor în tendințe ale săptămânii, cu poster, titlu, an și scor. Ai nevoie de Xcode cu Swift 5.9 sau mai nou, o țintă de implementare iOS 16 și o cheie amodb. Aplicația folosește URLSession cu async/await, modele Decodable și AsyncImage, fără pachete. Socotește cam 20 de minute.
O cheie dintr-o aplicație poate fi extrasă
Tratează orice secret livrat într-o aplicație ca fiind public. Cineva poate citi șiruri din binar, poate inspecta traficul de rețea sau poate citi stocarea aplicației. În acest tutorial cheia vine dintr-o variabilă de mediu din schema Xcode, ceea ce e în regulă pe propriul dispozitiv. Pentru o aplicație pe care o lansezi, rulează un mic server care ține cheia, retransmite apelul către amodb și întoarce JSON-ul. Setează baseURL în clientul de mai jos la acel server și autentifică-ți acolo utilizatorii. Limita de cereri se aplică pe cheie, deci un server îți permite și să ții în cache un răspuns pentru toți utilizatorii tăi.
Pasul 1: obține o cheie API
Compară planurile la prețuri. Fiecare plan întoarce toate datele, deci alegerea ține de cererile pe minut. Creează o cheie în panoul contului tău. Verifică-o dintr-un terminal înainte să scrii orice Swift:
Un 200 înseamnă că cheia funcționează. Un 401 înseamnă că este greșită. Pagina autentificare explică ambele moduri de a trimite cheia.
export AMODB_KEY=YOUR_API_KEY
curl -s -o /dev/null -w "%{http_code}\n" \
"https://api.amodb.dev/3/trending/movie/week?api_key=$AMODB_KEY"Pasul 2: creează proiectul Xcode
Creează un proiect iOS App numit Trending cu interfața SwiftUI și setează ținta minimă de implementare la iOS 16. Șterge ContentView.swift generat. În schema ta, deschide Edit Scheme, alege Run, apoi Arguments și adaugă o variabilă de mediu numită AMODB_KEY cu cheia ta. Xcode o transmite aplicației doar când rulezi din Xcode.
Pasul 3: trimite prima cerere
Citește cheia din mediu și trimite-o ca antet Bearer. Setează un timeout de 10 secunde, ca o conexiune blocată să se încheie.
let key = ProcessInfo.processInfo.environment["AMODB_KEY"] ?? ""
var request = URLRequest(url: URL(string: "https://api.amodb.dev/3/trending/movie/week?language=en-US")!)
request.timeoutInterval = 10
request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
print((response as? HTTPURLResponse)?.statusCode ?? 0, data.count)Pasul 4: decodează răspunsul și arată posterele
Răspunsul este o pagină cu un array results de 20 de elemente. Fiecare film are id, title, release_date, poster_path și vote_average, plus media_type setat la movie. Setează keyDecodingStrategy la .convertFromSnakeCase și declară proprietăți în camel case. Câmpurile pe care nu le declari sunt ignorate, ceea ce se potrivește câmpurilor care apar mai târziu. poster_path poate fi null, iar release_date poate lipsi sau poate fi un șir gol, deci modelul le declară pe ambele ca opționale.
poster_path începe cu o bară oblică, deci șirul de mai sus lipește corect dimensiunea și numele fișierului. Posterele vin de la img.amodb.dev, care nu cere cheie și ține imaginile în cache 30 de zile. Pagina imagini listează dimensiunile, iar tendințe și liste acoperă endpoint-ul.
struct Movie: Decodable, Identifiable {
let id: Int
let title: String
let releaseDate: String?
let posterPath: String?
let voteAverage: Double
var posterURL: URL? {
guard let posterPath else { return nil }
return URL(string: "https://img.amodb.dev/t/p/w185\(posterPath)")
}
}Pasul 5: tratează erorile și limitele de cereri
Mapează fiecare stare la o eroare pe care ecranul o poate afișa.
| Stare | Semnificație | Ce face aplicația |
|---|---|---|
| 401 | Cheie lipsă, greșită sau suspendată | Arată un mesaj, fără reîncercare. |
| 402 | Perioada planului s-a încheiat | Arată un mesaj. Reînnoiește la prețuri. |
| 429 | Ai depășit cererile pe minut | Așteaptă Retry-After secunde, cu plafon de 30, reîncearcă de cel mult două ori. |
| 500 sau 504 | Eroare pe partea serverului | Arată starea și oferă tragere pentru reîmprospătare. |
URLError | Offline, timeout, conexiune pierdută | Arată localizedDescription. |
Vezi erori și limite de cereri pentru fiecare cod.
switch status {
case 200..<300:
return try Self.decoder.decode(T.self, from: data)
case 401:
throw AmodbError.invalidKey
case 429 where attempt < 2:
let header = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Retry-After")
try await Task.sleep(for: .seconds(min(Int(header ?? "") ?? 5, 30)))
attempt += 1
case 429:
throw AmodbError.rateLimited
default:
throw AmodbError.server(status)
}Pasul 6: păstrează lista în cache
Marginea ține listele în tendințe în cache 30 de minute, deci o nouă cerere mai devreme întoarce aceleași filme. Limita de cereri numără oricum apelul. Păstrează ultimul rezultat în modelul de vizualizare și reîncarcă doar când este mai vechi de 30 de minute sau când utilizatorul trage pentru reîmprospătare.
Pasul 7: aplicația completă
Acest singur fișier conține modelele, clientul, modelul de vizualizare și vizualizările. Lipește-l peste fișierul de aplicație generat.
Rulează aplicația pe un simulator. Lista se umple după primul apel, iar tragerea în jos forțează o reîncărcare. Scorurile sunt vote_average agregat din listă, iar un titlu fără voturi încă arată 0.0. Pentru scorurile IMDb sau Kinopoisk, cere un film cu append_. Obiectul ratings conține imdb, kinopoisk, metacritic, rotten_tomatoes și tmdb, fiecare fiind null sau un obiect cu value și votes, după cum arată pagina evaluări.
import SwiftUI
// MARK: Models
struct TrendingPage: Decodable {
let results: [Movie]
}
struct Movie: Decodable, Identifiable {
let id: Int
let title: String
let releaseDate: String?
let posterPath: String?
let voteAverage: Double
var year: String { String((releaseDate ?? "").prefix(4)) }
var posterURL: URL? {
guard let posterPath else { return nil }
return URL(string: "https://img.amodb.dev/t/p/w185\(posterPath)")
}
}
// MARK: Client
enum AmodbError: LocalizedError {
case missingKey
case invalidKey
case rateLimited
case server(Int)
var errorDescription: String? {
switch self {
case .missingKey: return "Set AMODB_KEY in the Xcode scheme."
case .invalidKey: return "The API key was rejected (401)."
case .rateLimited: return "Too many requests. Try again in a minute."
case .server(let status): return "The service answered with status \(status)."
}
}
}
struct AmodbClient {
var baseURL = URL(string: "https://api.amodb.dev/3")!
let apiKey: String
private static let decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return decoder
}()
func get<T: Decodable>(_ path: String, query: [URLQueryItem] = []) async throws -> T {
guard !apiKey.isEmpty else { throw AmodbError.missingKey }
var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false)!
components.queryItems = query
var request = URLRequest(url: components.url!, timeoutInterval: 10)
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
var attempt = 0
while true {
let (data, response) = try await URLSession.shared.data(for: request)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
switch status {
case 200..<300:
return try Self.decoder.decode(T.self, from: data)
case 401:
throw AmodbError.invalidKey
case 429 where attempt < 2:
let header = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Retry-After")
try await Task.sleep(for: .seconds(min(Int(header ?? "") ?? 5, 30)))
attempt += 1
case 429:
throw AmodbError.rateLimited
default:
throw AmodbError.server(status)
}
}
}
}
// MARK: View model
@MainActor
final class TrendingViewModel: ObservableObject {
@Published private(set) var movies: [Movie] = []
@Published private(set) var errorMessage: String?
@Published private(set) var isLoading = false
private let client: AmodbClient
private var loadedAt: Date?
init(client: AmodbClient) {
self.client = client
}
func load(force: Bool = false) async {
if !force, let last = loadedAt, !movies.isEmpty, Date().timeIntervalSince(last) < 30 * 60 {
return
}
isLoading = true
defer { isLoading = false }
do {
let page: TrendingPage = try await client.get(
"trending/movie/week",
query: [URLQueryItem(name: "language", value: "en-US")]
)
movies = page.results
loadedAt = Date()
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
}
}
// MARK: Views
struct MovieRow: View {
let movie: Movie
var body: some View {
HStack(spacing: 12) {
AsyncImage(url: movie.posterURL) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFill()
case .failure:
Color.gray.opacity(0.2)
default:
ProgressView()
}
}
.frame(width: 60, height: 90)
.clipShape(RoundedRectangle(cornerRadius: 6))
VStack(alignment: .leading, spacing: 4) {
Text(movie.title).font(.headline)
Text(movie.year).font(.subheadline).foregroundStyle(.secondary)
Label(String(format: "%.1f", movie.voteAverage), systemImage: "star.fill")
.font(.caption)
}
}
}
}
struct ContentView: View {
@StateObject private var model: TrendingViewModel
init(client: AmodbClient) {
_model = StateObject(wrappedValue: TrendingViewModel(client: client))
}
var body: some View {
NavigationStack {
List(model.movies) { movie in
MovieRow(movie: movie)
}
.overlay {
if model.isLoading && model.movies.isEmpty {
ProgressView()
} else if let message = model.errorMessage, model.movies.isEmpty {
Text(message).multilineTextAlignment(.center).padding()
}
}
.navigationTitle("Trending")
.refreshable { await model.load(force: true) }
.task { await model.load() }
}
}
}
@main
struct TrendingApp: App {
private let client = AmodbClient(apiKey: ProcessInfo.processInfo.environment["AMODB_KEY"] ?? "")
var body: some Scene {
WindowGroup {
ContentView(client: client)
}
}
}Pașii următori
- Adaugă un ecran de detalii care încarcă
/movie/{id}cuappend_și vezi filme pentru câmpuri.to_ response= credits, ratings - Schimbă query item-ul
languagela limba dispozitivului, folosind cele 14 limbi de bază de pe pagina limbi. - Mută cheia pe serverul tău și îndreaptă
baseURLspre el înainte să publici aplicația. - Construiește aceeași listă pentru web cu Next.js.